Decision records

ADR-0006: Static file confinement

  • Status: Accepted (2026-10-05), revised 2026-10-05 (openat2 implemented)

Decision

  1. Percent-decode the request path once. Reject NUL, backslash, decoded / inside a segment (%2f), and any .. segment. Collapse . and empty segments.
  2. Deny any segment starting with . except the first segment .well-known.
  3. Linux: open the result with openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) relative to a directory fd of the root opened at prepare time, with O_NONBLOCK | O_NOCTTY | O_CLOEXEC. The kernel guarantees confinement for the whole resolution, so a concurrently swapped symlink cannot escape (closes the TOCTOU window of the canonicalise approach), and opening a FIFO cannot block. File type and size come from fstat on the opened fd.
  4. Fallback (other OSes; kernels without openat2; seccomp policies that reject it): canonicalise root/rel and require it to stay beneath the canonical root.
  5. Directories serve configured index files (same lookup); never list.

Consequences

  • With RESOLVE_BENEATH, absolute symlinks are refused even if they point inside the root; relative symlinks that stay inside the root work. This is stricter than the fallback and is documented for operators.
  • Non-blocking hot path (Linux): the lookup is first attempted on the async worker with RESOLVE_CACHED (5.12+; EAGAIN unless every component is in the dentry cache) and small files are read with preadv2(RWF_NOWAIT) (EAGAIN unless in the page cache). Anything that would block is redone on the blocking pool. Large files stream in 256 KiB positional reads, inline when cached, otherwise one blocking-pool hop per chunk; no thread is held between chunks, so slow clients cannot pin blocking threads.
  • On overlayfs (e.g. a container’s writable layer) RESOLVE_CACHED typically returns EAGAIN, so the blocking path is used: correct but slower. Serve document roots from a real filesystem (ext4/xfs, or a volume in containers).