# ADR-0050: Route matching and route policies

URL: https://scalws.com/docs/adr/0050-route-matching-and-policies/
Source: `docs/adr/0050-route-matching-and-policies.md` in the ScalWS repository

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%2F@evil.example` with `prefix: /` redirects to `/%2F@evil.example` 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.
