Internals: decision records

ADR-0050: Route matching and route policies

Status: accepted, 2026-10-14

Context

Routes matched on a whole-segment path prefix only, longest first. An ingress controller (Kubernetes phase 2b and 2c, ADR-0049) needs exact and regex paths, method, header and query matching, and the per-route policies that ingress-nginx annotations and the Gateway API express: rewrites, header changes, redirects that replace parts of the URL, body limits, timeouts, CORS, basic authentication and address allow/deny lists. Paths are attacker-controlled input: a regex engine must not backtrack, and a rewrite must not open a second decoding step that reaches paths the deny rules or the route itself would refuse. Existing configurations must keep their behaviour and their cost per request.

Decision

  • Path types. path_type: prefix (default, whole segments), exact (the decoded, normalised path) or regex. Matchers: methods (empty: any; HEAD is not GET), headers (name case-insensitive, value exact or regex searched anywhere; a header sent several times matches if any value does) and query (the decoded value of the first occurrence of the name, percent-decoding only, + stays +). At most 16 entries in each list. All matchers of a route must match.
  • Precedence within an application: exact routes, then prefix routes by path length (longest first), then regex routes in configuration order. Among routes with the same path type and path, a route with a method matcher first, then more header matchers, then more query matchers, then the earlier route. A route whose matchers fail falls through to the next candidate; when none matches, the application’s runtime answers, or 404 when it has none (an application with routes may omit runtime). Two routes with the same path type, path and matchers are an error; a regex route after a matcher-less prefix route / is never reached and gets a warning.
  • Fast path. An application whose routes are all prefix routes without matchers uses the existing longest-prefix router: no matcher work and no extra cost per request.
  • Segments. A prefix route matches whole segments of the normalised path. An encoded slash (%2F, any case), which normalisation keeps as three characters, also ends a segment for route selection: /admin%2Fx selects /admin and its policies, because a proxied backend may decode it to /admin/x. File handlers still refuse encoded slashes.
  • Regex engine. The meta engine of regex-automata 0.4 (the engine behind the regex crate, used directly for anchored search and size limits): linear time, no backtracking, no look-around or back-references. Path patterns are anchored at the start of the path and not at the end (as nginx location ~ ^...) and run on the decoded, normalised path; case-sensitive unless the pattern says (?i). Limits: pattern text at most 4096 bytes, nesting at most 64, compiled size at most 256 KiB per pattern (each automaton the engine builds is limited to that size as well); the lazy-DFA search cache is capped at 32 KiB, and the meta engine keeps one cache per thread that searches concurrently, so a pattern uses at most its compiled size plus 32 KiB per event loop. Compiled size plus one cache per pattern, summed over every path, header and query regex, is limited to 8 MiB per configuration. A larger pattern or configuration is refused at load.
  • Policies, all optional, on every route (and so on a whole application through a route path: /): rewrite, request_headers, response_headers, max_body, request_timeout, https_redirect (overrides the application’s, both ways), cors, basic_auth, allow, deny.
  • Order for a matched route: allow/deny (403), CORS preflight (204), basic authentication (401, 503 when busy), max_body (413), the tenant quotas, rewrite (400; deny rules re-checked, 404), request header changes, the runtime (bounded by request_timeout), response header changes. The application’s security (deny paths, per-path rate limits) and .htaccess run before route selection, as before, on the path before the route’s rewrite.
  • .htaccess rewrites. Route selection uses the path after .htaccess rewrites (that route’s runtime serves the request), but when .htaccess changed the path, the route the client’s normalised path selects keeps its access policies: the allow/deny of both routes must pass, and its basic_auth is checked instead of the selected route’s (the selected route’s applies only when the client route has none; one Authorization header cannot satisfy two users files). Such a request bypasses the response cache when either route is restricted. Without this a front controller (RewriteRule ^ index.php) would serve /admin/users through /index.php without the /admin route’s authentication; nginx and Apache both check access before such rewrites. A route rewrite.path/rewrite.prefix on a static or php route of an application with .htaccess gets a load warning: .htaccess was read for the client’s path, not for the rewritten path’s directories.
  • Rewrite. rewrite.path is a full path with $0 (the match) and $1..$9 (captures of a regex route; $$ is a literal $); rewrite.prefix replaces the matched prefix of a prefix route; rewrite.host sets the Host sent upstream (implies preserve_host: false). The rewrite never decodes the request twice: it starts from the decoded, normalised path, every piece copied from the request (the rest after the prefix, each capture) is percent-encoded once, configuration literals are inserted as written (percent-encoded text), and the result is normalised once with the rules of an incoming path. So /app/%252e%252e/x with prefix: /v2 becomes /v2/%252e%252e/x, never /x. A capture that would form a . or .. segment answers 400, and so does a result that climbs above /. The rewritten path is checked against the application’s deny rules again. In rewrite.path, the part after the first ? is the new query and replaces the request’s (captures there are encoded as query values); without ? the query is kept. Validation refuses $n above the regex’s group count, $ on non-regex routes, prefix on non-prefix routes, and path with prefix. PHP keeps the client’s original URI as REQUEST_URI, as after an .htaccess rewrite.
  • Header changes. set replaces, add appends, remove deletes; at most 64 names per block, values at most 8 KiB of printable ASCII. Hop-by-hop and framing headers (connection, keep-alive, proxy-connection, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade, content-length) cannot be changed, nor host in requests (use rewrite.host). Proxies apply request changes after their X-Forwarded-* headers, so set can replace those; other runtimes see them at once. Handlers declare whether they apply them themselves (the proxy, pool proxy and worker-pool handlers); for every other handler, PHP included (where they become HTTP_* FastCGI parameters), the pipeline applies them before the handler runs. Response changes apply to every response after the route match, including answers generated by ScalWS (401, 403, 413, 504, …), after the built-in Server and x-request-id headers.
  • Redirects. The redirect runtime gains Gateway-style fields as an alternative to to: scheme, host, port, path, prefix (only on the runtime of a prefix route or the application’s own runtime) and status. Unset parts come from the request; an unset port is the well-known port of a configured scheme, else the request’s port; a well-known port is not written. The query is kept.
  • max_body replaces limits.max_request_body_bytes for the route, in both directions: a route may allow more than the global limit.
  • request_timeout bounds the time from the route match until the response head, through the response cache stage and the runtime, and answers 504 when it passes. The response body is not bounded, so long downloads are not cut; for a WebSocket it bounds the handshake only. There is no server-wide request timeout.
  • CORS. A preflight (OPTIONS with Origin and Access-Control-Request-Method) is answered with 204 by ScalWS and never reaches the runtime; it carries the CORS headers only for an allowed origin. Other requests from an allowed origin get Access-Control-Allow-Origin (the origin, or * for allow_origins: ["*"] without credentials), Access-Control-Allow-Credentials and Access-Control-Expose-Headers as configured. The route owns these three headers: values a runtime sets are replaced or removed, so an upstream cannot allow more than the route, and they win over response_headers. Every response of a CORS route carries Vary: Origin. * with allow_credentials: true is refused at load; https://*.example.com matches one or more labels before example.com, and scheme and port must match. An origin whose host ends with a dot is refused at load (browsers send the host without it, so it would never match). Defaults follow ingress-nginx (allow_methods, allow_headers, max_age 20 days, at most 365).
  • Basic authentication. users_file is an htpasswd file of at most 1 MiB, read at load and on reload (and watched by server.reload.watch). On Linux the file and its directory must belong to root or to the user scalwsd runs as and must not be writable by group or others. Accepted hashes: bcrypt $2a$, $2b$, $2y$ (cost 4 to 12), SHA-256 and SHA-512 crypt $5$, $6$ (at most 100,000 rounds) and Apache MD5 $apr1$. Plain text, {SHA} and DES crypt entries are refused with a warning at load and never match. Every check of a known or unknown user computes one hash (an unknown user is checked against the most expensive entry), hash parts are compared in constant time, passwords over 1024 bytes and Authorization values over 4096 bytes are refused without hashing. Verification runs on the blocking pool with at most 64 at once in the process; above that the answer is 503 with Retry-After: 1 without queueing. Failures answer 401 with WWW-Authenticate: Basic realm="...". The Authorization header is not passed to the runtime.
  • allow / deny. Addresses and CIDR blocks (IPv4 and IPv6, at most 1024 entries each), compared with the client address after trusted_proxies; IPv4-mapped IPv6 addresses match IPv4 networks. A deny match answers 403; with allow set, an address outside it answers 403.
  • Response cache. Routes with basic_auth, allow or deny bypass the response cache, for lookups and stores: the cache is keyed by URL, and another route on the same URL must never be answered with a restricted response. When an application’s routes are not prefix-only, the matched route’s identity (a hash of its path type, path and matchers, stable across reloads that keep the route) is part of the cache variant, so routes on one URL that differ by matchers (a header-selected canary) never share an entry; prefix-only applications keep the variant they had.
  • Metrics. scalws_security_blocked_total gains the reasons route_access, basic_auth and basic_auth_busy.

Consequences

  • Prefix-only configurations take the same code path as before; configurations with matchers pay for the matchers of the candidate routes only.
  • Regular expressions cannot cause catastrophic backtracking, and their memory is bounded at load; patterns that need look-around or back-references cannot be expressed.
  • .htaccess rules and per-path rate limits see the path before a route’s rewrite; only the deny rules are checked again on the rewritten path.
  • After an .htaccess rewrite to a route with its own basic_auth, a client route with basic_auth decides alone: its users, not the selected route’s, are let through.
  • A prefix replacement (rewrite.prefix, a redirect’s prefix) always yields a path starting with /, also when the rest after the prefix starts with an encoded slash (/old%[email protected] with prefix: / redirects to /%[email protected] on the same host).
  • Cached entries of applications with matchers are kept per route: fewer hits when sibling routes would have produced the same response.
  • Responses of restricted routes are never cached; a public entry already cached for the same URL stays until it expires when a reload makes a sibling route restricted.
  • Route selection now happens before the request body size check and the tenant quotas, because max_body and the policies depend on the route.

Amended 2026-10-15 (Gateway API, ADR-0052): the tie-break among equal paths follows the Gateway API (method, then header count, then query count) instead of the total matcher count. Order changes where same-path routes differ in the kind of matcher: a method list counts once however many methods it names, and header matchers outrank any number of query matchers. Configurations whose same-path routes use only header matchers, or only query matchers, keep their order; the routes the ingress controller generates keep theirs.