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) orregex. Matchers:methods(empty: any;HEADis notGET),headers(name case-insensitive,valueexact orregexsearched anywhere; a header sent several times matches if any value does) andquery(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
runtimeanswers, or404when it has none (an application with routes may omitruntime). 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%2Fxselects/adminand 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-automata0.4 (the engine behind theregexcrate, 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 nginxlocation ~ ^...) 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,503when busy),max_body(413), the tenant quotas, rewrite (400; deny rules re-checked,404), request header changes, the runtime (bounded byrequest_timeout), response header changes. The application’ssecurity(deny paths, per-path rate limits) and.htaccessrun before route selection, as before, on the path before the route’s rewrite. .htaccessrewrites. Route selection uses the path after.htaccessrewrites (that route’s runtime serves the request), but when.htaccesschanged the path, the route the client’s normalised path selects keeps its access policies: theallow/denyof both routes must pass, and itsbasic_authis checked instead of the selected route’s (the selected route’s applies only when the client route has none; oneAuthorizationheader 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/usersthrough/index.phpwithout the/adminroute’s authentication; nginx and Apache both check access before such rewrites. A routerewrite.path/rewrite.prefixon astaticorphproute of an application with.htaccessgets a load warning:.htaccesswas read for the client’s path, not for the rewritten path’s directories.- Rewrite.
rewrite.pathis a full path with$0(the match) and$1..$9(captures of a regex route;$$is a literal$);rewrite.prefixreplaces the matched prefix of a prefix route;rewrite.hostsets theHostsent upstream (impliespreserve_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/xwithprefix: /v2becomes/v2/%252e%252e/x, never/x. A capture that would form a.or..segment answers400, and so does a result that climbs above/. The rewritten path is checked against the application’s deny rules again. Inrewrite.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$nabove the regex’s group count,$on non-regex routes,prefixon non-prefix routes, andpathwithprefix. PHP keeps the client’s original URI asREQUEST_URI, as after an.htaccessrewrite. - Header changes.
setreplaces,addappends,removedeletes; 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, norhostin requests (userewrite.host). Proxies apply request changes after theirX-Forwarded-*headers, sosetcan 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 becomeHTTP_*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-inServerandx-request-idheaders. - Redirects. The
redirectruntime gains Gateway-style fields as an alternative toto:scheme,host,port,path,prefix(only on the runtime of a prefix route or the application’s own runtime) andstatus. Unset parts come from the request; an unsetportis the well-known port of a configuredscheme, else the request’s port; a well-known port is not written. The query is kept. max_bodyreplaceslimits.max_request_body_bytesfor the route, in both directions: a route may allow more than the global limit.request_timeoutbounds the time from the route match until the response head, through the response cache stage and the runtime, and answers504when 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 (
OPTIONSwithOriginandAccess-Control-Request-Method) is answered with204by ScalWS and never reaches the runtime; it carries the CORS headers only for an allowed origin. Other requests from an allowed origin getAccess-Control-Allow-Origin(the origin, or*forallow_origins: ["*"]without credentials),Access-Control-Allow-CredentialsandAccess-Control-Expose-Headersas 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 overresponse_headers. Every response of a CORS route carriesVary: Origin.*withallow_credentials: trueis refused at load;https://*.example.commatches one or more labels beforeexample.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_age20 days, at most 365). - Basic authentication.
users_fileis an htpasswd file of at most 1 MiB, read at load and on reload (and watched byserver.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 andAuthorizationvalues 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 is503withRetry-After: 1without queueing. Failures answer401withWWW-Authenticate: Basic realm="...". TheAuthorizationheader 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 aftertrusted_proxies; IPv4-mapped IPv6 addresses match IPv4 networks. Adenymatch answers403; withallowset, an address outside it answers403.- Response cache. Routes with
basic_auth,allowordenybypass 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_totalgains the reasonsroute_access,basic_authandbasic_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.
.htaccessrules 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
.htaccessrewrite to a route with its ownbasic_auth, a client route withbasic_authdecides alone: its users, not the selected route’s, are let through. - A prefix replacement (
rewrite.prefix, a redirect’sprefix) always yields a path starting with/, also when the rest after the prefix starts with an encoded slash (/old%[email protected]withprefix: /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_bodyand 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.