Issue 116: route-proxy has no authentication or IP-restriction mechanism #109
@@ -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