# Threat model
What `mini-static` defends against, what it deliberately does not, and — for every row in
the first table — the test that fails when the defence is removed.
The second and third tables are the more useful ones. A list of defences with no stated
boundary is marketing; naming what this crate does *not* do is what makes the first list
credible.
## Trust boundary
`mini-static` trusts:
- **The contents of the served root.** Every file under it is publicly readable by
anyone who can reach the server. Putting a secret there is a deployment decision this
crate cannot second-guess — the hidden-file policy narrows the default, it does not
make the root safe to fill with private data.
- **The operator's own configuration.** A 404 page, a response header or an
immutable-asset predicate supplied at startup is taken at face value, subject to the
validation rows below.
- **`hyper`**, for HTTP/1 framing — request lines, header parsing, chunked encoding, and
the smuggling defences at that layer.
- **The filesystem's containment primitives** — that a file descriptor's real path does
not change under it after `open` returns.
`mini-static` trusts nothing from the wire: not the request path, not its percent
encoding, not the method, not `Accept-Encoding`, not `Range`, not `If-None-Match`.
## Defended
Every row was verified by removing the guarantee from the source and confirming that
exactly that test fails. `./verify-guarantees.sh` performs those mutations and is the
evidence for this table — run it rather than believing it. Last full run: 24 of 24 caught.
| A `..` path segment is refused, however it was spelled | `resolve_traversal_attempt_rejected` | `traversal-rejected` |
| Segments supplied by a router are checked before they reach the filesystem — where they came from is the caller's business, whether they can escape the root is this crate's | `a_segment_containing_a_separator_is_refused` | `segments-from-a-router-are-checked` |
| A guard on a path prefix cannot be bypassed by encoding the separator — proven end to end, API routes and files in one process | `an_encoded_separator_cannot_bypass_a_guard_on_the_prefix` | `segments-from-a-router-are-checked` |
| The probe that runs *before* the original is opened makes the same refusals as the open it replaces — hidden segments, encoded separators, traversal | `the_pre_open_path_still_refuses_hidden_files` | `sidecar-preopen-makes-the-same-refusals` |
| A precompressed sidecar is served only if containment is proven on **its own** descriptor, so a symlinked sidecar cannot escape the root | `a_sidecar_symlinked_outside_the_root_is_declined` | `sidecar-containment-verified-on-fd` |
| A sidecar must be a regular file, so a directory is not descended via the index retry and a FIFO cannot block the request | `a_directory_named_like_a_sidecar_is_declined` | `sidecar-refuses-irregular-files` |
| The content cache holds only real regular files — symlinks, FIFOs, sockets and devices are refused | `a_symlink_to_a_file_outside_the_root_is_not_cacheable` | `cache-holds-only-real-files` |
| The population walk never follows a symlinked directory, so every cached path is inside the root by construction rather than by a check | `the_walk_does_not_descend_a_symlinked_directory` | `cache-walk-never-follows-a-link` |
| Enumeration is bounded, so a pathological root cannot make construction unbounded | `the_walk_stops_at_its_ceiling` | `cache-enumeration-bounded` |
| The byte budget stops rather than packing, so the cached set is a deterministic prefix of a sorted list | `the_budget_stops_rather_than_packing` | `cache-budget-stops-rather-than-packs` |
| A cache hit makes every refusal the disk path makes — traversal, encoded separator, hidden segment — from the same function | `a_cached_response_is_identical_to_an_uncached_one` | `cache-hit-makes-the-same-refusals` |
| A server holding both a content cache and live-reload refuses to start, in either builder order and on the composed path | `a_cache_and_live_reload_cannot_both_be_enabled` | `cache-refuses-live-reload` |
| A percent-encoded separator is not a separator (RFC 3986 §3.3) — `/a%2Fb` is one segment, not two | `an_encoded_separator_does_not_reach_a_nested_file` | `encoded-separator-refused` |
| A decoded backslash is refused on every platform, not only where it separates | `an_encoded_backslash_is_refused` | `encoded-backslash-refused` |
| A NUL byte, raw or `%00`, is refused at the path rather than deep inside a syscall | `resolve_null_byte_rejected` | `nul-byte-rejected` |
| Containment is proven on the *opened* file descriptor, so a symlink cannot escape the root and there is no check-to-open window | `resolve_rejects_symlink_escaping_root` | `containment-verified-on-fd` |
| Dot-prefixed segments answer as a miss by default | `dotfiles_are_denied_by_default` | `hidden-files-denied` |
| `.well-known` is exempt at the root, and only at the root | `well_known_is_served_despite_its_leading_dot` | `well-known-exempt` |
| `X-Content-Type-Options: nosniff` is on every response shape, including 304, 404 and 405 | `every_response_carries_nosniff_and_success_returns_the_real_file_content` | `nosniff-on-every-response` |
| A per-response computed header cannot be pinned to a fixed value by configuration | `server_computed_headers_are_refused` | `computed-headers-refused` |
| An HTML page over the injection cap is served unmodified rather than buffered whole | `an_html_page_over_the_cap_is_served_unmodified_and_logged` | `injection-cap-enforced` |
| `run`/`run_ephemeral` bind loopback only — a convenience entry point never exposes a server to the LAN | `the_ephemeral_bind_address_is_loopback` | `ephemeral-bind-loopback` |
| Shutdown drains in-flight work, then stops waiting — an open SSE stream cannot hold the process open | `live_reload_shutdown_aborts_a_still_open_sse_connection_after_the_drain_timeout` | `shutdown-drain-bounded` |
| A configured 404 page outside the served root is refused at configuration time | `a_page_outside_the_root_is_rejected` | `404-page-must-be-inside-root` |
| An unsatisfiable `Range` is a 416, not a silent full-body 200 | `range_out_of_bounds_returns_416` | `range-unsatisfiable-is-416` |
Beyond the example-based tests above, path resolution is covered by property tests over
generated paths (`tests/resolve_property.rs`): every resolved path lies under the root's
canonical form, traversal is always rejected, and hidden segments never resolve.
## Inherited from `mini-serve`
Not implemented here. This crate builds a `mini-serve` app with itself as the fallback
handler, so the connection lifecycle — accepting, bounding, timing out and draining — is
`mini-serve`'s, and is mutation-verified there rather than twice.
These rows moved out of the table above when that happened. Their tests stayed: they run
against a real socket through this crate's own `run_ephemeral`, so they prove the
inheritance is actually wired up, which a citation alone would not.
| Concurrent connections are capped | `server_with_max_connections_bounds_concurrent_connections` | `connection-ceiling` |
| The header-read timeout re-arms per *message* | `a_stall_on_the_second_request_of_a_connection_still_times_out` | `header-timeout-per-message` |
| A header block over 64 KiB is refused on its size | `an_oversized_header_on_the_second_request_is_still_rejected` | `header-block-bounded` |
| Shutdown drains in-flight work, then stops waiting | `live_reload_shutdown_aborts_a_still_open_sse_connection_after_the_drain_timeout` | `shutdown-drain-bounded` |
| An HTTP/1.1 request with no `Host` is refused (RFC 9112 §3.2) | — | `host-required-on-http11` |
| The request path is split before it is decoded, so `%2F` is never a separator | `an_encoded_separator_does_not_reach_a_nested_file` | `fallback-gets-router-segments` |
**The header-block ceiling went the other way.** `mini-serve` had no such bound until this
migration: adopting it would have silently dropped this crate's 64 KiB limit, and the test
above is what caught it. The bound now lives in `mini-serve` and every one of its
consumers gets it.
## Enforced by construction
These are not in the table because there is nothing to mutate — they hold by type, by
manifest, or by the compiler, which is stronger than a test.
- **No `unsafe` in this crate's own code.** A statement about `src/` only; `tokio` and
`hyper` contain `unsafe`, as any async runtime must.
- **HTTP/1 only.** The `hyper` dependency declares `features = ["http1", "server"]` and no
h2, so an HTTP/2 preface cannot negotiate a session — there is no h2 implementation
linked in to negotiate with. Pinned by `an_http2_preface_never_negotiates_an_http2_session`
as a regression guard.
- **A 404 body cannot echo the requested path.** `StaticError::user_message()` returns
`&'static str`, so there is no dynamic content available to leak. Both a miss and a
traversal attempt return the same `"not found"`, which is also why the two are
indistinguishable to a client probing for what the root contains.
## Not defended
- **`containment-verified-on-fd` was false on the sidecar path from 0.9.0 until 0.38.5.**
Recorded here rather than quietly fixed, because the guarantee was listed as defended and
mutation-tested for that whole period. `select_precompressed_sidecar` opened its file with
a bare `File::open` and inferred containment from the *original* having been verified, so a
`styles.css.br` symlinked outside the root was served for `GET /styles.css` with
`Accept-Encoding: br` — while `GET /styles.css.br` on the same file correctly returned 404.
The existing mutation test passed throughout because it exercised `resolve` and never the
probe.
Two lessons are worth keeping over the fix itself. **A guarantee is only proven on the
paths its test actually walks** — this is the second time a second route to bytes skipped
the first route's refusals, after the content cache served `/.env`. And **a `debug_assert`
is not a control**: the parent-equality assertion standing here compared constructed paths
rather than descriptors, and was compiled out of every release build regardless.
Reachability needed a symlink inside the served root, which extracting an untrusted archive
(`tar` and `unzip` both preserve symlinks), a user-upload directory, or a CI artifact drop
all produce without shell access. For anyone on a published version before 0.38.5 the
mitigation is to allow no symlinks into the served root.
- **With `with_content_cache`, this crate serves what it read at start-up.** The cache is never
invalidated: a file replaced in place, or torn by a concurrent write during the walk, is served
in that state until the process restarts. That is a guarantee **given up** in exchange for
throughput, not an oversight, and it is why a server holding both the cache and live-reload
refuses to start rather than choosing between them. A deployment that writes into its served
root while running must not enable it.
Two narrower notes on the same promise:
- **A torn read becomes permanent.** On the request path a file being written mid-read costs
one request; cached, it costs every request until restart.
- **The race between enumerating a file and reading it is not closed.** A file seen as a
regular file could be replaced by a symlink before it is read, and the read would follow it.
Closing that would need `O_NOFOLLOW`; it is not closed because **it requires write access to
the served root, and a writer with that access can simply write the file directly.** The
walk's symlink refusal exists for the operator who leaves a link pointing out of the root by
accident, which is a configuration mistake rather than an attack, and it holds.
- **A cache hit rate depends on the filesystem.** Keys are byte-exact, so on a case-insensitive
filesystem `/STYLES.CSS` — which serves today, because folding is the operating system's —
misses the cache and falls through to disk. Correct, but it means the hit rate is not portable.
Normalising keys would be worse: it risks serving a file for a path that must `404` on a
case-sensitive system.
- **Nothing under the root is private.** There is no authentication, no authorisation and
no per-path access control. The hidden-file policy hides dotfiles from the default
configuration; it is not an access-control mechanism and must not be used as one.
- **No rate limiting.** The connection ceiling bounds concurrency, not request rate. A
client within the ceiling may request as fast as it likes.
- **No content cache.** Every request resolves and opens the file. This is a throughput
decision, not a security one, but it means a hot path is not shielded from disk.
- **TLS is out of scope**, permanently. Terminate it in front, or compose `mini-serve`
with `mini-tls`.
- **The `Range` implementation answers a single range only.** A multi-range request is
answered with the full body rather than a multipart response — deliberate, since
multipart range assembly is a parser this crate would rather not own.
- **Symlink containment depends on the platform.** On macOS, iOS and Linux the check runs
against the opened descriptor's real path. Elsewhere it falls back to
canonicalize-then-open, which re-admits a check-to-open window. Both variants are
exercised by the property tests on whichever platform compiles them.