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