RECON: a Felhom-issued subdomain works in the product — the blocker is Cloudflare edge-cert depth
Question A: YES, no code change. customer.domain is a trimmed string with no UNIQUE, no CHECK, no format rule (store.go:114, configs.go:673), copied verbatim into controller.yaml (configgen.go:48), and every one of its 30 consumers on the box interpolates it without parsing. Zero hits for registrable/eTLD/publicsuffix across both repos. Nothing creates DNS records (zero hits for dns_records) — the two Cloudflare clients are WAF-only. And the zone-ownership assumption is a SWITCH, not a requirement: traefik.yml.tmpl selects DNS-01 when cf_api_token is set and HTTP-01 when it is empty. The real blocker is Cloudflare, proven live: the edge certificate covers exactly one wildcard level (SAN = demo-felhom.eu, *.demo-felhom.eu), so a two-label hostname — which a per-tester subdomain forces — gets "tls alert handshake failure" and no peer certificate at all. That makes Advanced Certificate Manager a prerequisite of the separate-domain plan, not an optional extra. Whether ACM is available on the account could not be established read-only: the only Cloudflare tokens in reach are the Zone:DNS:Edit tokens on the demo boxes, which the fence forbids using. Question C, measured rather than reasoned: r.Cookie returns the FIRST match and never tries the others (BOGUS+real = 302, real+BOGUS = 200), so a tossed cookie wins outright — DoS and confusion, not takeover, since it fails closed on mutations. CSRF is a single choke point (server.go:256) and the token carries the whole load against a same-registrable-domain attacker. But it is SKIPPED entirely when no session cookie is present, which with browser-cached Basic auth is cross-origin CSRF on every mutating route (R-135). Agreeing with the separate-domain recommendation, with the caveat the brief asked for: it is necessary but not sufficient. It does not solve Question D, because that is a shared-zone problem and the new domain is a shared zone. Filed R-133..R-138: duplicate domains accepted; hub/controller zone-resolvers disagree on depth; CSRF skipped on the no-cookie path; __Host- rename (one line, preconditions verified met); geo-WAF rules zone-scoped and non-namespaced (four cross-tenant faults, blocks shared-zone onboarding); shared-zone cf_api_token is a zone-wide DNS-write capability on a customer's box. Nothing created: no customer, DNS record, tunnel, route or code change.
This commit is contained in:
@@ -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: "*.<domain>"`, `sans: ["<domain>"]` |
|
||||
| App Traefik rules | catalog compose | ``Host(`${SUBDOMAIN}.${DOMAIN}`)`` |
|
||||
| App env | `controller/internal/stacks/manager.go:1187`, `deploy.go:560` | `DOMAIN=<domain>` |
|
||||
| Controller-host guard | `controller/internal/web/server.go:571` | `controllerHost := "felhom." + domain` |
|
||||
| Report / DR recipe | `controller/internal/report/builder.go:53` | `https://felhom.<domain>` |
|
||||
| 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.<felhom-owned-zone>"` 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.<domain>` (`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 `<tunnel>.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 → `<tunnel-id>.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=<REDACTED>; 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=<real>` | **HTTP 200 — authenticated** |
|
||||
| `hub_session=BOGUS; hub_session=<real>` | **HTTP 302 → /login — rejected** |
|
||||
| `hub_session=<real>; 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.<domain>` |
|
||||
| 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** — `<customer_id>-<random>`; 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 "<customer domain>"` 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` → `<tunnel-id>.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/<id>/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).
|
||||
@@ -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 "<domain>"` 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
|
||||
|
||||
Reference in New Issue
Block a user