From 226d5567432095ef60287e3c8722fea97fc2979c Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 11:51:56 +0200 Subject: [PATCH 1/4] Issue 116: route-proxy has no authentication or IP-restriction mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparing route-proxy against what HAL's actual Traefik config does today, not Traefik's general feature set, per the standing rule that the nox mesh must do at minimum what the HAL mesh it replaces already does. Everything else checked out even or better; these two are real, confirmed gaps — RedisInsight has no login of its own and depends entirely on Traefik's basicauth middleware, and the gitea-internal route depends on an IP-scoped deny rule. Neither has any equivalent in route-proxy's single-lookup request path. --- .../00-report.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md diff --git a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md new file mode 100644 index 0000000..a814001 --- /dev/null +++ b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md @@ -0,0 +1,74 @@ +--- +status: located +opened: 2026-09-25 +located-in: [mesh-controller examples/route-proxy] +fixed-by: +amended-design: +--- + +# 116 — `route-proxy` has no authentication or IP-restriction mechanism, and two live HAL routes depend on one + +## 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. + +Reading `/services/traefik/dynamic/` on the control-node directly, two of its routes carry +middleware `route-proxy` has nothing to match: + +``` +redisinsight.novox.be → traefik.http.middlewares.redisinsight-auth.basicauth.users: + (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 +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. + +Everything else compared cleanly or better, and is **not** part of this issue: + +- 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. + +## 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: + +- 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. + +## 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. -- 2.54.0 From c839d9ac26b524c449c0ad0d5871c1ca70f98290 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 13:24:34 +0200 Subject: [PATCH 2/4] Issue 116: scrub the disclosure, and correct the count and the shape of the gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../00-report.md | 153 ++++++++++++------ 1 file changed, 102 insertions(+), 51 deletions(-) diff --git a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md index a814001..cecdf46 100644 --- a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md +++ b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md @@ -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: - (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. -- 2.54.0 From a11da865915eb2ea6be1278c52b0dda6f99db480 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 13:48:18 +0200 Subject: [PATCH 3/4] ADR 0108: a route carries the policy applied to a request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 116 found the mesh's proxy applies nothing to a request — host lookup, forward. Against what the replaced ingress actually relies on, four capabilities are missing: authentication (three dependents, each gating an admin surface with no login of its own), refusal scoped to a path (one, a live incident mitigation), path-scoped routing with priority, and redirect. Policy goes on the route rather than beside it. A proxy-side settings layer keyed by route name would keep the grant literally clean, but then "what protects this route" is answered from two files nothing keeps in step — and a route's protection is part of what a route is. The set is closed at those four, so a fifth is an amendment and each addition is earned by a dependent that exists. An open middleware surface was rejected: it recreates what is being replaced, and narrowing one later is far harder than widening a closed one. Where policy needs a credential the declaration names a secret and never carries the value, which keeps the existing secret machinery the only thing holding credentials. Inlining a hash was rejected as the first credential in a declaration — a precedent easier to set than withdraw. This re-keys the routing table by host and path with priority, which follows from the decision rather than being a separate one: two of the four need one host routed more than one way. Equal priorities must resolve identically every time or the proxy stops being reproducible. The record says how it is checked, including the negative case that rots quietly — a declaration carrying a credential value rather than a reference must be refused, so the rejected option cannot return by accident. 08-connectivity §3 names the record and gains the subsection; issue 116 gains amended-design. --- ...carries-the-policy-applied-to-a-request.md | 102 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/08-connectivity.md | 36 ++++++- .../00-report.md | 2 +- 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md diff --git a/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md b/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md new file mode 100644 index 0000000..d7e46d3 --- /dev/null +++ b/02-DECISIONS/0108-a-route-carries-the-policy-applied-to-a-request.md @@ -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. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index e0ae9bd..f23c044 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -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) - **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) +- **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 diff --git a/03-DESIGN/01-to-be/08-connectivity.md b/03-DESIGN/01-to-be/08-connectivity.md index fad77e3..ae7801f 100644 --- a/03-DESIGN/01-to-be/08-connectivity.md +++ b/03-DESIGN/01-to-be/08-connectivity.md @@ -7,11 +7,12 @@ code: - mesh-controller internal/identity/authority.go - mesh-host internal/identity/serving.go - mesh-host internal/apply (the service that reflects a rule set) -updated: 2026-09-23 +updated: 2026-09-25 decisions: - 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/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/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 @@ -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 ([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 **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are a diff --git a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md index cecdf46..952cd86 100644 --- a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md +++ b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md @@ -3,7 +3,7 @@ status: located opened: 2026-09-25 located-in: [mesh-controller examples/route-proxy] fixed-by: -amended-design: +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 -- 2.54.0 From 367df38e6dd009950178a29e1ce5468396846fea Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 14:25:24 +0200 Subject: [PATCH 4/4] Issue 116: resolved by mesh-controller PR #58 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap is closed in the proxy: policy applies, the four capabilities exist, the table is keyed by host and path with a total ordering, and the two failure modes that rot quietly are held by tests — a declaration carrying a credential refused rather than served, an unreadable secret failing closed. Resolved rather than left open because the issue reports a gap in the proxy and that gap is gone. But the record says plainly what it does not yet allow: an operator still cannot move the affected routes, because that needs the mesh side — a manifest able to declare these values and the controller minting the secret auth names. Until both exist the capability is reachable only by writing the routes file by hand. That is the ordinary build-out of a contract this issue's decision created, and it belongs to to-be 08 rather than here. The open questions are marked answered and kept rather than deleted, pointing at ADR 0108 — what was rejected and why is the half worth having, and a section still saying "the fix should not be written before these are answered" after the fix was written reads as though nobody looked. One finding kept in the record: priority was read with the reader for ports, which caps at 65535, and the one real rule this reproduces is declared at 100000. It parsed to zero, so refusal and path scoping would have shipped looking complete and doing nothing on the only case that motivated them. A validator borrowed from a neighbouring field is a silent default. --- .../00-report.md | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md index 952cd86..8390ad2 100644 --- a/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md +++ b/04-ISSUES/116-route-proxy-has-no-auth-or-ip-restriction/00-report.md @@ -1,8 +1,8 @@ --- -status: located +status: resolved opened: 2026-09-25 located-in: [mesh-controller examples/route-proxy] -fixed-by: +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 --- @@ -105,10 +105,13 @@ vulnerability. The two outcomes if it is left unfixed are both bad: can resolve a name, while a known-exploited path loses the block that was put in front of it during an incident. -## Open questions +## Open questions — answered -These are design questions, not implementation details, and the fix should not be written before -they are 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 @@ -123,3 +126,27 @@ they are answered. 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. -- 2.54.0