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
| Credential | Grants | Lives in | Rotation |
|---|---|---|---|
| Share link key/slug | That album, anonymously | The URL; whoever it’s forwarded to | Delete 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 itself | nginx 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 logins | Photos, per-user | Family password managers | Immich admin |
| ssh private keys | Shell on Tower → LAN | Each device; public halves on GitHub | Remove from GitHub (live) / authorized_keys (break-glass) |
| GitHub account | Add key → shell on Tower → LAN. This is a house key. | — | 2FA is load-bearing |
| WG peer keys (6) | LAN via Pi | Devices + Pi config | pivpn remove; 4 peers dormant >12d |
| CF API token | DNS control → could issue certs, MITM | ~/.env Mac+lab, /root/.secrets/cloudflare.ini | Dashboard 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 major —
IMMICH_VERSION=v3pins 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 onssh 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 | tailnames 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-scriptsResiduals (open items)
Upgrade Immich— done 2026-07-02 (v3.0.1) + nightly auto-update within major.- Prune
~ns/.ssh/authorized_keyson Tower — 8 entries; should be ~1 break-glass (GitHub is canonical). - Prune 4 dormant WG peers on the Pi (
pivpn clients/ remove). - Trim CF token to
Zone → DNS → Editonly. - Router forward table (only via router UI): expect exactly 22/tcp→Tower, 443/tcp→Tower, 51820/udp→Pi. Anything else is unaccounted.
- Optional: fail2ban jail on repeated 444s (slows allowlist enumeration; low value).