From c839d9ac26b524c449c0ad0d5871c1ca70f98290 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 13:24:34 +0200 Subject: [PATCH] Issue 116: scrub the disclosure, and correct the count and the shape of the gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the report got wrong, and one it could not have found the way it looked. Disclosure first: it carried a real hostname and an absolute node path, in a public repository. Both are gone; the ingress, the modules and the routes are named by role, as the rest of 04-ISSUES does. The count was low. Basic authentication has three dependents in the catalogue, not one — the key-value store's browser UI, a database web UI, and the ingress's own dashboard. All three are credential-less admin surfaces whose only gate is a middleware the mesh's proxy lacks. The earlier version read only the node's dynamic configuration directory, which cannot see what modules declare as container labels; counting needs both sources, and the report now says so. Two gaps were missing entirely. Redirect rules: two live routes canonicalise a www name onto its apex, they exist only on the node and not in the catalogue, and they fail silently rather than erroring. And path-scoped routing with priority, which is the one that reorders the issue: the table maps host to exactly one target, so a host cannot be routed two ways, and the refusal rule matches a path on a host already routed elsewhere. Authentication and a source filter would not make it expressible. Path scoping is a prerequisite, not a sibling. Also corrected: the refusal rule was described as an address-scoped deny. It is an allow-list holding a single documentation-range address — deny-everyone — so reading it as address-scoped points at the wrong fix. And its severity was understated: its own header records it as incident response closing an abused write primitive, which is not "a real exposure" but a live mitigation. The open questions now say plainly that they are design questions and the fix should not be written before they are answered, and one is added: whether a declaration may carry a credential at all. --- .../00-report.md | 153 ++++++++++++------ 1 file changed, 102 insertions(+), 51 deletions(-) diff --git a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md index a814001..cecdf46 100644 --- a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md +++ b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md @@ -6,69 +6,120 @@ fixed-by: amended-design: --- -# 116 — `route-proxy` has no authentication or IP-restriction mechanism, and two live HAL routes depend on one +# 116 — The mesh's proxy applies no policy to a request: no authentication, no source restriction, no path scoping, no redirects ## What was observed -On the control-node, 2026-09-25, deciding whether `route-proxy` (novox/hq 08-connectivity §3, -`mesh-controller/examples/route-proxy/main.go`) can replace HAL's adopted Traefik instance as the -permanent public ingress. The standing requirement is that the nox mesh does, at minimum, what the -HAL mesh it is replacing already does — so the comparison is against Traefik's *current, real* -configuration on this node, not Traefik's general feature set. +On 2026-09-25, deciding whether the mesh's own reverse proxy +([to-be 08](../../03-DESIGN/01-to-be/08-connectivity.md)) can replace the ingress the mesh adopted +from the predecessor, as the permanent public entry point. The standing requirement is that the mesh +does **at minimum** what the system it replaces already does, so the comparison is against the +predecessor's real, live configuration and its module catalogue — not against a feature list. -Reading `/services/traefik/dynamic/` on the control-node directly, two of its routes carry -middleware `route-proxy` has nothing to match: +**The proxy's entire request path is a host lookup and a forward.** Its handler takes the request's +host, finds a target in a table, and either answers a named 404 or hands the request to the reverse +proxy. There is no authentication check, no source-address check, no redirect handling and no +middleware chain anywhere in the program. -``` -redisinsight.novox.be → traefik.http.middlewares.redisinsight-auth.basicauth.users: - (RedisInsight has no login of its own; this is the only gate in front of it) -gitea-internal → zz-security-gitea-internal-deny.yml — an IP-scoped deny rule -``` +The table is the reason this is structural rather than a missing feature: it maps **host → one +target**, and the lookup strips the port and lowercases the host. **A host cannot be routed two ways.** -`route-proxy`'s entire request path is `main.go`'s `handler()`: one lookup into the routing table -built from `$ROUTES`, then `proxy.ServeHTTP(w, r)` — no middleware chain, no auth check, no source -filtering, anywhere in the file. Confirmed by reading the whole of `main.go` (261 lines): the only -things it does per-request are route lookup and proxy. It was designed this way deliberately — "a -route hands back a name, not a credential" (the module's own README) — but that design covers the -*route grant*, not what a request arriving at the name is allowed to do, which is what these two -HAL routes need. +The design is deliberate as far as it goes — *"a route hands back a name, not a credential"* — but +that governs the **route grant**. It says nothing about what a request arriving at that name is +allowed to do, which is what the live configuration relies on. -Everything else compared cleanly or better, and is **not** part of this issue: +## What the predecessor actually relies on, counted -- Streaming/large request bodies (why Traefik needed `minio-api-body-size: maxRequestBodyBytes: - 0` for the object store's routes): `net/http/httputil.ReverseProxy` streams with no default cap, - so this need requires nothing extra here. -- WebSocket/connection upgrades (used by at least one console route): native in - `httputil.ReverseProxy` since Go 1.12; not yet verified live against this proxy, but not absent - by design the way auth is. -- TLS/ACME: `route-proxy`'s own `HostPolicy` (`onlyWhatTheMeshSaid`) refuses to certify anything - the mesh did not actually route, and defaults to Let's Encrypt staging until a node opts in to - production (novox/hq 04-ISSUES/004) — stricter than the hand-maintained Traefik config it would - replace. -- Multiple public names for one module (minio's `files-api` / `files`): already solved by the - `contributes` many-shape (mesh-controller PR #55), needs nothing further from the proxy. +Two sources, because neither alone is complete: the **module catalogue**, and the **ingress's own +dynamic configuration** on the node. The catalogue misses what was hand-written on the node; the +node's directory misses what modules declare as container labels. An earlier version of this report +read only the latter and undercounted as a result. + +### 1. Basic authentication — three dependents in the catalogue + +| Module | What it is the only gate on | +|---|---| +| the key-value store | its browser UI, which has no login of its own | +| the relational store | a **database web UI** | +| the ingress itself | its own dashboard — twice, counting a desktop flavour | + +Every one is a credential-less admin surface whose sole protection is a middleware the mesh's proxy +does not have. The database web UI is the worst of the three, and the ingress dashboard means the +ingress is currently protecting itself with a mechanism its replacement lacks. + +### 2. Outright refusal on a path — an incident mitigation + +One hand-written rule on the node blocks external access to an internal API path on the forge. Its +own header records it as **incident response to a compromise**, closing the write primitive that was +abused. It is expressed as an allow-list containing a single documentation-range address — that is, +a deny-everyone — and the middleware is named accordingly. + +It is **not** an address-scoped allow-list in any useful sense, and reading it as one points at the +wrong fix. What it needs is the ability to refuse a request outright, scoped to a path. + +The same header already records where this belongs: *"not mesh-managed. Durable home is the +route-proxy module; re-home when convenient."* + +### 3. Path-scoped routing with priority — and this one gates the others + +That rule matches a **path prefix** on a host that is **already routed elsewhere**, and carries an +explicit high priority so it shadows the ordinary route. The mail module needs the same shape for a +different reason: it routes a certificate-challenge path on a host that otherwise goes to the mail +front end. + +Because the table maps a host to exactly one target, **neither is expressible today, and adding +authentication and a source filter would not make them so.** Path scoping with priority is a +prerequisite for the refusal rule, not a feature beside it. + +### 4. Redirect rules — live, and they fail quietly + +Two routes canonicalise a `www` name onto its apex with a rewriting redirect. They appear in the +node's configuration and **not** in the catalogue, so a catalogue-only survey misses them. They are +the easiest of the four to lose, because losing them produces no error — just two public names that +quietly stop redirecting. + +## What compared cleanly, and is not part of this issue + +- **Large and streaming request bodies.** Four modules raise or remove the body cap — the object + store, the file-sync application, the image registry. The standard library's reverse proxy streams + with no default cap, so this needs nothing added. +- **Connection upgrades**, used by at least one console route: native to the standard library's + reverse proxy. Not yet verified live against this proxy, but not absent by design the way the four + gaps above are. +- **Certificate issuance.** The proxy refuses to certify any name the mesh did not route, and + defaults to a staging issuer until a node opts in to production + ([issue 004](../004-certificate-issuance-targets-production/00-report.md)) — stricter than the + hand-maintained configuration it would replace. +- **Several public names for one module.** Already solved by the `contributes` many-shape; needs + nothing from the proxy. ## Why it matters -Two routes cannot move off Traefik until this is closed, and — under the standing "at minimum" -rule — nothing wholesale can be called a Traefik replacement while it is. Left unfixed, either: +Under the "at minimum" rule, **nothing can be called a replacement for the predecessor's ingress +while any of the four is missing** — and one of them is a live mitigation for an exploited +vulnerability. The two outcomes if it is left unfixed are both bad: -- those two routes stay on `route-adapter`/Traefik indefinitely, which means the mesh is running - two reverse proxies side by side for no principled reason, or -- somebody migrates them anyway and RedisInsight (no auth of its own) or the gitea-internal path - (currently IP-restricted) becomes reachable to anyone who can resolve the name — a real exposure, - not a cosmetic gap. +- the affected routes stay on the adopted ingress indefinitely, leaving the mesh running two + reverse proxies side by side with no principled division between them; or +- they are migrated anyway, and three credential-less admin surfaces become reachable by anyone who + can resolve a name, while a known-exploited path loses the block that was put in front of it + during an incident. ## Open questions -- Where does the auth/restriction config come from? The contract today is "one contribution, a - name and a port" (`main.go`'s `contribution.Values`) — extending it to optionally carry an - htpasswd hash or an allowed-CIDR list keeps the mesh (not the proxy) as the source of truth, - consistent with everything else `route` already does. An alternative — a second, proxy-specific - settings layer, keyed by route name — decouples it from the route grant itself but adds a second - place to look. Worth deciding deliberately rather than defaulting to whichever is less code. -- Basic auth and an IP allow/deny list cover what HAL's config uses today. Whether that is the - right *general* shape for route-level middleware, or just the two cases observed so far, is - itself worth a decision before writing it — the module's own README already states "the contract - is the file, not this program," so nothing here requires all of it to live in this one reference - implementation. +These are design questions, not implementation details, and the fix should not be written before +they are answered. + +- **Where does request-level policy come from?** Today a contribution carries a name and a port. + Extending it to carry policy keeps the mesh as the source of truth, consistent with everything + else a route already does. A separate proxy-side settings layer keyed by route name decouples + policy from the grant but adds a second place to look. The module's own README notes *"the contract + is the file, not this program"*, so this is a contract decision and not a property of one + reference implementation. +- **May a declaration carry a credential?** A password hash in a contribution puts a secret in a + declaration. That cuts across how the mesh mints and holds secrets, and it should be settled + deliberately rather than as a side effect of whichever option is less code. +- **Is the right general shape "these four", or something narrower?** Authentication, refusal, path + scoping and redirects are what the predecessor uses *today*. Whether route-level policy should be + an open middleware surface, or exactly these four and no more, is worth deciding before any of it + is written — an open surface is far harder to withdraw than to add.