Skip to content

feat(proxy): K1 path-level redirect within an ingress - #195

Merged
passcod merged 5 commits into
mainfrom
workhorse/k1
Sep 17, 2026
Merged

passcod merged 5 commits into
mainfrom
workhorse/k1

Conversation

@passcod

@passcod passcod commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary

Review Hero

  • Run Review Hero

passcod and others added 2 commits September 17, 2026 05:17
A route declared on a service can answer with a redirect instead of being
served by a pod: `route("/v1/login").redirect("/api/login", 308)`. The
prefix is bound within a hostname that otherwise proxies, which neither the
vhost-level HTTP to HTTPS redirect nor the whole-hostname site-ingress
attachment could express.

A target names the parts of the request that carry over as `<tail>` and
`<query>`, translated by the runtime rather than written in the proxy's own
placeholder syntax: a target is served as a `Location`, where a braced word
would be substituted from the proxy's state, its environment among it.

Redirect routes are declared on the service and reach the emitter without a
pod binding them, so a service carrying one no longer takes the `/` fallback.
@github-code-quality

github-code-quality Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: TypeScript, Rust

TypeScript / code-coverage/vitest

The overall line coverage in commit a7f37d0 in the workhorse/k1 branch remains at 66%, unchanged from commit 7cbfde4 in the main branch.

Rust / code-coverage/rust

The overall line coverage in commit a7f37d0 in the workhorse/k1 branch remains at 62%, unchanged from commit 7cbfde4 in the main branch.

Show a line coverage summary of the most impacted files.
File main 7cbfde4 workhorse/k1 a7f37d0 +/-
crates/core/src...system/types.rs 100% 94% -6%
crates/core/src...oncile/proxy.rs 21% 17% -4%
crates/core/src...e/site_proxy.rs 50% 49% -1%
crates/core/src/oi/server.rs 60% 59% -1%
crates/core/src...caddy/config.rs 99% 99% 0%
crates/core/src/defs/pod.rs 100% 100% 0%
crates/core/src...defs/summary.rs 79% 80% +1%
crates/core/src...ervice/proxy.rs 91% 94% +3%
crates/core/src...nslate/proxy.rs 85% 91% +6%
crates/core/src...defs/service.rs 80% 88% +8%

Updated September 17, 2026 11:12 UTC

Comment thread crates/core/src/system/caddy/config.rs Outdated
Comment thread crates/core/src/defs/service/proxy.rs
Comment thread crates/core/src/defs/service.rs Outdated
Comment thread crates/core/src/system/reconcile/proxy.rs Outdated
Comment thread crates/core/src/system/reconcile/proxy.rs Outdated
Comment thread crates/core/src/system/reconcile/proxy.rs
Comment thread crates/core/src/defs/service/proxy.rs Outdated
Comment thread crates/core/src/defs/service/proxy.rs
Comment thread crates/core/src/system/reconcile/proxy.rs
@review-hero

review-hero Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 1)
12 agents reviewed this PR | 3 critical | 6 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 10 below threshold

