9e079c7883
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.
483 lines
30 KiB
Markdown
483 lines
30 KiB
Markdown
# 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).
|