Security posture — public exposure

Audited 2026-07-02. The internet-facing surface of the home network, what gates what, failure chains, and operational duties. Config lives in services/ (nginx/public.conf, ssh/, fail2ban/); background in drop-cloudflared-direct-exposure.md.

Threat model: untargeted mass scanning for known vulnerabilities. Not defended against: targeted attackers with 0-days, or compromise of an owned device.

Surface map (everything the internet can reach)

:22/tcp  (Tower) sshd ─[keypair: ~/.ssh/authorized_keys ∪ github.com/Suputra.keys]─ shell as ns
:443/tcp (Tower) nginx ─[TLS + exact SNI, else handshake refused — no cert, no banner]
    ├─ anonymous ─[allowlist]─ /share, /s, /_app statics, 4 bootstrap endpoints,
    │      share-API handlers (key/slug param required) ─[Immich key validation]─ that album only
    ├─ X-Lab-Key header / lab_key cookie / /gate/<key> ─ all of Immich ─[Immich login]─ photos
    ├─ same key ─ box app (UI /api /ws → 127.0.0.1:8000) + full private tree (box-data/files)
    └─ anonymous ─ box.saah.as/<name> ─[public/ symlink exists?]─ that shared item only
:51820/udp (Pi) WireGuard ─[peer keypair; silent to everything else]─ LAN

Everything else: connection closed, zero bytes (return 444). Scanners can fingerprint “Immich exists here” from the share endpoints, but not its version (/api/server/version|about are gated).

box (replaced pad 2026-07; pad.saah.as retired clean, no redirect). Anonymous surface is static bytes only — nginx try_files over box-data/public/ share symlinks; no app code is anonymously reachable, and no autoindex means the shared set can’t be enumerated (a share is a capability URL, like an Immich share link). Every app-side path passes one chokepoint (safe_path: traversal/absolute/symlink-escape/reserved names; safe_extract: zip-slip) with adversarial tests as the definition of done (services/box/test_box.py). CSP-sandbox same-origin isolation, load-bearing: shared documents live on the app’s origin, and the browser would attach the HttpOnly gate cookie to same-origin requests — so a shared page’s JS opened by a key-holder could otherwise call /api/delete as them. nginx adds Content-Security-Policy: sandbox allow-scripts to disk-served documents (.html/.htm/.svg/.xml): opaque origin, no cookie attached, no readable API responses, no form posts. Don’t remove it casually; relax per-name if a shared page ever legitimately needs same-origin powers. The /v/<name> viewer shell is trusted repo code (not user content) and runs unsandboxed — it must only handle user bytes via embeds, a sandboxed iframe, or textContent, never innerHTML/markdown-render (that would hand a shared note script the key-holder’s origin).

Credential inventory

CredentialGrantsLives inRotation
Share link key/slugThat album, anonymouslyThe URL; whoever it’s forwarded toDelete link in Immich; per-link expiry+password available
LAB_KEY (64 hex)Reachability of Immich API + all of box (files, upload, share toggle) — no photo data by itselfnginx config on Tower, phones’ Immich settings, browser cookies, password manager, ~/.env on Mac (box CLI)openssl rand -hex 32, sed nginx config, reload, update phones + ~/.env
Immich loginsPhotos, per-userFamily password managersImmich admin
ssh private keysShell on Tower → LANEach device; public halves on GitHubRemove from GitHub (live) / authorized_keys (break-glass)
GitHub accountAdd key → shell on Tower → LAN. This is a house key.2FA is load-bearing
WG peer keys (6)LAN via PiDevices + Pi configpivpn remove; 4 peers dormant >12d
CF API tokenDNS control → could issue certs, MITM~/.env Mac+lab, /root/.secrets/cloudflare.iniDashboard roll; currently over-scoped (Access/Zone/Firewall write)

Failure chains

To all photos (ranked): ① pre-auth bug in Immich’s share-key handlers — the only single-gate path; anonymous internet reaches ~6 handler families; defense = Immich patch promptness. ② LAB_KEY leak + Immich account creds (two secrets; login endpoint isn’t anonymously reachable, so no credential stuffing without the key). ③ Tower compromise (below). ④ nginx pre-auth RCE (tail risk; auto-patched).

To desktop/network: ① GitHub account takeover → ssh. ② Stale key in authorized_keys or dormant WG peer from a lost device. ③ sshd pre-auth 0-day (patching + fail2ban’s rate limit). ④ RCE in Immich (docker-contained, not VM) or box (LAB_KEY-gated, bound to 127.0.0.1) → Tower → LAN is flat, no segmentation. Tower is the soft center; CF token on it extends blast radius to DNS.

Operational duties (what keeps this safe over time)

  • Immich: auto patch/minor, manual majorIMMICH_VERSION=v3 pins the major; immich-update.timer (nightly ~04:30) pulls within it, health-checks ping, prunes old images. A new major or a failed health check writes to /etc/motd (seen on ssh lab) + journal; majors need a migration-guide read, DB dump, and a one-line pin bump. Upgraded 2026-07-02: v2.7.5 → v3.0.1.
  • sshd/nginx/openssl: unattended-upgrades (verified on).
  • Certs: certbot timer (verified active), DNS-01, auto nginx reload.
  • fail2ban: sshd jail, verified detecting real failures.
  • Share page breaks after an Immich upgrade → sudo grep " 444 " /var/log/nginx/access.log | tail names the missing path; add one map row.

Gate self-test (run after config changes)

curl -s https://pics.saah.as/api/server/version        # closed (000)
curl -s https://pics.saah.as/api/server/config          # 200
curl -s "https://pics.saah.as/api/shared-links/me?key=x" # 401 (Immich rejecting, gate passing)
curl -s https://pics.saah.as/                            # closed
curl -s -H "X-Lab-Key: $KEY" https://pics.saah.as/api/server/ping  # pong
 
curl -s https://box.saah.as/                             # closed (000) — gated app
curl -s -H "X-Lab-Key: $KEY" https://box.saah.as/api/list # 200 JSON
curl -s https://box.saah.as/scratch.md                    # closed — private, unshared (THE invariant)
curl -sI https://box.saah.as/<shared>.html | grep -i content-security-policy  # sandbox allow-scripts

Residuals (open items)

  1. Upgrade Immich — done 2026-07-02 (v3.0.1) + nightly auto-update within major.
  2. Prune ~ns/.ssh/authorized_keys on Tower — 8 entries; should be ~1 break-glass (GitHub is canonical).
  3. Prune 4 dormant WG peers on the Pi (pivpn clients / remove).
  4. Trim CF token to Zone → DNS → Edit only.
  5. Router forward table (only via router UI): expect exactly 22/tcp→Tower, 443/tcp→Tower, 51820/udp→Pi. Anything else is unaccounted.
  6. Optional: fail2ban jail on repeated 444s (slows allowlist enumeration; low value).