Below consensus threshold (10 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/core/src/defs/pod.rs:85 Design & Architecture nitpick In the bare-service http(port, service) arm the prefix is always /, and a redirect on / is unconditionally refused by declare_redirect, so this record_binding() can never reject anything ...
crates/core/src/defs/service/proxy.rs:1197 Design & Architecture suggestion parse_redirect_positional implements "the map form with the tail and query appended" by formatting {to}<tail><query> into a string and re-parsing it. That round-trip through text is what forces...
crates/core/src/defs/service/proxy.rs:1204 Bugs & Correctness nitpick The positional form appends <tail><query> unconditionally, so a script that writes a token itself — redirect("/find<query>"), an easy mistake given the map form accepts tokens — parses to `/fin...
crates/core/src/defs/service/proxy.rs:1354 Bugs & Correctness suggestion The "setting declared before the redirect" check tests settings.balance != BalanceSettings::default(), so a route that explicitly declared the default policy (e.g. `balance(#{ policy: "round_robi...
crates/core/src/defs/service/proxy.rs:1365 Security suggestion The Location refusal only inspects route-level settings, but collect_redirect_routes resolves headers against the service level (resolve(&service, routes.get(&prefix)), reconcile/proxy.rs) an...
crates/core/src/defs/summary.rs:378 Design & Architecture suggestion fallback_serves = bound.is_empty() && http.redirects.is_empty() re-derives, in the summary layer, the rule that build_proxy_config encodes as `upstream.routes.is_empty() && upstream.redirects.i...
crates/core/src/system/caddy/config.rs:526 Security critical Open redirect: the carried <tail> is not guaranteed to start with /, so an absolute-URL target can be turned into a redirect to an attacker's host via the URL userinfo trick. The route matcher...
crates/core/src/system/caddy/config.rs:528 Security suggestion <tail> on its own emits {http.request.uri.path}, which is the decoded path (r.URL.Path), whereas <tail><query> emits {http.request.uri}, which is the escaped request URI. The decoded form...
crates/core/src/system/caddy/config.rs:550 Design & Architecture suggestion query_var_handler plus QUERY_VAR plus a new http.handlers.map entry in required-modules.txt all exist for one shape: a <query> that is not immediately preceded by <tail>. Neither positi...
crates/core/src/system/translate/proxy.rs:210 Bugs & Correctness suggestion The fallback is suppressed on the basis of upstream.routes, which collect_http_routes derives from running pods, not from declared bindings. So a service that declares one redirect plus ordin...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/core/src/system/caddy/config.rs:349`: The route matcher is `"{prefix}*"` (line 273), which matches any request whose path merely *starts with* the prefix, not one that continues on a path-segment boundary. `strip_path_prefix` is a plain byte-prefix trim in Caddy, so for prefix `/v1/login` a request to `/v1/login.evil.com/x` is rewritten to `.evil.com/x` and `<tail>` is `.evil.com/x` — with no leading `/`, contradicting the spec ("carrying its leading `/`"). Combined with the legal absolute-URL target form this is an open redirect: `route("/v1/login").redirect("https://example.com")` emits `Location: https://example.com{http.request.uri}`, so that request yields `Location: https://example.com.evil.com/x` — a host the caller chose. Path targets are wrong too, just less dangerously (`/v1x` under prefix `/v1` → `/apix`). Match the prefix on segment boundaries for redirect routes (`"path": [prefix, format!("{prefix}/*")]`), or guarantee the tail is empty-or-leading-`/` before splicing it into the Location.

-------

`crates/core/src/defs/service/proxy.rs:1348`: The "a redirect leaves this nothing to act on" rule is now written out twice: once as `refuse_settings_for_redirect`, which enumerates every field of `ProxySettings` (compress / balance / rate_limit / headers.request / headers.response-names-Location), and once as five scattered `this.refuse_if_redirect("…")` calls plus a bespoke inline block in the `headers` builder in service.rs. The two enumerations must stay in sync by hand, and nothing forces that: adding a sixth route setting to `ProxySettings` (a timeout, say) means remembering both sides, and forgetting either leaves the clash accepted in one declaration order and refused in the other — exactly the ordering asymmetry this pair of functions exists to close. Every route setting already funnels through one choke point, `HttpServiceRoute::with_route_settings`; making that the single place that refuses (it can take the setting's name, and `headers` can pass its own predicate) leaves `refuse_settings_for_redirect` as the sole enumeration, used by `declare_redirect` for the reverse direction.

-------

`crates/core/src/defs/service.rs:401`: `bound_prefixes` introduces a second source of truth for which prefixes a pod binds: the field exists solely so `declare_redirect` can refuse a prefix already bound, while the reconciler continues to derive the routes it emits from the pods themselves (`collect_http_routes`). Nothing keeps the mirror complete — it is populated only by the two `route.record_binding()` calls hand-added in pod.rs, so any future binding path that forgets the call silently accepts a redirect-over-binding clash, and the miss is invisible because no emitter ever reads the set. Since the clash is a property of the finished app definition rather than of declaration order, a single check over pods-vs-`redirects` run once after script evaluation would catch both orders without the mirrored state; if there is no such pass to hang it on, at least route every binding through one method on `HttpServiceRoute` so `record_binding` cannot be skipped.

-------

`crates/core/src/system/reconcile/proxy.rs:420`: `redirects_of` is a third copy of the `resources.values().find_map(Service | ExternalService => def.lock().http…)` walk that `proxy_settings_for` (line 224) and `routes_of` already perform, and `collect_redirect_routes` calls both, so it walks the resource map twice and takes the service lock twice to read two fields of the same `HttpServiceDef`. Folding redirects into `proxy_settings_for`'s return (or extracting a single `http_def_of(snapshot, service_name)` that the existing helpers also use) keeps the Service/ExternalService dispatch in one place — worth doing now, while there are three copies rather than four.

-------

`crates/core/src/system/reconcile/proxy.rs:397`: `collect_redirect_routes` calls `proxy_settings_for` unconditionally, before it knows whether the service declares any redirects at all. `proxy_settings_for` is a full linear scan of `snapshot.resources` plus a deep clone of the service's entire `routes` map (`routes_of` → `h.routes.clone()`, each `ProxySettings` carrying header rules). Since `collect` runs this per ingress on every reconcile tick, and the overwhelmingly common case is zero redirects, every app now pays an extra scan + map clone per ingress for nothing. Fetch the redirects first and `return Vec::new()` when the map is empty, before touching `proxy_settings_for`.

-------

`crates/core/src/system/reconcile/proxy.rs:415`: `redirects_of` walks `snapshot.resources.values()` to find the same service resource that `proxy_settings_for` just found two lines above, and clones the whole `BTreeMap<String, RouteRedirect>` (each value owning a `Vec<RedirectSegment>` of `String`s) only to immediately re-map it into `HttpRedirectRoute`. Combined with `service_level_proxy`, one iteration of the `collect` loop now performs three separate linear scans of the resource map for one service name — and `site_proxy::resolve_forward_upstream` repeats the pattern per attachment. Resolve the service once per loop iteration and hand the settings + redirects to both collectors, and build the `HttpRedirectRoute`s from a borrow rather than a cloned map.

-------

`crates/core/src/defs/service/proxy.rs:1263`: The `//` refusal is bypassed by `/\`, which has the same effect in browsers. Per the WHATWG URL spec, Chrome, Firefox and Safari normalise backslashes to forward slashes in the special-scheme authority position, so `Location: /\evil.com` navigates to `evil.com` exactly as `//evil.com` does. `redirect("/\\evil.com")` passes both the `//` check and the `starts_with('/')` check.

Since the guard exists precisely to stop a target that "names another host while reading as a path", it should cover the equivalent form: reject a target whose second character is `/` or `\` (and reject a backslash in the authority position of the composed result generally).

-------

`crates/core/src/defs/service/proxy.rs:1272`: Open redirect: the `//` guard inspects only the declared string, never the composed `Location`. `route("/v1").redirect("/")` parses to `[Literal("/"), Tail, Query]`, which `redirect_location` emits as `Location: /{http.request.uri}`. A request to `https://host/v1/evil.com` is stripped of the `/v1` prefix, so the emitted header becomes `//evil.com` — a protocol-relative URL that browsers resolve to the attacker-chosen host `evil.com`. The same applies to the map form `#{ to: "/<tail>" }`. Any target whose leading literal is exactly `/` (or, more generally, ends with `/`) immediately followed by `<tail>` concatenates into a second leading slash. Fix by rejecting a target whose first literal is `/` when the next segment is `Tail`, or by making the emitter guarantee the `Location` cannot begin with two slashes (e.g. requiring at least one non-slash character in the leading literal).

-------

`crates/core/src/system/reconcile/proxy.rs:415`: A service-level `Location` response operation displaces the redirect's computed target, contradicting `r[service.http.route.redirect]` ("the route carries no response operation naming that header, so none can displace it"). `refuse_location_on_redirect` only fires for route-level `headers()` and for route settings present at `declare_redirect` time; `collect_redirect_routes` then calls `resolve(&service, routes.get(&prefix))`, and `resolve_headers`/`layer_headers` merge the service's response rules in. So `service.http(80).headers(#{ response: #{ replace: #{ "Location": "https://elsewhere" } } })` is accepted, emitted as a deferred `headers` handler ahead of `static_response`, and rewrites the `Location` when the response is written — silently sending every request under the prefix somewhere other than what `to` declares. Either filter a `Location` rule out of `resolved.headers.response` for redirect routes (as is already done for the request direction), or refuse it at declaration.

Comment thread crates/core/src/system/translate/proxy.rs Outdated
Comment thread crates/core/src/system/caddy/config.rs
Comment thread crates/core/src/system/caddy/tests.rs Outdated
Comment thread crates/core/src/defs/service.rs Outdated
Comment thread crates/core/src/system/reconcile/proxy.rs Outdated
Comment thread crates/core/src/system/caddy/config.rs
Comment thread crates/core/src/system/reconcile/proxy.rs Outdated
@review-hero

review-hero Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 2)
9 agents reviewed this PR | 2 critical | 5 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 7 below threshold, 1 suppressed

Below consensus threshold (7 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/core/src/defs/service.rs:283 Bugs & Correctness critical The redirect/binding clash check compares prefixes byte-for-byte (is_redirect / record_binding / declare_redirect all key on the raw string), but the emitter normalises a redirect prefix by t...
crates/core/src/defs/service/proxy.rs:1254 Design & Architecture nitpick build_redirect(to, written, code) takes the same string twice from parse_redirect_map (build_redirect(&to, &to, code)), and the two arguments differ only for the positional forms. Having the ...
crates/core/src/defs/service/proxy.rs:1385 Design & Architecture suggestion The "what a redirect route may not carry" policy is now spelled out in three places: per-builder refuse_if_redirect("compress"/"balance"/"rate_limit") calls on six with_fn arms, the inline requ...
crates/core/src/defs/summary.rs:421 Bugs & Correctness suggestion app.describe reports a redirect route's resolved response header operations verbatim, including a service-level operation on Location, while the reconciler strips exactly that header before emi...
crates/core/src/system/caddy/config.rs:282 Design & Architecture suggestion Match semantics now depend on the handler kind within one vhost: a redirect route matches [prefix, prefix/*] on segment boundaries with trailing slashes trimmed by redirect_prefix, while a prox...
crates/core/src/system/caddy/config.rs:526 Design & Architecture critical The emitter and its tests encode two different designs for carrying request parts into the Location. redirect_location always inlines the custom {seedling.redirect.tail}/`{seedling.redirect.que...
crates/core/src/system/types.rs:678 Performance nitpick ProxyRouteHandler::RouteRedirect adds a Vec<RedirectSegment> plus RouteHeaderOps to every route value, which makes the unconditional let mut routes = vh.routes.clone() in `proxy_routes_for_...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/core/src/system/translate/proxy.rs:210`: Suppressing the `/` fallback whenever `upstream.redirects` is non-empty blackholes every other path of a service that legitimately has no per-prefix bindings. The fallback exists for "TCP-only services fronted by an HTTPS ingress, or a transient state where no pod has bound yet" (the comment just above). Failure scenario: a service whose pods never call `deployment.http(port, svc.route(prefix))` and is served through the service routing pool; the app adds a single `route("/v1/login").redirect("/api/login")`. `collect_http_routes` returns no routes, so with this change the vhost emits only the `/v1/login` redirect and every other request — the entire site — now 404s at the proxy instead of reaching the service IP. The suppression is not needed to protect the redirect either: a redirect prefix can never be `/` (refused in `declare_redirect`), and `proxy_routes_for_vhost` sorts longest-prefix-first with terminal routes, so a `/` fallback can never shadow a redirect route. Gate the fallback on `upstream.routes.is_empty()` alone (and correspondingly `fallback_serves` in `route_summaries`).

-------

`crates/core/src/system/caddy/config.rs:567`: The route matcher and the tail/query extraction disagree on case, so a matched request can silently lose its tail and query. Caddy's `path` matcher compares case-insensitively (it lowercases both sides), while `input_regexp` `^/v1/login([^?]*)(\?.*)?$` is a case-sensitive RE2 match against `{http.request.uri}`. A request for `/V1/Login/reset?token=x` therefore matches the route but misses the mapping, falls through to `defaults: ["", ""]`, and is redirected to `/api/login` — the path remainder and the `?token=x` are dropped without any signal. Prefix the pattern with `(?i)` so the extraction claims exactly what the matcher claimed.

-------

`crates/core/src/system/caddy/tests.rs:1549`: Two incompatible emission designs landed together. The emitter (`request_parts_handler`, config.rs:561) produces a single `map` handler over `{http.request.uri}` writing `{seedling.redirect.tail}` / `{seedling.redirect.query}` with `defaults: ["",""]`, and `redirect_location` puts those placeholders in the `Location`. These tests assert the other design that `plan.md` says was abandoned: a `rewrite` handler with `strip_path_prefix`, a `Location` of `/api/login{http.request.uri}` (1553), `{http.request.uri.path}` for a bare tail (1619), a query-only `map` sourced from `{http.request.uri.query}` with `defaults[0] == "?{http.request.uri.query}"` (1595-1598), and a `["headers","rewrite","static_response"]` chain (1645). The emitter can never produce a `rewrite` handler, so these assertions cannot pass. Pick one design and make the tests and the doc comment in `request_parts_handler` describe it; note also that `plan.md` claims `http.handlers.rewrite` was added to required-modules.txt while the diff only adds `http.handlers.map`.

-------

`crates/core/src/defs/service.rs:393`: `HttpServiceDef` now carries three prefix-keyed collections — `routes`, `redirects`, `bound_prefixes` — whose disjointness is an invariant maintained by hand at four separate call sites (`record_binding` and `is_redirect` here, `declare_redirect`'s `bound_prefixes` check, `refuse_if_redirect`, plus `pod.rs`'s two `record_binding()` calls), each with its own error string for the same rule. Modelling it as one `BTreeMap<String, RouteKind>` where `RouteKind` is `Proxied(ProxySettings) | Redirect(RouteRedirect)` would make "a prefix is either redirected or proxied" structural rather than defended, collapse the two mirror-image clash errors into one, and remove `bound_prefixes` as a second source of truth for binding state that the reconciler independently derives from the pods (the two can only agree by construction, never by check).

-------

`crates/core/src/system/reconcile/proxy.rs:456`: `"Location"` is now spelled in three places for one wire contract: `REDIRECT_OWNED_HEADER` here, `LOCATION` in defs/service/proxy.rs:1377 (which drives the declaration-time refusal), and the literal key in config.rs:362. The declaration refusal and the reconciler's `without()` strip must name the same header or a service-level `Location` operation silently displaces a redirect target; keeping them as two unrelated private constants in two crates' modules makes that a coincidence rather than a fact. Export one constant and have both sides plus the emitter use it.

-------

`crates/core/src/system/caddy/config.rs:519`: Prefix normalisation is split across layers: `redirect_prefix` trims trailing `/` at emission time, but the declaration layer keys `redirects`, `routes` and `bound_prefixes` on the raw string from `route(prefix)` (no normalisation there — service.rs:426 only checks non-empty and leading `/`). So `web.route("/v1/").redirect(...)` plus a pod bound to `web.route("/v1")` passes the either-redirected-or-proxied check (different map keys) and then emits two routes that match overlapping request sets, with the redirect winning by being listed first. Normalise the prefix once where the route is created, so every downstream map, clash check and matcher agrees on the identity of a prefix.

-------

`crates/core/src/system/reconcile/proxy.rs:120`: `service_proxy_def` is still called once per ingress inside the `for resource in snapshot.resources.values()` loop, and each call linear-scans the whole resource map and deep-clones both `routes` and `redirects` (every `ProxySettings`, header rule and `RouteRedirect` target). The doc comment on `ServiceProxyDef` claims the point is to avoid "a scan of the app's whole resource map" per collector, but the scan just moved up one level: several ingresses hanging off the same service (the common `:80` + `:443`, or multi-hostname, case) re-scan and re-clone identical data on every reconciliation tick, and `build_proxy_config` then clones each redirect target again per vhost. Memoise per service name within `collect` — e.g. `let mut defs: HashMap<&str, ServiceProxyDef>` filled with `entry(svc_name).or_insert_with(|| service_proxy_def(snapshot, svc_name))` — so the scan and the clone happen once per distinct service rather than once per ingress.

Comment thread crates/core/src/system/caddy/tests.rs Outdated
Comment thread crates/core/src/defs/service.rs Outdated
Comment thread crates/core/src/defs/service.rs Outdated
Comment thread crates/core/src/defs/service.rs Outdated
Comment thread crates/core/src/system/reconcile/proxy.rs Outdated
Comment thread crates/core/src/defs/service.rs
Comment thread crates/core/src/system/caddy/config.rs Outdated
@review-hero

review-hero Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 3)
12 agents reviewed this PR | 3 critical | 4 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 10 below threshold, 1 suppressed

Below consensus threshold (10 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/core/src/defs/service/proxy.rs:1293 Security suggestion The protocol-relative guard only inspects byte 1 for / or \\, but header_value() deliberately permits horizontal tab (proxy.rs:1040 exempts \\t), and per the WHATWG URL spec browsers strip al...
crates/core/src/defs/service/proxy.rs:1311 Bugs & Correctness nitpick The composition check only guards a literal ending in / immediately before <tail>. The analogous query case is unguarded: redirect(#{ to: "/find?<query>" }) composes into /find??foo=1, sinc...
crates/core/src/defs/summary.rs:421 Bugs & Correctness suggestion app.describe reports a redirect route's response header operations unfiltered, while the reconciler drops Location from them (collect_redirect_routes.without(REDIRECT_OWNED_HEADER), reco...
crates/core/src/system/caddy/config.rs:561 Performance critical Every redirect that names <tail> or <query> emits a map handler running ^<prefix>([^?]*)(\\?.*)?$ against {http.request.uri} on every request under the prefix. The plan and the committed t...
crates/core/src/system/caddy/config.rs:561 Security suggestion The tail and query are cut straight out of {http.request.uri} and handed to the replacer as {seedling.redirect.tail} / {seedling.redirect.query}, which are then substituted into the `Location...
crates/core/src/system/caddy/config.rs:568 Bugs & Correctness suggestion The tail/query regexp is case-sensitive while the path matcher that selects the route is not. Caddy's path matcher compares case-insensitively, so /V1/Login/reset matches `["/v1/login", "/v1/lo...
crates/core/src/system/reconcile/proxy.rs:258 Design & Architecture suggestion service_level_proxy(snapshot, service_name, def) and collect_http_routes(snapshot, service_name, running_pods, def) now take both the inputs the def was derived from and the derived def. Noth...
crates/core/src/system/reconcile/proxy.rs:425 Performance nitpick collect_redirect_routes runs the full resolve() for every redirect but keeps only headers.response — the resolved compress, balance and rate-limit settings are computed and dropped, and `reso...
crates/core/src/system/reconcile/proxy.rs:455 Design & Architecture nitpick REDIRECT_OWNED_HEADER here and LOCATION in defs/service/proxy.rs are the same rule — "a redirect owns its Location" — spelled twice in two layers, one refusing the operation at declaration and ...
docker/caddy/required-modules.txt:23 Performance suggestion Only http.handlers.map is declared, but the intended emission path (per the plan's "What landed" and the tests) also uses http.handlers.rewrite. Once the strip-prefix handler is actually emitte...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/core/src/system/caddy/tests.rs:1549`: The new caddy emission tests assert a different implementation than the one this PR ships, so they cannot pass. `proxy_routes_for_vhost` emits a `map` handler (`request_parts_handler`, config.rs:562) sourced from `{http.request.uri}` and a `Location` built from `{seedling.redirect.tail}`/`{seedling.redirect.query}`, with a match of `["/v1/login", "/v1/login/*"]`. These tests expect `["rewrite", "static_response"]` with `strip_path_prefix`, a `Location` of `/api/login{http.request.uri}` (line 1553), `/api{http.request.uri.path}` (line 1619), a `map` whose `source` is `{http.request.uri.query}` with `defaults[0] == "?{http.request.uri.query}"` (lines 1595-1598), and `match[0].path[0] == "/v1/login*"` (line 1554) — every one of those assertions fails against the current emitter. (`docker/caddy/required-modules.txt` likewise only gained `http.handlers.map`, not the `http.handlers.rewrite` the tests' handler chain would need.) Either the emitter or the tests is from the wrong revision; reconcile them and re-run `cargo test -p seedling-core --lib caddy::tests`.

-------

`crates/core/src/defs/service.rs:393`: `HttpServiceDef` now carries three prefix-keyed collections (`routes`, `redirects`, `bound_prefixes`) whose central invariant — a prefix is either redirected or proxied — is upheld by hand-written checks in four separate places (`record_binding`, `declare_redirect`, `refuse_if_redirect`, `refuse_settings_for_redirect`). Every new entry point that touches a prefix has to remember all of them, and the redirect/settings clash is already checked twice in mirrored code. Modelling the per-prefix state as one map to an enum (`Proxied(ProxySettings)` / `Redirect(RouteRedirect)`, with binding recorded inside the proxied arm) would make the clash unrepresentable rather than merely refused, and reduce the four checks to one match on the existing entry.

-------

`crates/core/src/defs/service.rs:401`: `bound_prefixes` introduces a second source of truth for which prefixes a pod binds; the authoritative one is the pods' `http_bindings`, which the reconciler still walks (`collect_http_routes`). Nothing keeps the two in step — the set only grows, is never pruned when a binding is removed or a pod is redefined, and is written from `PodDef`'s builder into the *service*'s def. Since `HttpServiceDef` already has `routes` keyed by prefix, consider recording the prefix's disposition in one map (e.g. a `RouteKind::{Proxied, Redirect}` alongside the existing `ProxySettings`) so "redirected or proxied" is a single value that cannot hold both, rather than a set and a map that must be checked against each other from both directions.

-------

`crates/core/src/defs/service.rs:693`: The redirect/settings exclusion is expressed twice in two different shapes: five `this.refuse_if_redirect("…")?` calls hand-threaded through the builder methods (plus two bespoke checks inside `headers`), and the post-hoc scan `refuse_settings_for_redirect` for the opposite declaration order. Every new route-level setting must remember to add its guard, and the setting name is duplicated as a string literal at each site. `with_route_settings` already funnels all of these mutations through one place — applying `f` and then running `refuse_settings_for_redirect` on the result (when the prefix is a redirect) would collapse both directions onto the single scan and make a forgotten guard impossible.

-------

`crates/core/src/system/reconcile/proxy.rs:120`: `service_proxy_def` is called once per ingress inside the loop over `snapshot.resources`, and each call itself walks the whole resource map and deep-clones the service's `routes` and `redirects` maps (`ProxySettings` carries header rules, content-type lists, etc.). Multiple ingresses can hang off one service (see the comment at line 64), so a service fronted by N ingresses pays N scans and N full clones of the same declaration on every reconciliation tick — and `resolve_forward_upstream` in site_proxy.rs repeats the pattern per attachment. The struct's own doc comment says it exists to avoid exactly this, but it only halves the walks rather than removing the repetition. Memoise it: build a `HashMap<&str, ServiceProxyDef>` (or `Rc<ServiceProxyDef>`) keyed by service name once before the loop and hand out references, so the scan-and-clone happens once per distinct service instead of once per ingress.

-------

`crates/core/src/defs/service.rs:706`: `declare_redirect` refuses only the exact prefix `/`, but the emitter normalises away trailing slashes (`redirect_prefix` in system/caddy/config.rs uses `trim_end_matches('/')`). `route("//")` passes both the `route()` validator (non-empty, starts with `/`) and this check, and then emits `"path": ["", "/*"]` — a matcher for every request on the virtual host — sorted ahead of every other route because sorting uses the untrimmed prefix length (2 > 1). So `svc.route("//").redirect("https://attacker.example")` gives an app the whole-hostname redirect that `l[service.http.route.redirect]` explicitly reserves for an operator's site-ingress attachment, and because `collect_redirect_routes` is also used by site_proxy.rs it hijacks operator-owned hostnames attached to that service, including paths served by other pods. Refuse a prefix whose trailing-slash-trimmed form is empty (and normalise the prefix once, at `route()`, so declaration and emission agree).

-------

`crates/core/src/system/caddy/config.rs:518`: The "a prefix is either redirected or proxied" invariant is enforced on exact strings (`redirects` / `bound_prefixes` keyed by the literal prefix) but the redirect matcher is built from the trailing-slash-trimmed prefix. `web.route("/v1/login/").redirect("https://elsewhere.example")` alongside `deployment.http(8080, web.route("/v1/login"))` therefore passes both clash checks in `record_binding` and `declare_redirect`, yet emits `"path": ["/v1/login", "/v1/login/*"]` and — because routes sort by untrimmed `prefix.len()` (10 > 9) — lands ahead of the pod's route and is terminal, so every request the pod was bound to serve is redirected away instead. Compare normalised prefixes in the two clash checks, and sort on the same effective match prefix the matcher is built from.

Comment thread crates/core/src/defs/summary.rs Outdated
} else {
resolved.headers.request.into_grouped().into()
},
response: resolved.headers.response.into_grouped().into(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bugs & Correctness] suggestion

app.describe reports a Location response operation on a redirect route that the runtime silently drops. collect_redirect_routes strips it (resolved.headers.response.without(REDIRECT_OWNED_HEADER), reconcile/proxy.rs) because a service-declared Location op would displace the computed target, but the summary passes resolved.headers.response through unfiltered for redirect routes. A service declaring headers(#{ response: #{ replace: #{ "Location": ... } } }) for its proxied routes therefore reads, in describe, as having that operation in force on its redirect routes when it does not — the same failure mode the adjacent compress / rate_limit filtering was added to avoid. Apply .without("Location") when redirect.is_some().

///
/// The two are defined together because they are one cut of one string.
// r[impl service.http.route.redirect]
fn request_parts_handler(prefix: &str) -> Value {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] critical

Two different emission designs have landed in one PR, and the code and its tests disagree. request_parts_handler implements request-part extraction as a single map handler over {http.request.uri} with an input_regexp, producing a chain of ["map", "static_response"] and a Location built from {seedling.redirect.tail} / {seedling.redirect.query}. The tests assert the other design entirely: crates/core/src/system/caddy/tests.rs:1549 expects ["rewrite", "static_response"] with handle[0].strip_path_prefix == "/v1/login" and location == "/api/login{http.request.uri}", :1595 expects a map whose source is {http.request.uri.query} with defaults[0] == "?{http.request.uri.query}", and :1619 expects {http.request.uri.path}. .workhorse/plans/k1/plan.md ("What landed") describes the rewrite+map design too, and claims http.handlers.rewrite was added to docker/caddy/required-modules.txt — it was not; only http.handlers.map is listed. So the redirect caddy tests cannot pass as committed, and whichever design is intended, the module contract and the plan notes need to match it. Pick one emission strategy and make config.rs, tests.rs, required-modules.txt and the plan agree.

/// The header a redirect computes for itself, and which therefore takes no
/// operation from anywhere.
// r[impl service.http.route.redirect]
pub(super) const REDIRECT_OWNED_HEADER: &str = "Location";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] suggestion

REDIRECT_OWNED_HEADER = "Location" duplicates LOCATION = "Location" at crates/core/src/defs/service/proxy.rs:1377, with near-identical doc comments ("the header a redirect computes for itself"). This is one wire contract — the header a redirect owns — defined twice in two layers, which is exactly the kind of split the repo rules call out. Export the single constant (alongside HeaderRules::names/without, which already live in the defs proxy module) and have the reconciler use it.

/// `/` rather than as nothing, so the tail would never be empty.
///
/// The two are defined together because they are one cut of one string.
// r[impl service.http.route.redirect]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Performance] suggestion

The production redirect shape (redirect("/api/login"), which desugars to Literal + Tail + Query) emits a map handler with an input_regexp that is evaluated against {http.request.uri} on every request under the prefix. Adjacent <tail><query> is exactly {http.request.uri} after the prefix is stripped, so this case can be served by a single rewrite/strip_path_prefix handler with no regexp at all — which is what the plan doc claims ("the production case costs one strip-prefix handler and nothing else") and what the tests in crates/core/src/system/caddy/tests.rs assert (handlers(...) == ["rewrite", "static_response"], location == "/api/login{http.request.uri}"). As written, redirect_location always emits TAIL_VAR/QUERY_VAR and needs_request_parts always pulls in the regexp map, so the default form of the feature pays a per-request regular-expression match on the proxy hot path where a prefix-strip would do. Special-case a target ending in adjacent Tail, Query (and a Tail-only target, which maps to {http.request.uri.path}) to emit the rewrite instead, falling back to the map only when a query is named apart from the tail.

Comment thread crates/core/src/system/caddy/config.rs Outdated
"source": "{http.request.uri}",
"destinations": [TAIL_VAR, QUERY_VAR],
"mappings": [{
"input_regexp": format!("^{prefix}([^?]*)(\\?.*)?$"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] critical

The runtime tail/query extraction places raw, client-controlled request text into a Location template without excluding the proxy's placeholder syntax. Declaration-time validation refuses { in a target precisely because a braced word in a Location is substituted from the proxy's own state (its environment among it) — but input_regexp: ^{prefix}([^?]*)(\?.*)?$ happily captures braces from {http.request.uri} into {seedling.redirect.tail} / {seedling.redirect.query}. A client can send GET /v1/login/{env.SECRET}?x={env.SECRET} (Go's HTTP server accepts braces in the request target and {http.request.uri} is the raw escaped form), so the extracted tail contains a placeholder. Whether it is expanded depends on whether Caddy's map handler runs its outputs through the replacer after regexp expansion — it documents placeholder support in outputs, so this is at least one replacer pass over attacker text, and the payoff is daemon environment disclosure in a header served to that client. Don't rely on that ordering: exclude braces from the captures so a brace-carrying request falls to defaults and contributes nothing, e.g. ^{prefix}([^?{}]*)(\?[^{}]*)?$ (same fail-safe already used for a URI that doesn't begin with the prefix).

/// each other about.
// l[impl service.http.route.redirect]
fn declare_redirect(&mut self, redirect: RouteRedirect) -> Result<(), Box<EvalAltResult>> {
if self.prefix == "/" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] critical

The / guard compares the raw prefix string while the emitter normalises trailing slashes, so it is trivially bypassed. route("//") passes route()'s only validation (non-empty, starts with /), is not equal to "/", and then redirect_prefix("//") trims to "", emitting path: ["", "/*"] — a matcher that answers every request on the hostname. That is exactly the whole-hostname redirect the rule declares an operator's to make, and because the raw prefix "//" is longer than "/" it sorts ahead of the pod-bound root route and swallows it. Normalise the prefix (the same trim_end_matches('/') the emitter uses) before the == "/" comparison, or reject a prefix containing // in route() so declaration and emission agree on what a prefix means.

Comment thread crates/core/src/defs/service.rs Outdated

let prefix = self.prefix.clone();
self.http.service.with_http_def(|d| {
if d.bound_prefixes.contains(&prefix) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] suggestion

The redirect/binding clash check keys on the raw prefix string (bound_prefixes.contains(&prefix) and is_redirect(prefix)), but the emitter treats /v1/login and /v1/login/ as the same claim (redirect_prefix trims trailing slashes, and the proxied matcher is /v1/login*). So web.route("/v1/login/").redirect(...) alongside a pod bound at web.route("/v1/login") passes both directions of the check, then emits a redirect whose matcher covers the pod's prefix and, being the longer raw prefix, sorts ahead of it — every request intended for the pod is redirected instead. Normalise the prefix once (on route(), so redirects, routes and bound_prefixes all key on the canonical form) rather than trimming only at emission.

@review-hero

review-hero Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 4)
12 agents reviewed this PR | 3 critical | 4 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 7 below threshold

Below consensus threshold (7 unique issues not confirmed by majority)
Location Agent Severity Comment
crates/core/src/defs/service.rs:399 Design & Architecture suggestion bound_prefixes adds a second source of truth for which prefixes a pod binds — the reconciler still derives the routes it emits from the pods (collect_http_routes), and the doc comment concedes ...
crates/core/src/defs/service.rs:558 Design & Architecture suggestion The set of settings a redirect refuses is enumerated twice in two different forms: as a string literal at each of the five builder call sites (refuse_if_redirect("compress"), …("balance"), `…("...
crates/core/src/defs/service/proxy.rs:1216 Bugs & Correctness suggestion The positional form appends <tail><query> unconditionally, so a target that already names a token gets it twice with no diagnostic. redirect("/api/login<tail>") parses to `[Literal, Tail, Tail,...
crates/core/src/system/caddy/config.rs:280 Design & Architecture suggestion Match semantics now depend on the handler kind: a redirect route matches [prefix, prefix/*] (segment boundaries) while every other route matches prefix*. The reasoning in the comment is sound, ...
crates/core/src/system/caddy/config.rs:568 Bugs & Correctness critical The tail/query regexp is case-sensitive while the Caddy path matcher that selects the route is case-insensitive, so a request that differs only in case silently loses its tail. `GET /V1/Login/res...
crates/core/src/system/reconcile/proxy.rs:245 Performance suggestion service_proxy_def is called once per ingress inside collect's loop, and each call linearly scans the app's entire snapshot.resources map and then deep-clones the service's routes and `redir...
crates/core/src/system/translate/proxy.rs:210 Bugs & Correctness suggestion Suppressing the / fallback keys off upstream.routes, which is built from running pods, not from declared bindings. A service that both binds prefixes to pods and declares one redirect loses i...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`crates/core/src/defs/summary.rs:421`: `app.describe` reports a `Location` response operation on a redirect route that the runtime silently drops. `collect_redirect_routes` strips it (`resolved.headers.response.without(REDIRECT_OWNED_HEADER)`, reconcile/proxy.rs) because a service-declared `Location` op would displace the computed target, but the summary passes `resolved.headers.response` through unfiltered for redirect routes. A service declaring `headers(#{ response: #{ replace: #{ "Location": ... } } })` for its proxied routes therefore reads, in describe, as having that operation in force on its redirect routes when it does not — the same failure mode the adjacent `compress` / `rate_limit` filtering was added to avoid. Apply `.without("Location")` when `redirect.is_some()`.

-------

`crates/core/src/system/caddy/config.rs:561`: Two different emission designs have landed in one PR, and the code and its tests disagree. `request_parts_handler` implements request-part extraction as a single `map` handler over `{http.request.uri}` with an `input_regexp`, producing a chain of `["map", "static_response"]` and a `Location` built from `{seedling.redirect.tail}` / `{seedling.redirect.query}`. The tests assert the other design entirely: `crates/core/src/system/caddy/tests.rs:1549` expects `["rewrite", "static_response"]` with `handle[0].strip_path_prefix == "/v1/login"` and `location == "/api/login{http.request.uri}"`, `:1595` expects a map whose `source` is `{http.request.uri.query}` with `defaults[0] == "?{http.request.uri.query}"`, and `:1619` expects `{http.request.uri.path}`. `.workhorse/plans/k1/plan.md` ("What landed") describes the rewrite+map design too, and claims `http.handlers.rewrite` was added to `docker/caddy/required-modules.txt` — it was not; only `http.handlers.map` is listed. So the redirect caddy tests cannot pass as committed, and whichever design is intended, the module contract and the plan notes need to match it. Pick one emission strategy and make config.rs, tests.rs, required-modules.txt and the plan agree.

-------

`crates/core/src/system/reconcile/proxy.rs:456`: `REDIRECT_OWNED_HEADER = "Location"` duplicates `LOCATION = "Location"` at crates/core/src/defs/service/proxy.rs:1377, with near-identical doc comments ("the header a redirect computes for itself"). This is one wire contract — the header a redirect owns — defined twice in two layers, which is exactly the kind of split the repo rules call out. Export the single constant (alongside `HeaderRules::names`/`without`, which already live in the defs proxy module) and have the reconciler use it.

-------

`crates/core/src/system/caddy/config.rs:560`: The production redirect shape (`redirect("/api/login")`, which desugars to `Literal + Tail + Query`) emits a `map` handler with an `input_regexp` that is evaluated against `{http.request.uri}` on every request under the prefix. Adjacent `<tail><query>` is exactly `{http.request.uri}` after the prefix is stripped, so this case can be served by a single `rewrite`/`strip_path_prefix` handler with no regexp at all — which is what the plan doc claims ("the production case costs one strip-prefix handler and nothing else") and what the tests in crates/core/src/system/caddy/tests.rs assert (`handlers(...) == ["rewrite", "static_response"]`, `location == "/api/login{http.request.uri}"`). As written, `redirect_location` always emits TAIL_VAR/QUERY_VAR and `needs_request_parts` always pulls in the regexp map, so the default form of the feature pays a per-request regular-expression match on the proxy hot path where a prefix-strip would do. Special-case a target ending in adjacent `Tail, Query` (and a `Tail`-only target, which maps to `{http.request.uri.path}`) to emit the rewrite instead, falling back to the map only when a query is named apart from the tail.

-------

`crates/core/src/system/caddy/config.rs:568`: The runtime tail/query extraction places raw, client-controlled request text into a `Location` template without excluding the proxy's placeholder syntax. Declaration-time validation refuses `{` in a target precisely because a braced word in a `Location` is substituted from the proxy's own state (its environment among it) — but `input_regexp: ^{prefix}([^?]*)(\?.*)?$` happily captures braces from `{http.request.uri}` into `{seedling.redirect.tail}` / `{seedling.redirect.query}`. A client can send `GET /v1/login/{env.SECRET}?x={env.SECRET}` (Go's HTTP server accepts braces in the request target and `{http.request.uri}` is the raw escaped form), so the extracted tail contains a placeholder. Whether it is expanded depends on whether Caddy's `map` handler runs its outputs through the replacer after regexp expansion — it documents placeholder support in outputs, so this is at least one replacer pass over attacker text, and the payoff is daemon environment disclosure in a header served to that client. Don't rely on that ordering: exclude braces from the captures so a brace-carrying request falls to `defaults` and contributes nothing, e.g. `^{prefix}([^?{}]*)(\?[^{}]*)?$` (same fail-safe already used for a URI that doesn't begin with the prefix).

-------

`crates/core/src/defs/service.rs:708`: The `/` guard compares the raw prefix string while the emitter normalises trailing slashes, so it is trivially bypassed. `route("//")` passes `route()`'s only validation (non-empty, starts with `/`), is not equal to `"/"`, and then `redirect_prefix("//")` trims to `""`, emitting `path: ["", "/*"]` — a matcher that answers every request on the hostname. That is exactly the whole-hostname redirect the rule declares an operator's to make, and because the raw prefix `"//"` is longer than `"/"` it sorts ahead of the pod-bound root route and swallows it. Normalise the prefix (the same `trim_end_matches('/')` the emitter uses) before the `== "/"` comparison, or reject a prefix containing `//` in `route()` so declaration and emission agree on what a prefix means.

-------

`crates/core/src/defs/service.rs:718`: The redirect/binding clash check keys on the raw prefix string (`bound_prefixes.contains(&prefix)` and `is_redirect(prefix)`), but the emitter treats `/v1/login` and `/v1/login/` as the same claim (`redirect_prefix` trims trailing slashes, and the proxied matcher is `/v1/login*`). So `web.route("/v1/login/").redirect(...)` alongside a pod bound at `web.route("/v1/login")` passes both directions of the check, then emits a redirect whose matcher covers the pod's prefix and, being the longer raw prefix, sorts ahead of it — every request intended for the pod is redirected instead. Normalise the prefix once (on `route()`, so `redirects`, `routes` and `bound_prefixes` all key on the canonical form) rather than trimming only at emission.

passcod and others added 2 commits September 17, 2026 20:02
The caddy tests still asserted the strip-prefix design the emitter had moved
off, which is what CI was failing on. The map design is the one that stands:
`strip_path_prefix` re-anchors an emptied path to `/`, so a request for
exactly the prefix would gain a trailing slash the spec forbids, and reading
the tail off the decoded path would let a `%3F` in a path segment reach a
client as the `?` that starts a query.

A prefix now has one spelling from `route()` onwards, and one kind. Three
prefix-keyed collections whose disjointness four checks defended are one map to
a `RouteKind`, and a trailing slash is trimmed at declaration rather than at
emission — without which `/v1/login/` and `/v1/login` passed the
either-redirected-or-proxied check as two prefixes and then claimed the same
requests, and `//` slipped past the root guard onto every request of the host.

The `/` fallback is restored for a service that declares a redirect: a
redirect sits above it, not in place of it.
@passcod
passcod added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 31907f0 Sep 17, 2026
16 checks passed
@passcod
passcod deleted the workhorse/k1 branch September 17, 2026 11:27
@passcod
passcod restored the workhorse/k1 branch September 17, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant