Issue 116: route-proxy has no authentication or IP-restriction mechanism #109

Merged
jschoubben merged 4 commits from issue/116-route-proxy-has-no-auth-or-ip-restriction into main 2026-09-25 12:28:58 +00:00
4 changed files with 290 additions and 1 deletions
@@ -0,0 +1,102 @@
---
topic: the tiers
status: accepted
date: 2026-09-25
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0007-connectivity.md
---
# 108. A route carries the policy applied to a request, and names a secret rather than holding one
## Context
[ADR 0007](0007-connectivity.md) settled that **a route is a grant**: a module contributes the name
it wants and the port it listens on, and the proxy hands back the public name. That governs the
**grant**. It says nothing about what a request arriving at the name is permitted to do, and the
mesh's proxy currently permits everything: its request path is a host lookup and a forward, with no
authentication, no source check, no redirect handling and no middleware anywhere in it.
The standing requirement is that the mesh does **at minimum** what the system it replaces already
does. Measured against the predecessor's live configuration and its module catalogue
([issue 116](../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md)), four
capabilities are relied on and absent:
| Capability | Dependents, counted |
|---|---|
| Authentication | **three** modules, each gating a credential-less admin surface — a key-value browser UI, a **database web UI**, and the replaced ingress's own dashboard |
| Refusal scoped to a path | **one**, and it is a live **incident mitigation** closing a write primitive that was abused |
| Path-scoped routing with priority | **two** — the refusal above, and a certificate-challenge path on a host that otherwise routes elsewhere |
| Redirect | **two** live routes canonicalising a `www` name onto its apex |
Counting needed two sources and neither alone is complete: the catalogue cannot see what was
hand-written on a node, and a node's configuration directory cannot see what modules declare as
container labels. An earlier count read one source and undercounted authentication by two.
**The third row is a prerequisite, not a sibling.** The proxy's table maps a host to exactly one
target, so a host cannot be routed two ways. Adding authentication and a source filter would not
make the refusal rule expressible.
## Considered Options
1. **Leave policy out of the mesh; keep the affected routes on the adopted ingress.** *Rejected.*
The mesh would run two reverse proxies indefinitely with no principled division between them, and
one of the routes held back is a live mitigation — leaving it on a component being decommissioned
means its removal date is whenever somebody forgets.
2. **A separate, proxy-side settings layer keyed by route name.** *Rejected.* It keeps the grant
literally clean, but answering *"what protects this route"* then requires reading two files that
nothing keeps in step. A route's protection is part of what a route is.
3. **The grant carries a reference; the detail lives in a second layer.** *Rejected.* Both costs of
option 2, plus a naming indirection to maintain.
4. **Carry the password hash in the declaration.** *Rejected.* It would be the first credential
value in a declaration, and a precedent is easier to set than to withdraw. A hash is not a
plaintext password, but it is sufficient to pass the gate it protects.
5. **An open middleware surface the proxy applies.** *Rejected.* It recreates the thing being
replaced, makes the proxy's behaviour unbounded, and an open surface is far harder to narrow later
than a closed one is to widen.
## Decision
**A route contribution carries the policy applied to requests arriving at its name**, alongside the
name, the port and the location it already carries.
**The set is closed, and it is these four:** authentication; refusal scoped to a path; path-scoped
routing with priority; redirect. A fifth is an amendment to this record, deliberately — each
addition should be earned by a dependent that exists.
**Where policy needs a credential, the declaration names a secret the mesh mints and holds. It never
carries the value.** This keeps the existing secret machinery as the only thing that holds
credentials, and keeps hashes out of anything regenerated, synced or committed.
**Consequently the routing table is keyed by host and path, with priority** — not by host alone.
This follows from the decision rather than being a separate one: two of the four capabilities need a
single host routed more than one way.
## Consequences
**What this makes possible.** The affected routes can leave the adopted ingress, and "at minimum"
becomes a satisfiable claim rather than a standing exception. The incident mitigation gets a durable
home in the mesh, which its own note already asked for.
**What got harder.** The contribution shape grows, and every provider of `route` must understand
more of it. The table is no longer a flat map, and priority introduces ordering that has to be
deterministic rather than incidental — equal priorities must resolve the same way every time or the
proxy becomes non-reproducible. A closed set means a new need is a decision, not a patch.
**What does not change.** The proxy remains a reference implementation: the contract is the file the
mesh writes, not the program that reads it. Another proxy may implement the same file.
**How this is checked.** A rule states how it is verified, so this one does. A lab bed must show,
against a mesh that declared them: a route with authentication refusing an unauthenticated request
and admitting an authenticated one; a path-scoped refusal shadowing an ordinary route on the same
host while that ordinary route still serves every other path; a redirect answering with the
redirect; and — the negative case, which is the one that rots quietly — **a declaration carrying a
credential value rather than a reference being refused**, so option 4 cannot return by accident.
## References
- [Issue 116](../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md) — the count,
the evidence, and the structural finding about the table.
- [ADR 0007](0007-connectivity.md) — a route is a grant. This record extends it to the request.
- [ADR 0009](0009-modules-and-the-graph.md) — the provision vocabulary a contribution belongs to.
- [To-be 08 §3](../03-DESIGN/01-to-be/08-connectivity.md) — where exposure is specified.
+1
View File
@@ -123,6 +123,7 @@ python3 00-META/checks/index.py fail if stale
- **0094** — [A module may hold several secrets from one provider, each a pair of its own](0094-a-module-may-hold-several-secrets-from-one-provider.md) - **0094** — [A module may hold several secrets from one provider, each a pair of its own](0094-a-module-may-hold-several-secrets-from-one-provider.md)
- **0095** — [The control plane is the way to ask a module](0095-the-control-plane-is-the-way-to-ask-a-module.md) - **0095** — [The control plane is the way to ask a module](0095-the-control-plane-is-the-way-to-ask-a-module.md)
- **0098** — [A fact a provider makes at first start is fetched from it, not carried in its manifest](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md) - **0098** — [A fact a provider makes at first start is fetched from it, not carried in its manifest](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md)
- **0108** — [A route carries the policy applied to a request, and names a secret rather than holding one](0108-a-route-carries-the-policy-applied-to-a-request.md)
### What runs on them, and how it gets there ### What runs on them, and how it gets there
+35 -1
View File
@@ -7,11 +7,12 @@ code:
- mesh-controller internal/identity/authority.go - mesh-controller internal/identity/authority.go
- mesh-host internal/identity/serving.go - mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set) - mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-09-23 updated: 2026-09-25
decisions: decisions:
- 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md - 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md
- 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md - 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md
- 02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md
- 02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md - 02-DECISIONS/0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md - 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
- 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md - 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md
@@ -442,6 +443,39 @@ exactly the per-module cost it is meant to remove. The design is the composition
stopgap until the manifest layer can carry a label and a domain separately stopgap until the manifest layer can carry a label and a domain separately
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)). ([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
### A route also carries what a request arriving at it may do
*2026-09-25, from comparing the mesh's proxy against the ingress it would replace
([issue 116](../../04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md)),
decided in [ADR 0108](../../02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md).*
A grant hands back a name. It did not say what the name admits, and the proxy admitted everything —
its request path was a host lookup and a forward. Measured against what the replaced ingress
actually relies on, four things were missing: **authentication**, **refusal scoped to a path**,
**path-scoped routing with priority**, and **redirect**. Three modules depend on the first, each to
gate an admin surface that has no login of its own; one dependent of the second is a live incident
mitigation.
**Policy belongs to the route, not beside it.** A contribution carries it along with the name, the
port and the location. The alternative — a proxy-side settings layer keyed by route name — keeps the
grant literally clean but makes *"what protects this route"* a question answered from two files that
nothing keeps in step. A route's protection is part of what a route is.
**The set is closed at those four.** A fifth is an amendment, so each addition is earned by a
dependent that exists rather than added because a middleware surface was open. An open surface would
recreate the thing being replaced, and is far harder to narrow later than a closed one is to widen.
**Where policy needs a credential, the declaration names a secret; it never carries one.** The mesh
already mints and holds credentials, and that machinery stays the only thing that does — so a hash
never reaches anything regenerated, synced or committed.
**This re-keys the table.** Two of the four need one host routed more than one way, so the proxy
matches on host **and path**, with priority, rather than mapping a host to a single target. Equal
priorities must resolve identically every time, or the proxy stops being reproducible.
The proxy remains a reference implementation: the contract is the file the mesh writes, not the
program that reads it, and another proxy may implement the same file.
## 4 — Filtering ## 4 — Filtering
**Derived from what is assigned here, and from the overlay's shape** — a node's open ports are a **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are a
@@ -0,0 +1,152 @@
---
status: resolved
opened: 2026-09-25
located-in: [mesh-controller examples/route-proxy]
fixed-by: mesh-controller PR #58 — the four capabilities implemented in the reference proxy; the table re-keyed by host and path with a total ordering; a declaration carrying a credential refused rather than served, and an unreadable secret failing closed
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
---
# 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 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.
**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.
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.**
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.
## What the predecessor actually relies on, counted
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
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:
- 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 — answered
These were design questions, not implementation details, and the fix was not written before they
were answered. All three were settled in
[ADR 0108](../../02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md): policy goes
**on the route**, the set is **closed at the four**, and a declaration **names a secret and never
carries one**. Kept as asked, because what was rejected and why is the half worth having.
- **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.
## What the fix covers, and what it does not yet allow
*2026-09-25, on resolution.* The gap this issue reports is closed: the proxy applies policy, the
four capabilities exist, the table is keyed by host and path with a total ordering, and the two
failure modes that would rot quietly are held by tests — a declaration carrying a credential is
refused rather than served, and an unreadable secret makes the route refuse rather than open.
**It does not yet let an operator move the affected routes.** That needs the mesh side: a manifest
able to declare these values, and the controller minting the secret that `auth` names. Until both
exist the capability is reachable only by writing the routes file by hand, so the routes held back
on the adopted ingress stay there.
Resolved rather than left open because the issue reports a gap **in the proxy**, and that gap is
gone. The remaining work is not this fault persisting; it is the ordinary build-out of a contract
this record's decision created, and it belongs to
[to-be 08](../../03-DESIGN/01-to-be/08-connectivity.md) rather than here.
**One thing found while fixing it, worth keeping.** Priority was first read with the reader for
ports, which caps at 65535 — and the one real rule this has to reproduce is declared at 100000. It
parsed to zero, so refusal and path scoping would both have shipped looking complete, passing their
own tests, and doing nothing on the only case that motivated them. *A validator borrowed from a
neighbouring field is a silent default*, and the test that now guards it goes through the proxy,
because at the parser the value looked fine.