# Threat model
What `mini-serve` 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 table is the more useful one. 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-serve` trusts:
- **The code its user writes.** Handlers, middleware and state are the application's
responsibility. A handler that reflects unescaped input or leaks a secret is not
something this crate can prevent.
- **The transport beneath it.** TLS termination, whether by the `tls` feature or by a
platform in front of the process, is assumed correct.
- **`hyper`**, for HTTP/1 framing — request lines, header parsing, chunked encoding, and
the smuggling defences that live at that layer.
`mini-serve` trusts nothing from the wire: not the path, not the query string, not header
values, not the body, not the declared body length, not the request method.
## 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: 22 of 22 caught.
| A request header block over 64 KiB is refused on its size — the read timeout does not cover a peer that sends headers steadily and forever | `an_oversized_header_block_is_refused_on_its_size` | `header-block-bounded` |
| Concurrent connections are capped; a queued client is served, not dropped | `max_connections_ceilings_concurrent_requests` | `connection-ceiling` |
| The header-read timeout re-arms per *message*, so request 2 on a keep-alive connection is bounded too | `a_stall_on_the_second_request_of_a_connection_still_times_out` | `header-timeout-per-message` |
| A request path over 8 KiB is rejected before routing | `an_oversized_path_is_rejected` | `oversized-path-rejected` |
| A query string over 4 KiB is rejected before routing | `an_oversized_query_is_rejected` | `oversized-query-rejected` |
| An oversized `Content-Length` is refused from the headers alone, before a byte of body is read | `an_oversized_content_length_is_refused_before_the_body_is_sent` | `body-limit-content-length` |
| A chunked body with no `Content-Length` cannot overrun the limit by streaming | `a_chunked_body_that_overruns_the_limit_is_rejected` | `body-limit-chunked-overrun` |
| `X-Content-Type-Options: nosniff` is on every response shape, including 404, 405, 400 and handler errors | `nosniff_is_sent_on_every_response_shape` | `nosniff-every-response` |
| Connection-framing headers cannot be pinned to a fixed value by configuration | `connection_owned_headers_are_refused` | `framing-headers-refused` |
| CORS headers are applied on *every* exit path, not only handler success | `error_responses_carry_cors_headers_too` | `cors-on-error-exits` |
| Credentialed-wildcard CORS is unrepresentable — rejected at construction, in debug and release alike | `credentialed_wildcard_rejected_in_debug` | `cors-credentialed-wildcard` |
| A preflight for an unregistered path 404s rather than being masked by a 204 | `cors_preflight_only_for_registered_routes` | `preflight-only-real-routes` |
| A 5xx never echoes the handler's internal error to the client; the operator still gets it | `a_5xx_reports_its_internal_message_while_the_client_body_stays_sanitized` | `5xx-body-sanitized` |
| A panicking handler is reported rather than silently dropping the connection | `a_handler_panic_is_reported` | `handler-panic-reported` |
| Shutdown drains in-flight work, then stops waiting — a wedged handler cannot hold the process open | `a_wedged_handler_does_not_hold_shutdown_open_forever` | `shutdown-drain-bounded` |
| A HEAD response reports the `Content-Length` its GET would (RFC 9110 §9.3.2) | `head_reports_the_length_of_the_get_it_mirrors` | `head-length-matches-get` |
| An HTTP/1.1 request with no `Host` is refused (RFC 9112 §3.2) — hyper 1.11 serves these | `http_1_1_without_host_is_refused` | `host-required-on-http11` |
| A transport that never finishes negotiating does not hold its connection slot | `a_transport_that_never_completes_does_not_hold_its_slot` | `connect-step-bounded` |
| A fallback is handed the segments the router already split and decoded, so no consumer parses the path a second time | `the_fallback_receives_the_routers_own_segments` | `fallback-gets-router-segments` |
| A fallback registered with `with_fallback` runs *inside* the middleware chain, so a guard on a path prefix protects what the fallback serves under it | `middleware_wraps_the_fallback` | `fallback-is-wrapped` |
| An upgraded connection still counts against `max_connections` and is still ended by the shutdown drain | `an_upgraded_connection_still_counts_against_max_connections` | `upgrade-keeps-its-permit` |
| `bind_ephemeral` binds loopback only — a test helper never exposes a server to the LAN | `ephemeral_bind_addr_is_loopback_only` | `ephemeral-bind-loopback` |
Beyond the example-based tests above, the decoding surface this crate owns — percent
decoding and trie matching — is covered by property tests over generated paths
(`tests/unit/router_properties.rs`). The load-bearing one is path confusion: percent
encoding an unreserved character must not change which route matches or what it captures,
so `/hello/ada` and `/hello/%61da` cannot be routed differently. Replacing the decode with
raw-byte matching fails it.
Additionally, and enforced by the compiler rather than by a test: **this crate's own code
contains no `unsafe`** (`#![forbid(unsafe_code)]`). That is a statement about `src/` only.
Dependencies including `tokio`, `hyper`, `bytes` and `mio` all contain `unsafe`, as any
async runtime must; nothing here makes a claim about the tree as a whole.
## Not defended — by design
Each of these is a deliberate boundary, with what to compose instead.
| Authentication and authorisation | Identity is application-shaped; a server that guesses at it gets it wrong | Middleware in your application |
| Rate limiting, abuse scoring, IP reputation | Needs state, time windows and policy that outlive one request | [`mini-guard`](https://gitlab.com/makeitmini/secure), which ships a `serve` feature with `GuardMiddleware` |
| TLS certificate issuance, renewal, rotation | Platform-owned; duplicating it invites two sources of truth | Terminate at Fly.io / Cloudflare, or supply a `rustls::ServerConfig` via the `tls` feature |
| Volumetric DDoS absorption | Cannot be solved inside the process being flooded | A network-edge provider |
| Request body *content* validation beyond size | Schema and semantics belong to the handler | `serde` in your handler; `json_body` bounds the size, not the meaning |
| Log storage, shipping, retention | Not a server concern | `with_request_logging_to` takes any `Write`; point it wherever you ship |
| HTTP/1 framing and request smuggling | Belongs to the HTTP layer, and re-implementing it would add risk, not remove it | `hyper` — see `tests/adversarial.rs` for what the stack rejects |
## Known limits worth stating
- **The body limit binds `json_body`, not the connection.** A handler that consumes
`Incoming` itself is responsible for its own bound. `with_max_body_size` configures the
helper's ceiling.
- **An upgraded connection has no idle timeout.** The header-read timeout stops applying
once a connection stops being HTTP. An upgraded connection is bounded by
`max_connections` and by the shutdown drain, but a peer can hold one open indefinitely
while the server runs. Supplying an idle bound is the protocol's job — WebSocket
ping/pong, or whatever the tunnelled protocol defines — and a crate built on
[`OnUpgrade`] should provide one.
- **Request logging is opt-in.** Nothing is logged without `with_request_logging`, so an
operator who wants an audit trail must ask for one.
- **The logged path is the raw, undecoded request path**, deliberately: a probe is the
line an operator most needs verbatim, and normalising it would hide what was sent.
- **Supply-chain checks run in `gate.sh`, not in CI.** CI is disabled for this repo, so
advisory scanning happens when someone runs the gate — not continuously. `cargo audit`
(advisories) and `cargo deny` (licences, banned crates, non-crates.io sources, per
`deny.toml`) both SKIP rather than FAIL when the tool is absent or the advisory
database is unreachable, so an offline run does not go red. The failing path is
verified rather than assumed: adding a dependency with a known advisory turns the gate
red and names the RUSTSEC id.
## Reporting
Security issues: open a confidential issue on the repository rather than a public one.