diff --git a/documentation/audits/RECON-subdomain-onboarding-2026-07-31.md b/documentation/audits/RECON-subdomain-onboarding-2026-07-31.md new file mode 100644 index 0000000..c4aeea5 --- /dev/null +++ b/documentation/audits/RECON-subdomain-onboarding-2026-07-31.md @@ -0,0 +1,482 @@ +# RECON — can a tester get a Felhom-issued subdomain instead of owning a domain? (2026-07-31) + +**Read-only.** No customer, DNS record, tunnel, route or code change was created. Two hub login +sessions were created (in-memory, 7-day expiry) to test cookie precedence; nothing else was mutated. + +**Headline:** the *product* accepts a Felhom-issued subdomain today with **no code change**. The +blocker is not Felhom — it is **Cloudflare's edge certificate depth**, proven live: a two-label +hostname under a zone gets `tls alert handshake failure` and **no certificate at all**. Everything +else follows from that. + +--- + +## 1. Baselines — read fresh, none carried + +| What | Value | How | +|---|---|---| +| controller `main` | **v0.188.0** | `HEAD`=`origin/main`=`4115e88`, clean | +| controller on `demo-felhom` (9201) | 0.187.0 | `pct exec 9201 -- cat /etc/felhom-controller-image` | +| controller on `demo-hp` (9201) | 0.187.0 | same, on demo-hp | +| golden-baked controller | **0.188.0** (sha `7353d8be…`) | hub `/configuration`, `selected` option | +| agent published + vouched | **0.119.0** (sha `e37aca82…`) | hub manifest; both boxes run 0.119.0 | +| hub | **0.84.0** | `deploy/hub` image tag | +| ISO | v1.25.0 (`scripts/iso/build-felhom-iso.sh:44`); host-install v1.22.0 | source read | +| `felhom.eu` HEAD | **`b4edc08`** | `git rev-parse` | +| MinAgent | 0.113.0 | hub `/configuration` | + +Both the hub (0.83.0→0.84.0) and the agent (0.118.1→0.119.0) moved since this morning's tester-gate +run, which is exactly why the brief says to read rather than carry. + +**Not mine, left untouched:** `hub/internal/api/testdata/host-report.golden.json` is dirty in the +shared worktree — another session's WIP. Not staged, not reverted. + +--- + +## 2. Question A — does the product accept a Felhom-issued subdomain? + +### 2.1 The domain's full path, hop by hop + +| # | Hop | Citation | What happens | +|---|---|---|---| +| 1 | Operator form | `hub/internal/web/configs.go:673` | `Domain: strings.TrimSpace(r.FormValue("domain"))` — trimmed, nothing else | +| 2 | Hub DB | `hub/internal/store/store.go:114` | `domain TEXT NOT NULL DEFAULT ''` — **no UNIQUE, no CHECK, no format** | +| 3 | `controller.yaml` generation | `hub/internal/configgen/configgen.go:48` | `setNested(base, []string{"customer","domain"}, cfg.Domain)` — verbatim copy | +| 4 | Box pull | `felhom-host-install.sh` `config-retrieve` → `/opt/docker/felhom-controller/controller.yaml` | verified present on demo-felhom | +| 5 | Controller config load | `controller/internal/config/config.go:363,379` | env override `FELHOM_CUSTOMER_DOMAIN`; **the only check is non-empty** | +| 6 | Consumers | 30 call sites (§2.3) | all plain string interpolation | + +### 2.2 Constraints found — the answer is "almost none" + +- **No format validation anywhere.** Grepped the hub and controller for `valid|regexp|match|check` near + `domain`: no hits on the customer domain. +- **No registrable-domain / eTLD logic anywhere.** `grep -riE 'registrable|etld|publicsuffix'` across + both repos: **zero hits**. Nothing computes "the registrable part" of a customer domain. +- **No "must not be a subdomain" rule.** None exists. +- **No uniqueness on domain.** `hub/internal/web/configs.go:644` rejects a duplicate **`customer_id`** + only. Two customers may be given the identical domain string with no complaint (→ **R-133**). +- **The only permissive-pattern check** is the agent's `internal/lanresolver/lanresolver.go:225` + `domainRe = ^[ \t]*domain:[ \t]*"?([A-Za-z0-9.-]+)"?` — dots allowed, so a subdomain parses fine. + +### 2.3 What consumes it on the box — all interpolation, no parsing + +| Surface | Citation | Shape | +|---|---|---| +| Controller Traefik route | `controller/internal/infra/infra.go:214` | ``Host(`felhom.%s`)`` | +| Wildcard TLS SAN | same, `:217-223` | `main: "*."`, `sans: [""]` | +| App Traefik rules | catalog compose | ``Host(`${SUBDOMAIN}.${DOMAIN}`)`` | +| App env | `controller/internal/stacks/manager.go:1187`, `deploy.go:560` | `DOMAIN=` | +| Controller-host guard | `controller/internal/web/server.go:571` | `controllerHost := "felhom." + domain` | +| Report / DR recipe | `controller/internal/report/builder.go:53` | `https://felhom.` | +| FileBrowser | `controller/internal/stacks/infra.go:157` | domain passed to compose render | +| Share launcher, escrow, export, claim, data-path cards | 9 further sites | template data only | +| SMB | `controller/internal/infra/samba.go:65` | NetBIOS name from `smb.EffectiveServerName()` — **not the domain** | + +**None of these parse the domain.** A dotted subdomain flows through every one of them unchanged. + +### 2.4 Does anything assume the customer owns the zone? — Yes, but it is a *switch*, not a requirement + +This is the question the brief flagged, and the answer is better than feared. The ACME challenge is +**config-selected** at `controller/internal/infra/templates/traefik.yml.tmpl`: + +```yaml +certificatesResolvers: + letsencrypt: + acme: + email: {{.ACMEEmail}} +{{- if .CFAPIToken}} + dnsChallenge: + provider: cloudflare +{{- else}} + httpChallenge: + entryPoint: web +{{- end}} +``` + +- **With `cf_api_token`** → DNS-01, which **does** need `Zone:DNS:Edit` for that zone, and the token is + written to a 0600 `.env` on the box (`controller/internal/infra/infra.go:123`). +- **Without it** → **HTTP-01**, which needs no DNS credential at all. + +The wildcard SAN is gated on the same flag: `controller/internal/stacks/infra.go:211` — +`wildcardTLS := CFAPIToken != "" && Customer.Email != ""`. + +**Live on demo-felhom:** DNS-01 is what actually runs — `traefik.yml` shows +`dnsChallenge: provider: cloudflare`, a 0600 `.env` exists, `cloudflared` is up, and the dynamic route +carries `main: "*.demo-felhom.eu"`. So both demo boxes hold a zone-write Cloudflare token **because +they own their zones**, not because the code demands one. + +### 2.5 Does anything create DNS records automatically? — No + +`grep -rn 'dns_records'` across both repos: **zero hits**. Neither the hub nor the controller ever +creates, updates or deletes a DNS record. The two Cloudflare clients that exist are **WAF-only**: + +- `hub/internal/cloudflare/unblock.go` — deletes `[felhom-geo]` WAF rules. +- `controller/internal/cloudflare/{waf,geosync,zone}.go` — reconciles `[felhom-geo]` WAF rules. + +The cloudflared template says it outright: *"Routes are configured in the Cloudflare dashboard +(Zero Trust > Networks > Tunnels > Public Hostname)"*. **All DNS and tunnel routing is manual operator +work today.** + +### 2.6 Verdict on Question A + +> **YES — the product accepts a Felhom-issued subdomain today, with no code change.** + +`customer.domain = "t1."` flows through the hub, `controller.yaml`, Traefik, the +tunnel, app env and every URL builder without touching a single constraint. Nothing needs DNS API +credentials **if** `cf_api_token` is left empty (HTTP-01), and nothing creates DNS records either way. + +**The blocker is elsewhere** — see Question B.4. Two code-level caveats that are not blockers: + +- **Zone-resolution depth asymmetry (→ R-134).** The controller's `GetZoneID` + (`controller/internal/cloudflare/zone.go:18`) strips labels **progressively** (any depth). The hub's + `resolveZone` (`hub/internal/cloudflare/unblock.go:115`) tries the exact name and then + `parentDomain` — which strips exactly **one** label (`:136`). For `t1.felhomcust.hu` both succeed; + for a two-label customer domain the hub silently fails to find the zone while the controller + succeeds. Two implementations of one concept, disagreeing. +- **Geo-restriction must stay OFF** for shared-zone customers — Question D, and it is severe. + +--- + +## 3. Question B — Cloudflare routing + +### 3.1 How the demo boxes reach the internet + +**One tunnel per customer, created by hand.** `cf_tunnel_token` is a per-customer field the operator +pastes into the hub (`hub/internal/web/configs.go:1338`), stored in `config_json.infrastructure`, +delivered in `controller.yaml`, and passed straight to `cloudflared` as `TUNNEL_TOKEN` +(`controller/internal/infra/templates/cloudflared-compose.yml.tmpl`). If it is empty, cloudflared is +simply not deployed — `controller/internal/stacks/infra.go:67` logs *"LAN-only node"*. The token **is** +the entire config; there is no `config.yml`, and public hostnames live only in the dashboard. + +### 3.2 The live DNS picture + +``` +felhom.eu A 37.191.56.193 (NOT proxied) +hub.felhom.eu CNAME dooplex.hopto.org → (NOT proxied — no cf-ray, no server: cloudflare) +files.felhom.eu CNAME dooplex.hopto.org +ep0.felhom.eu A 167.233.158.164 +demo-felhom.eu (apex: no A) +felhom.demo-felhom.eu 188.114.97.0 / 188.114.96.0 (Cloudflare proxy) +poll.demo-felhom.eu 188.114.97.0 / 188.114.96.0 (Cloudflare proxy) +felhom.enkisfelhom.hu 188.114.96.0 / 188.114.97.0 (Cloudflare proxy) +``` + +All four zones (`felhom.eu`, `demo-felhom.eu`, `enkisfelhom.hu`, `sajatfelhom.hu`) sit on the **same +Cloudflare nameservers** (`martha`/`tony.ns.cloudflare.com`) — one account. + +**Two facts that matter, both established rather than assumed:** + +1. **`felhom.eu` is NOT behind Cloudflare.** `hub.felhom.eu` and `felhom.eu` return no `cf-ray` and no + `server: cloudflare`; they resolve through a DDNS name to a home IP and are served by DooPlex's k3s + nginx ingress. The hub is *not* protected by, or reachable through, the tunnel fabric. +2. **There is no `*.felhom.eu` wildcard today.** A random label under `felhom.eu` resolves to + **nothing**; the same probe under `demo-felhom.eu` and `enkisfelhom.hu` returns Cloudflare proxy + IPs. So the cookie exposure in Question C is **latent, not live** — it becomes live the moment a + tester box is put under `felhom.eu`. + +### 3.3 Proxied wildcards — verified, not remembered + +The brief warns against trusting a remembered plan restriction. **Checked empirically:** a random +label under a customer zone resolves to Cloudflare **proxy** anycast IPs and returns a real HTTP +response with `server: cloudflare` and a `cf-ray`. So a **proxied wildcard DNS record works on this +account today** — the historical "wildcard proxying is Enterprise-only" restriction does not bind here. + +Two wildcards in the same account already route to **two different tunnels** (each box runs its own +cloudflared with its own token). Distinct wildcard records at distinct names +(`*.t1.zone` → tunnel A, `*.t2.zone` → tunnel B) are ordinary DNS with no conflict. + +### 3.4 The actual blocker — edge certificate depth + +**Proven live, and this is the finding that decides the shape of the whole plan.** + +The Cloudflare edge certificate for a zone covers **exactly one wildcard level**: + +``` +$ openssl s_client -connect poll.demo-felhom.eu:443 -servername poll.demo-felhom.eu +subject=CN=demo-felhom.eu +issuer=C=US, O=Google Trust Services, CN=WE1 +X509v3 Subject Alternative Name: + DNS:demo-felhom.eu, DNS:*.demo-felhom.eu +``` + +A **two-label** hostname resolves — the wildcard DNS record matches at any depth — but the TLS +handshake is refused outright: + +``` +$ openssl s_client -connect zz.poll.demo-felhom.eu:443 -servername zz.poll.demo-felhom.eu +CONNECTED(00000003) +ssl3_read_bytes:tls alert handshake failure ... SSL alert number 40 +no peer certificate available + +$ curl https://zz.poll.demo-felhom.eu/ +curl: (35) TLS connect error: ... tls alert handshake failure +$ curl https://poll.demo-felhom.eu/ # one-level control +HTTP 404 # TLS fine; just no tunnel route for a random name +``` + +**Consequence.** A Felhom-issued subdomain means `customer.domain = t1.felhomcust.hu`, so the +controller lives at `felhom.t1.felhomcust.hu` and apps at `poll.t1.felhomcust.hu` — **two labels under +the zone**. Under Universal SSL those hostnames get **no certificate**, and the tester's browser gets a +hard TLS error before anything Felhom-related runs. + +Options, in order of how much I can vouch for them: + +| Option | Cost | Status | +|---|---|---| +| **Cloudflare Advanced Certificate Manager / Total TLS** — issue `*.t1.felhomcust.hu` at the edge | paid add-on (~$10/mo, one zone) | **Could not verify read-only** — needs the dashboard or a token I am fenced from using. **This is the single thing to check before committing to the plan.** | +| **One zone per tester** | one domain per tester, ~2–4k HUF/yr each | Works today, no code change — but it is the cost the plan exists to avoid | +| **Flat naming** (`poll-t1.felhomcust.hu`) | — | **Does not work without code change.** The controller hard-derives `felhom.` (`infra.go:214`, `web/server.go:571`); every tester's controller would want the identical `felhom.felhomcust.hu` | +| **Grey-cloud + DNS-01 wildcard on the box** | — | Traefik can issue `*.t1.felhomcust.hu` itself via DNS-01, and the browser would see *that* cert — but grey-cloud means **no tunnel**, so the box needs a public IP and port-forwarding. Loses the reason the tunnel exists | + +### 3.5 What must exist for one tester box — the create-list + +Assuming a Felhom-owned zone `felhomcust.hu` already on Cloudflare, and ACM/Total TLS confirmed: + +| # | Where | Action | +|---|---|---| +| 1 | Cloudflare → zone `felhomcust.hu` → SSL/TLS → Edge Certificates | Enable **Total TLS / ACM** so depth-2 hostnames get a cert. **Verify first — §3.4.** | +| 2 | Cloudflare → Zero Trust → Networks → Tunnels | **Create tunnel** `felhom-t1`. Copy its token (this is the `cf_tunnel_token`) | +| 3 | Same tunnel → **Public Hostname** | `felhom.t1.felhomcust.hu` → `https://traefik:443` (or per the existing demo tunnels' pattern — **I could not read those routes, §8**) | +| 4 | Same tunnel → Public Hostname, one per app | e.g. `poll.t1.felhomcust.hu` → same origin. Cloudflare creates the proxied CNAME to `.cfargotunnel.com` automatically when you add a public hostname | +| 5 | Cloudflare → DNS (only if you prefer a wildcard over per-app entries) | `*.t1.felhomcust.hu` CNAME → `.cfargotunnel.com`, **proxied**. Verified workable in §3.3 | +| 6 | Hub → Customers → + Add Customer | `customer_id` = `t1`; `domain` = **`t1.felhomcust.hu`**; `email` = **the tester's real mailbox** (see §6); DR tier / offsite per policy | +| 7 | Hub → the customer's Edit tab | Paste `cf_tunnel_token` from step 2. **Leave `cf_api_token` EMPTY** — §2.4 (HTTP-01), §5 (a shared-zone token is a cross-tenant write capability) | +| 8 | — | **Do not enable geo-restriction** for this customer. §5. | + +No DNS record and no tunnel is created by Felhom; steps 1–5 are entirely operator work in the +Cloudflare dashboard. + +--- + +## 4. Question C — cookies, CSRF, and the domain recommendation + +### 4.1 The cookie, reconfirmed at source and on the wire + +`hub/internal/web/server.go:656-663` sets `hub_session` with `Path: "/"`, `HttpOnly: true`, +`SameSite: SameSiteLaxMode`, `Secure: isSecure`, **no `Domain`**, **no `__Host-` prefix** — as the +brief states. Confirmed on the live production path: + +``` +set-cookie: hub_session=; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax +``` + +`isSecure` is computed from `r.TLS != nil || X-Forwarded-Proto == "https"`. Over the k3s ingress that +holds; over the plain-HTTP ClusterIP the same login returns the cookie **without `Secure`**. + +### 4.2 What the hub does with two same-named cookies — tested, not reasoned + +Against the live hub, `GET /configuration`, varying only the `Cookie:` header: + +| Cookie header | Result | +|---|---| +| `hub_session=` | **HTTP 200 — authenticated** | +| `hub_session=BOGUS; hub_session=` | **HTTP 302 → /login — rejected** | +| `hub_session=; hub_session=BOGUS` | **HTTP 200 — authenticated** | +| `hub_session=BOGUS` | HTTP 302 → /login | + +**`r.Cookie("hub_session")` returns the FIRST match in header order and never tries the others.** A +tossed cookie that sorts first therefore wins outright, and the real session is silently ignored. + +**Exploitability, stated precisely.** Per RFC 6265 §5.4 a browser orders cookies **longer `Path` +first**. Any page on any `*.felhom.eu` host can set +`hub_session=X; Domain=felhom.eu; Path=/configuration` — a longer path than the real `Path=/` cookie — +so the browser sends the attacker's value first for every `/configuration*` request. + +What that does and does not buy an attacker: + +- **It is not session takeover.** The attacker cannot mint a value present in the hub's in-memory + `sessions` map, so the tossed cookie never authenticates. +- **It is a persistent, hard-to-diagnose denial of service** against the operator UI, on paths of the + attacker's choosing, invisible in the UI and not clearable from the hub. +- **It fails closed on mutations.** `csrfToken(r)` (`server.go:698`) reads the same first cookie and + returns `""`, and `validateCSRF` (`:678`) returns false for an unknown session → 403. + +### 4.3 What the CSRF defence actually rests on + +**A single choke point, and it is the token.** `hub/internal/web/server.go:256-269` gates *every* +non-GET/HEAD/OPTIONS web route before the route switch, exempting only `/login`, `isPublicBindPath`, +and the no-password-configured case. `/api/v1/` is Bearer-authenticated and separately exempt. So the +token is enforced on every mutating web route — the brief's worry that it "would be carrying the whole +load" is correct, and it does carry it. + +Measured, using `POST /zz-recon-nonexistent` so the CSRF gate is isolated with **zero** mutation +(the middleware at `:256` runs before the route switch at `:271`; a 404 therefore means "past the +gate"): + +| Request | Result | +|---|---| +| session cookie, **no** `_csrf` | **403 — CSRF enforced** | +| session cookie, valid `_csrf` | 404 — past the gate | +| **Basic auth, no cookie, no `_csrf`** | **404 — CSRF SKIPPED** | +| Basic auth + bogus cookie, no `_csrf` | 403 — CSRF enforced | + +The third row is `validateCSRF`'s documented early return: *"No session cookie — likely Basic Auth or +programmatic access; skip CSRF"* (`server.go:679-683`). + +**Why that is a real hole and not just an API convenience (→ R-135).** Browsers cache HTTP Basic +credentials per origin and **resend them automatically, including on cross-origin requests**, and +`SameSite` does not govern the `Authorization` header. So if the operator has ever authenticated to +`hub.felhom.eu` with Basic auth in their browser, an attacker page anywhere — not merely same-site — +can POST to any mutating hub route: the credentials ride along, auth passes, and CSRF is skipped +because there is no cookie. The condition ("operator used Basic auth in a browser") is what makes this +latent rather than live, and it is a condition, not a guarantee. + +*Note on `SameSite=Lax`:* against a genuinely cross-site attacker it already blocks the cookie, so the +cookie path is safe there. Against a **same-registrable-domain** attacker (`tester.felhom.eu` → +`hub.felhom.eu`) `Lax` provides **no boundary at all**, and the CSRF token is the only thing left. + +### 4.4 Should customer boxes live under `felhom.eu`? — No. And that is necessary, not sufficient. + +**I agree with the recommendation, for the stated reason, and it is cheap.** A different registrable +domain makes tossing structurally impossible, restores `SameSite` as a real boundary, and permanently +answers "the tester owns no domain" for every future customer at one domain's annual cost. + +**But my findings say it does not finish the job, and the brief asked to be told if so:** + +1. **Question D is not solved by moving zones** — it is a *shared-zone* problem, and the new domain is + a shared zone. The geo-WAF collisions in §5 apply identically there. +2. **The nesting requirement collides with edge-certificate depth** (§3.4). The separate domain + *forces* two-label hostnames, which is precisely what Universal SSL will not serve. **The separate + domain makes ACM a prerequisite, not an optional extra.** That is a real, ongoing cost the + recommendation should carry openly. + +So: **adopt the separate registrable domain, and treat Cloudflare ACM/Total TLS on that zone as part of +its price.** If ACM turns out to be unavailable or unexpectedly expensive, the honest fallback is one +cheap zone per tester — which keeps every property except the "one domain forever" economics. + +**Independently, and regardless:** rename the cookie to `__Host-hub_session`. Verified on the live +production response that all three prefix preconditions already hold (`Secure`, `Path=/`, no `Domain`), +so it genuinely is a one-line change — with **one caveat worth writing into the ticket**: a browser +rejects a `__Host-` cookie that lacks `Secure`, and `isSecure` is conditional, so any plain-HTTP +browser access to the hub would stop working. Non-browser access uses Basic auth and is unaffected. +Filed as **R-136**; **not implemented here.** + +--- + +## 5. Question D — what breaks with many customers in one zone + +**What I checked, including what came back clean:** + +| Surface | Checked | Result | +|---|---|---| +| Hub uniqueness on `domain` | `store.go:114`, `configs.go:644` | **No constraint** — duplicate domains accepted silently (**R-133**) | +| App subdomain collisions | catalog `Host(${SUBDOMAIN}.${DOMAIN})` | **Clean** if each tester has their own label — `poll.t1.z` vs `poll.t2.z` | +| Controller hostname | `infra.go:214`, `web/server.go:571` | **Clean with nesting**, fatal with flat naming — every box wants `felhom.` | +| SMB / NetBIOS names | `controller/internal/infra/samba.go:63-65` | **Clean** — `workgroup = WORKGROUP` fixed, NetBIOS from settings, not the domain. LAN-scoped anyway | +| `storage.cfg` / PVE storage names | agent `internal/storage/` | **Clean** — no domain-derived names found | +| Host identity | hub log `sess-g-e687c3` | **Clean** — `-`; the agent never reads `customer.domain` except in `lanresolver` | +| Tunnel naming | dashboard-only, token is the whole config | **Clean** — tunnels are independent objects; no Felhom-side naming | +| LE certificate rate limits | — | **Not a factor** while the edge terminates TLS; becomes one only in the grey-cloud/DNS-01 topology | +| LAN split-horizon DNS | agent `internal/lanresolver/lanresolver.go:6-13` | **Scoped correctly with nesting** (`local=/t1.felhomcust.hu/`); would hijack the **entire zone** on that LAN if a customer's domain were the bare zone — another reason flat naming is wrong | +| **Cloudflare geo-WAF rules** | `controller/internal/cloudflare/{waf,geosync}.go` | **BROKEN — four distinct cross-tenant faults (R-137)** | + +### 5.1 The geo-WAF collision — the one real breakage + +The rules are written at **zone** scope with **non-namespaced** identities: + +- `waf.go:18` — `globalRuleDesc = "[felhom-geo] Global"` — one literal description for the whole zone. +- `waf.go:21` — `appRuleDescPrefix = "[felhom-geo] app:"` + the **app name**, with no customer in the key. +- `waf.go:241` — `BuildGlobalExpression` returns `(not ip.src.country in {...})` plus `http.host ne` + *exclusions* only. **There is no positive hostname scoping**: the rule matches every hostname in the + zone. +- `geosync.go:320` — `applyDiff` **deletes** every existing `[felhom-geo]` rule not in *this box's* + desired set. + +With two customers in one zone and geo-restriction enabled by either: + +1. **They overwrite each other forever.** Both want a rule described `[felhom-geo] Global` with + different expressions → each sync sees "expression changed" → `UpdateRule` → flip-flop on every tick. +2. **One customer's country policy applies to everyone** in the zone, because the expression is not + hostname-scoped. +3. **Per-app rules collide by name** — two testers running `rallly` both produce + `[felhom-geo] app:rallly`. +4. **Disabling the feature for one customer wipes them all** — `deleteAllRules` (`geosync.go:167`), and + the hub's operator "unblock" (`hub/internal/cloudflare/unblock.go:22` `RemoveGeoRules`) deletes + **all** `[felhom-geo]` rules in the resolved zone. + +**Mitigation for tester #1 (no code):** leave geo-restriction **off** for every shared-zone customer. +**Fix (code, R-137):** namespace the descriptions by `customer_id` and add a positive +`http.host ends_with ""` term to both expressions. That is ~4 functions across +`waf.go`/`geosync.go` plus the hub's `RemoveGeoRules` — a **two-repo change**, i.e. exactly the class +that `dr-recipe-three-producers-hub-allowlist` warns about. Small-to-medium, not trivial. + +### 5.2 The shared-zone credential problem + +If a shared-zone customer is ever given a `cf_api_token` (for DNS-01 or geo), that token is +`Zone:DNS:Edit` **for the whole shared zone**, written to a 0600 `.env` on a box in a stranger's home +(`controller/internal/infra/infra.go:123`). One compromised tester box could repoint **every other +tester's** DNS. Today each box holds a token only for a zone the operator owns and no one shares, so +this is new exposure created by the shared-zone plan. **Keep `cf_api_token` empty for shared-zone +customers** — which §2.4 shows is supported (HTTP-01). Recorded as part of **R-138**. + +--- + +## 6. The operator's step list for tester #1 + +Ordered, with the prerequisites that bite if taken out of order. + +1. **Choose and register the customer domain.** One registrable domain for all customers, e.g. + `felhomcust.hu`. **Not a subdomain of `felhom.eu`** — §4.4. +2. **Add the zone to Cloudflare** (same account) and point the registrar's nameservers at it. +3. **Verify ACM / Total TLS on that zone and enable it.** §3.4 — *do this before promising a tester a + date*; without it every hostname the tester touches fails TLS outright. If ACM is not viable, + fall back to one zone per tester and stop here. +4. **Create the tunnel** `felhom-t1` in Zero Trust → Networks → Tunnels. Keep the token; it is + `cf_tunnel_token`. +5. **Add the public hostnames** on that tunnel: `felhom.t1.felhomcust.hu` plus one per app the tester + will run — or a single proxied wildcard `*.t1.felhomcust.hu` → `.cfargotunnel.com`. +6. **Create the customer in the hub** — `customer_id` `t1`, `domain` **`t1.felhomcust.hu`**, and + **`email` = the tester's own working mailbox**. + > **R-119 prerequisite — this is the step that strands a tester.** The dashboard claim code is + > generated hub-side, bcrypt-hashed, and **emailed only**; there is no operator-side route to the + > plaintext. It is sent when the customer record is created and again on + > `POST /configs//claim-resend`. **The tester's mailbox must be on the record before their box + > boots**, or the box installs perfectly and nobody can claim it. Confirmed live in this morning's + > tester-gate run, where the operator had to relay the code by hand. +7. **Paste `cf_tunnel_token`** into the customer's Edit tab. **Leave `cf_api_token` empty** (§2.4, §5.2). +8. **Do not enable geo-restriction** for this customer (§5.1). +9. **Boot the box** from the v1.25.0 generic ISO. It self-registers as an unclaimed appliance; bind it + to `t1` on the hub's Hosts page; the credentials are delivered once and day-0 completes unattended. +10. **The tester claims the dashboard** with the emailed code and sets their own password. +11. **Sanity-check TLS first, before the tester does:** open `https://felhom.t1.felhomcust.hu`. If it + fails with a handshake error rather than a Felhom page, step 3 did not take. + +--- + +## 7. `R-n` rows opened + +Registered in `documentation/backlog/OPEN-ITEMS.md`; none implemented. + +| ID | What | Size | +|---|---|---| +| **R-133** | Hub enforces uniqueness on `customer_id` only — two customers can be given the same `domain` with no complaint | XS | +| **R-134** | Zone-resolution depth asymmetry: controller strips labels progressively, hub strips exactly one (`unblock.go:136`) | XS | +| **R-135** | `validateCSRF` returns **true** when no session cookie is present — with browser-cached Basic auth this is cross-origin CSRF on every mutating hub route | **S, security** | +| **R-136** | Rename `hub_session` → `__Host-hub_session` (all preconditions verified met on the production path) | XS, one line | +| **R-137** | Cloudflare geo-WAF rules are zone-scoped and non-namespaced — four cross-tenant faults; blocks geo-restriction for any shared zone | M, two-repo | +| **R-138** | A shared-zone `cf_api_token` is a zone-wide DNS-write capability sitting on a customer's box — policy + a guard | S | + +--- + +## 8. What could not be established read-only + +- **Whether Cloudflare ACM / Total TLS is available or enabled on this account.** This is the single + decision-critical unknown (§3.4). It needs the Cloudflare dashboard or an API token. **The only + Cloudflare tokens in reach are the `Zone:DNS:Edit` tokens in `/opt/docker/stacks/traefik/.env` on the + demo boxes — those can WRITE, so per the fence I did not use them.** The hub's own namespace holds no + Cloudflare credential at all (checked: 14 secrets, none Cloudflare-related). **Operator action: read + it off the dashboard.** +- **The existing tunnels' public-hostname route tables** — same reason. So step 5 of §3.5 names the + shape but not the exact origin URL the demo tunnels use; copy it from an existing tunnel. +- **Whether Cloudflare passes HTTP-01 `/.well-known/acme-challenge/` through a proxied hostname to the + tunnel origin.** Relevant only if `cf_api_token` is left empty, which §3.5 step 7 recommends. Would + need a live issuance attempt on a real hostname — i.e. tester #1's first boot. **If it fails, the + fallback is to set `cf_api_token` and accept §5.2's exposure, or rely on the edge cert alone.** +- **Browser Basic-auth resend behaviour** underlying R-135 is stated from the specification, not + measured here; a browser is needed and `claude-in-chrome` is unavailable on DooPlex. + +## 9. What was not done + +No customer, DNS record, tunnel, route or code change was created. No demo box, `drill-r50`, guest 9201 +or Peti's cluster was touched — the only writes anywhere were two hub login sessions (in-memory, 7-day +expiry, tokens shredded) and this document plus the six register rows. `curl -w '%{redirect_url}'` was +not used (R-132). diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index c9e4742..b39273b 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -75,6 +75,12 @@ State: `BLOCKED` · `READY` · `WAITING-ON-OPERATOR` · `WATCHING`. Every row ha | **R-130** | **A "hard min" that only warns.** A fresh box's `local-lvm` was ~75 GiB against `HARD_MIN_LVM_GIB=120` (`scripts/felhom-host-install.sh`); the installer logged `[WARN] local-lvm free ~75 GiB < hard min 120 GiB` and went on to a **fully successful** install | READY (S) | — | Either the minimum is not hard (rename it and state the real floor) or it is wrong (and 120 GiB is not what a working appliance needs). Leaving it is the R-29 shape: a check that reads as coverage while providing none. Evidence: same audit §8 | CC | | **R-131** | **`sess-f` is a fourth orphaned scratch customer** on the hub ("R-120 golden 0.186.0 proof", DOWN), left by the 2026-07-30 session | READY (XS) | — | After `drill-r50`, `sess-c`, `sess-d` — the accumulation `runbooks/target-selection.md:86-87` and `PROMPT-TEMPLATE.md` §13 both warn about, now on its fourth instance. Delete it (see the recorded command in `audits/tester-gate-golden-0.188.0-2026-07-31.md` §7.1); the recurrence itself argues for a periodic scratch-customer sweep rather than another reminder | CC | | **R-132** | **`curl -w '%{redirect_url}'` reconstructs the request URL WITH its basic-auth credential** — so a `-u ":$HUB_PW"` call that never put the password in a URL still printed it | **ACTION: rotate `HUB_PW`** | — | Happened on 2026-07-31 while red-proofing the R-120 gate: the hub operator password was written to the session transcript by the write-out format, not by the request. `-u` is safe; the *reporting* was not. Rule: read the redirect from `-D -` and grep `^Location:`, never `%{redirect_url}`, on any authenticated call. Rotate the hub password (`/configuration` → Login password; ConfigMap `auth.password_hash` is the reset path) and update `~/.config/credentials` | Viktor | +| **R-133** | **The hub enforces uniqueness on `customer_id` only** — `domain` is `TEXT NOT NULL DEFAULT ''` with no UNIQUE/CHECK (`hub/internal/store/store.go:114`) and the create path only rejects a duplicate id (`hub/internal/web/configs.go:644`), so two customers can be given the identical domain silently | READY (XS) | — | Harmless while every customer owns their own zone; a real footgun the moment customers share one (the subdomain-onboarding plan). Fix = reject a duplicate domain on create/edit, or warn. Evidence: `audits/RECON-subdomain-onboarding-2026-07-31.md` §2.2 | CC | +| **R-134** | **Two zone-resolvers disagree on depth.** The controller strips labels progressively (`controller/internal/cloudflare/zone.go:18`); the hub's `resolveZone` tries the exact name then `parentDomain`, which strips exactly ONE label (`hub/internal/cloudflare/unblock.go:115,136`) | READY (XS) | — | For a one-label Felhom-issued subdomain both work; for anything deeper the hub silently fails to find the zone while the controller succeeds — the geo-unblock would then no-op with a "no active zone found" error. One concept, two implementations. Same audit §2.6 | CC | +| **R-135** | **`validateCSRF` returns TRUE when there is no session cookie** (`hub/internal/web/server.go:678-683`) — measured live: `POST` with Basic auth and no cookie goes straight past the CSRF gate (404, not 403), while the same POST with a cookie and no token is 403 | READY (S) — **security** | — | Browsers cache HTTP Basic credentials per origin and resend them automatically on cross-origin requests, and `SameSite` does not govern the `Authorization` header. So if the operator has ever Basic-authed to the hub in a browser, any attacker page can POST to every mutating route. Latent on the condition, not guaranteed absent. Fix = require the token whenever the request is not provably programmatic, or drop browser-usable Basic auth. Same audit §4.3 | CC | +| **R-136** | **Rename `hub_session` → `__Host-hub_session`** — makes cookie tossing structurally impossible | READY (XS, one line) | — | Verified on the live production response that all three prefix preconditions already hold: `Path=/`, `Secure`, no `Domain`. **Caveat for the ticket:** browsers reject a `__Host-` cookie without `Secure`, and `isSecure` is conditional on `r.TLS`/`X-Forwarded-Proto`, so plain-HTTP *browser* access to the hub would stop working (non-browser access uses Basic auth, unaffected). Tested consequence: `r.Cookie` returns the FIRST match and never tries the others, so a tossed cookie wins outright. Same audit §4.1-4.2 | CC | +| **R-137** | **Cloudflare geo-WAF rules are zone-scoped and non-namespaced — four cross-tenant faults.** `globalRuleDesc = "[felhom-geo] Global"` (`waf.go:18`) is one literal description per ZONE; `appRuleDescPrefix` keys by app name with no customer (`waf.go:21`); `BuildGlobalExpression` has no positive hostname scoping (`waf.go:241`); `applyDiff` deletes every `[felhom-geo]` rule not in THIS box's desired set (`geosync.go:320`) | READY (M) — **blocks shared-zone onboarding** | — | With two customers in one zone: they overwrite each other's Global rule forever; one customer's country policy applies zone-wide; per-app rules collide by name; and disabling the feature for one (or the hub's `RemoveGeoRules`) wipes them all. Interim mitigation, no code: keep geo-restriction OFF for every shared-zone customer. Fix = namespace descriptions by `customer_id` + add `http.host ends_with ""` to both expressions — a TWO-REPO change (controller + hub `RemoveGeoRules`). Same audit §5.1 | CC | +| **R-138** | **A shared-zone `cf_api_token` is a zone-wide DNS-write capability on a customer's box** — written 0600 to `/opt/docker/stacks/traefik/.env` (`controller/internal/infra/infra.go:123`) | READY (S) | — | Today each box holds a token for a zone nobody else uses, so the blast radius is one customer. Under a shared customer zone, one compromised tester box could repoint every other tester's DNS. The ACME path is already switchable — an empty token selects HTTP-01 (`traefik.yml.tmpl`) — so the fix is policy plus a guard that refuses to hand a shared-zone customer a zone-scoped token. Same audit §5.2 | CC | | **R-133** | **The vaulted break-glass console credential is PLAINTEXT AT REST — every hub DB backup is a fleet-wide console-credential dump.** `host_recovery.secret` holds each managed box's `root@pam` password verbatim, so any copy of the SQLite DB (Longhorn snapshot, PBS backup of the hub PVC, a hand-taken copy during a diagnosis) carries root console access to every Felhom host in one file | **READY (M) — NEW 2026-07-31** | — | **The deferred leg of hub v0.84.0** (Console access card), filed separately because v0.84.0 changed only WHO can retrieve the secret, never how it is stored. v0.84.0 makes it more worth doing, not more broken: retrieval now rides the hub SESSION, so the DB and the login password are jointly the whole protection (ruling **S-4**, `CONTEXT.md`). Fix shape: **envelope-encrypt the `host_recovery.secret` column under a KEK held outside the DB** — the hub already proves it can hold something it cannot itself read (escrow blobs), and that contrast is the argument. Two constraints the design must respect: the credential must stay retrievable **when the box is unreachable** (that is the whole point of break-glass), so the KEK cannot live on the box or depend on the agent; and the global-key API path must keep working with the hub UI down. Would flip the capability-map row **"Break-glass management-plane recovery"**, which today reads IMPLEMENTED with this as its caveat | CC | ## Why the TOP READY rows rank this way