Issue 116: scrub the disclosure, and correct the count and the shape of the gap
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.
This commit is contained in:
@@ -6,69 +6,120 @@ fixed-by:
|
|||||||
amended-design:
|
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
|
## What was observed
|
||||||
|
|
||||||
On the control-node, 2026-09-25, deciding whether `route-proxy` (novox/hq 08-connectivity §3,
|
On 2026-09-25, deciding whether the mesh's own reverse proxy
|
||||||
`mesh-controller/examples/route-proxy/main.go`) can replace HAL's adopted Traefik instance as the
|
([to-be 08](../../03-DESIGN/01-to-be/08-connectivity.md)) can replace the ingress the mesh adopted
|
||||||
permanent public ingress. The standing requirement is that the nox mesh does, at minimum, what the
|
from the predecessor, as the permanent public entry point. The standing requirement is that the mesh
|
||||||
HAL mesh it is replacing already does — so the comparison is against Traefik's *current, real*
|
does **at minimum** what the system it replaces already does, so the comparison is against the
|
||||||
configuration on this node, not Traefik's general feature set.
|
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
|
**The proxy's entire request path is a host lookup and a forward.** Its handler takes the request's
|
||||||
middleware `route-proxy` has nothing to match:
|
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.
|
||||||
|
|
||||||
```
|
The table is the reason this is structural rather than a missing feature: it maps **host → one
|
||||||
redisinsight.novox.be → traefik.http.middlewares.redisinsight-auth.basicauth.users: <htpasswd hash>
|
target**, and the lookup strips the port and lowercases the host. **A host cannot be routed two ways.**
|
||||||
(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
|
|
||||||
```
|
|
||||||
|
|
||||||
`route-proxy`'s entire request path is `main.go`'s `handler()`: one lookup into the routing table
|
The design is deliberate as far as it goes — *"a route hands back a name, not a credential"* — but
|
||||||
built from `$ROUTES`, then `proxy.ServeHTTP(w, r)` — no middleware chain, no auth check, no source
|
that governs the **route grant**. It says nothing about what a request arriving at that name is
|
||||||
filtering, anywhere in the file. Confirmed by reading the whole of `main.go` (261 lines): the only
|
allowed to do, which is what the live configuration relies on.
|
||||||
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.
|
|
||||||
|
|
||||||
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:
|
Two sources, because neither alone is complete: the **module catalogue**, and the **ingress's own
|
||||||
0` for the object store's routes): `net/http/httputil.ReverseProxy` streams with no default cap,
|
dynamic configuration** on the node. The catalogue misses what was hand-written on the node; the
|
||||||
so this need requires nothing extra here.
|
node's directory misses what modules declare as container labels. An earlier version of this report
|
||||||
- WebSocket/connection upgrades (used by at least one console route): native in
|
read only the latter and undercounted as a result.
|
||||||
`httputil.ReverseProxy` since Go 1.12; not yet verified live against this proxy, but not absent
|
|
||||||
by design the way auth is.
|
### 1. Basic authentication — three dependents in the catalogue
|
||||||
- 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
|
| Module | What it is the only gate on |
|
||||||
production (novox/hq 04-ISSUES/004) — stricter than the hand-maintained Traefik config it would
|
|---|---|
|
||||||
replace.
|
| the key-value store | its browser UI, which has no login of its own |
|
||||||
- Multiple public names for one module (minio's `files-api` / `files`): already solved by the
|
| the relational store | a **database web UI** |
|
||||||
`contributes` many-shape (mesh-controller PR #55), needs nothing further from the proxy.
|
| 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
|
## Why it matters
|
||||||
|
|
||||||
Two routes cannot move off Traefik until this is closed, and — under the standing "at minimum"
|
Under the "at minimum" rule, **nothing can be called a replacement for the predecessor's ingress
|
||||||
rule — nothing wholesale can be called a Traefik replacement while it is. Left unfixed, either:
|
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
|
- the affected routes stay on the adopted ingress indefinitely, leaving the mesh running two
|
||||||
two reverse proxies side by side for no principled reason, or
|
reverse proxies side by side with no principled division between them; or
|
||||||
- somebody migrates them anyway and RedisInsight (no auth of its own) or the gitea-internal path
|
- they are migrated anyway, and three credential-less admin surfaces become reachable by anyone who
|
||||||
(currently IP-restricted) becomes reachable to anyone who can resolve the name — a real exposure,
|
can resolve a name, while a known-exploited path loses the block that was put in front of it
|
||||||
not a cosmetic gap.
|
during an incident.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
- Where does the auth/restriction config come from? The contract today is "one contribution, a
|
These are design questions, not implementation details, and the fix should not be written before
|
||||||
name and a port" (`main.go`'s `contribution.Values`) — extending it to optionally carry an
|
they are answered.
|
||||||
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
|
- **Where does request-level policy come from?** Today a contribution carries a name and a port.
|
||||||
settings layer, keyed by route name — decouples it from the route grant itself but adds a second
|
Extending it to carry policy keeps the mesh as the source of truth, consistent with everything
|
||||||
place to look. Worth deciding deliberately rather than defaulting to whichever is less code.
|
else a route already does. A separate proxy-side settings layer keyed by route name decouples
|
||||||
- Basic auth and an IP allow/deny list cover what HAL's config uses today. Whether that is the
|
policy from the grant but adds a second place to look. The module's own README notes *"the contract
|
||||||
right *general* shape for route-level middleware, or just the two cases observed so far, is
|
is the file, not this program"*, so this is a contract decision and not a property of one
|
||||||
itself worth a decision before writing it — the module's own README already states "the contract
|
reference implementation.
|
||||||
is the file, not this program," so nothing here requires all of it to live in this one reference
|
- **May a declaration carry a credential?** A password hash in a contribution puts a secret in a
|
||||||
implementation.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user