Issue 116: route-proxy has no authentication or IP-restriction mechanism #109
@@ -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: <htpasswd hash>
|
||||
(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.
|
||||
|
||||
Reference in New Issue
Block a user