header_in fn
fn header_in(hs: List[(Str, Str)], name: Str) -> Opt[Str]The first value of the header name in hs, matched case-insensitively.
Package
Composable HTTP handler wrappers: CORS, security headers and CSP, request ids, access logs, limits, deadlines, negotiation, auth and host checks
0.1.0 MIT
sspur add middleware@^0.1.0or in sspur.toml:
middleware = { version = "^0.1.0" }Composable wrappers for SSPUR HTTP handlers, the pieces of helmet, cors, morgan and friends that the server's own Server fields don't cover: CORS with credentials, wildcard subdomains and Private Network Access, security headers with a CSP builder and per-request nonces, request ids, JSON access logs with redaction and timing, per-route body and media type limits, deadlines, content negotiation, method override, Basic authentication, host allowlists, HTTPS redirects behind a proxy, IP filtering with CIDR rules and trusted proxies, and cross-site request guards (Fetch Metadata and Origin checks). Errors are RFC 9457 problem details.
A handler is a function Request -> Response ! e; a wrapper takes one and returns one with the same effects plus what it needs (log and time for access logs, rand for ids and nonces). Most of the package is also available as pure functions of the request and response (cors_preflight, security_headers, body_problem, client_ip, ...), so handlers and tests can use the logic without the wrappers.
$ sspur add middleware
use middleware.{Cors, cors, cors_for, secure, security_api, body_limit, request_ids, request_ids_default, access_log, access_log_default, compose, negotiate_media}
type ApiErr = NotFound{what: Str}
fn api(req: Request) -> Response ! fail[ApiErr]
= if req.path == "/notes" then http.json(200, ["first note"]) else raise NotFound{what: req.path}
fn guarded(req: Request) -> Response
= catch api(req)
| NotFound{what} => middleware.problem(404, "no route for " + what)
fn policy() -> Cors
= cors_for(["https://app.example"]) with credentials := true
// The whole stack: ids and access logs outside, then CORS, security headers and a body limit.
fn handler(req: Request) -> Response ! log, rand, time
= request_ids(request_ids_default(), access_log(access_log_default(), compose([n => cors(policy(), n), n => secure(security_api(), n), n => body_limit(65536, n)], guarded)))(req)
fn rq(m: Str, path: Str, hs: List[(Str, Str)]) -> Request
= Request{method: m, url: path, headers: hs, body: "", timeout_ms: 0, retries: 0, redirects: 0}
fn main() -> Unit ! log, rand, time
= do
pre = handler(rq("OPTIONS", "/notes", [("origin", "https://app.example"), ("access-control-request-method", "DELETE")]))
log("preflight: {pre.status} {pre.header("access-control-allow-methods").or("")}")
r = handler(rq("GET", "/notes", [("origin", "https://app.example"), ("x-request-id", "demo-1")]))
log("get: {r.status} {r.body} allow-origin={r.header("access-control-allow-origin").or("-")} frame={r.header("x-frame-options").or("-")}")
missing = handler(rq("GET", "/nope", []))
log("missing: {missing.status} {missing.body}")
log("negotiated: {negotiate_media("text/html;q=0.5, application/json", ["text/html", "application/json"])}")
// Serving it: http.serve_with(http.server(8080) with access_log := false, req => handler(req))
$ sspur run main.ssp
info: {"method":"OPTIONS","target":"/notes","status":204,"ms":0,"size":0,"id":"01a145c7-33b6-75a0-874b-161958864cb5","agent":"","referer":""}
preflight: 204 GET, HEAD, POST, PUT, PATCH, DELETE
info: {"method":"GET","target":"/notes","status":200,"ms":0,"size":14,"id":"demo-1","agent":"","referer":""}
get: 200 ["first note"] allow-origin=https://app.example frame=DENY
warn: {"method":"GET","target":"/nope","status":404,"ms":0,"size":85,"id":"01a145c7-33b7-78a9-b3b5-bd9b09a0f62a","agent":"","referer":""}
missing: 404 {"type":"about:blank","title":"Not Found","status":404,"detail":"no route for /nope"}
negotiated: some("application/json")
Handlers run concurrently, so the checker doesn't let a handler use a function value built outside it (E_PAR_SHARE). Build the stack inside the handler, as handler above does, and serve req => handler(req); that costs a few closures per request.
compose([a, b, c], h) is a(b(c(h))), for layers that keep the effect row (CORS, security headers, limits, guards). Nest the wrappers that add log, time or rand directly, as above: the checker doesn't yet infer one row for a list of layers that add different effects (see "Known limitations").
Errors: a raised error can't be caught generically by a wrapper today, so catch it in the handler: req => catch api(req) | e => middleware.internal_error(req, e) answers 500 with only the request id and logs the error under that id. Traps (overflow, failed contracts) can't be caught in SSPUR; the server answers them with 500 and a log line.
header_in(hs, name), header_values(hs, name): the first value or every value of a header, case-insensitively.set_header(r, name, value), add_header, remove_header, default_header: change a response's headers (set replaces, add appends, default sets only when missing). Names and values are checked: no header injection.set_request_header(req, name, value), remove_request_header(req, name): the same for requests.valid_header_name(s), valid_header_value(s): RFC 9110 tokens, and values without CR, LF or NUL.split_list(s): the elements of a comma-separated field value.add_vary(r, field): merge a field into Vary.problem(status, detail), problem_with(status, detail, members): RFC 9457 application/problem+json responses, with no-store.redirect(status, location): 301, 302, 303, 307 or 308.reason_phrase(status): the standard reason phrase.internal_error(req, err): a 500 problem carrying the request id, with err logged.compose(layers, h): the first layer outermost.only_when(pred, layer, next), under(prefix, req): apply a layer to some requests, such as everything under /admin.before(f, next), after(f, next): change the request or the response.with_headers(hs, next), strip_headers(names, next), no_store(next): fixed headers, removed headers, and cache-control: no-store by default.percent_decode(s, plus), percent_encode(s): RFC 3986 percent-encoding, strict about bad escapes and UTF-8.query_string(url), query_pairs(url): the raw and the decoded query.redact_query(url, names): hide secret parameters before logging.Weighted, parse_weighted(header): elements of Accept-style fields with their q weights; malformed weights are dropped.negotiate_media(header, offers): the best media type by specificity and weight.negotiate_encoding(header, offers): the content coding, with identity and * handled as the RFC says.negotiate_language(header, offers): RFC 4647 basic filtering.media_type(content_type), media_matches(pattern, mt): parse a Content-Type, match type/* and application/*+json.Cors, cors_for(origins), cors_public(): the policy, exact origins and https://*.example.com patterns, credentials, exposed headers, preflight caching and Private Network Access.cors(p, next): answer preflights and decorate responses.cors_preflight(p, req), cors_headers(p, req), is_preflight(req), cors_allows(p, origin), origin_matches(pattern, origin), valid_origin_pattern(s): the same logic as functions.The rules: null is only allowed when listed, a public policy can't send credentials (a precondition), credentialed answers reflect the exact origin and never *, and every origin-dependent answer varies on Origin. A refused preflight is 403 without CORS headers.
Security, security_defaults(), security_api(), security_headers(s), apply_security(s, r), secure(s, next): HSTS, CSP, X-Frame-Options, Referrer-Policy, X-Content-Type-Options, the three Cross-Origin-*-Policy headers, Permissions-Policy, X-DNS-Prefetch-Control, X-Permitted-Cross-Domain-Policies, Origin-Agent-Cluster and X-XSS-Protection: 0. Values are checked by refinements.Hsts, hsts_value(h): RFC 6797, with the preload requirements as a precondition.Csp, csp_empty(), csp_strict(), csp_helmet(), csp_set, csp_add, csp_remove, csp_report_only, csp_value, csp_header, valid_directive, valid_source: a CSP Level 3 builder that refuses sources that could end a directive.src_self(), src_none(), src_unsafe_inline(), src_unsafe_eval(), src_wasm_unsafe_eval(), src_strict_dynamic(), src_report_sample(), src_nonce(n), src_sha256(content), src_sha512(content): source expressions.with_nonce(c, next), request_nonce(req), nonce_header(): a fresh 128-bit nonce per request, given to the handler and added to script-src and style-src.permissions_policy(features): the structured Permissions-Policy value.RequestIds, request_ids_default(), request_ids(p, next), request_id(req), valid_request_id(id, max): keep a valid client id or make a UUIDv7, and echo it.AccessLog, AccessEntry, access_log_default(), access_log(p, next), access_entry(p, req, r, ms), access_level(p, a): one JSON line per request (SSPUR_LOG=json turns its fields into the line's fields), errors for 5xx, warnings for 4xx and slow answers, secrets in the query and Referer redacted, paths like /healthz skipped.server_timing(next): Server-Timing: app;dur=....body_limit(max, next), body_problem(req, max), content_length(req): 413 above a per-route size, 400 for a malformed Content-Length.require_media(types, next), media_problem(req, types): 415 with Accept-Post or Accept-Patch.Deadline, deadline_of(ms), deadline(d, next), remaining_ms(req, header, now_ms): a request budget the handler can pass on to outgoing calls, and 503 for late answers to safe requests.method_override(next), overridden_method(req): POST treated as PUT, PATCH or DELETE from the usual headers or a _method form field, never as a safe method.basic_auth(realm, check, next), basic_credentials(req), basic_challenge(realm), secret_equal(a, b): RFC 7617 with UTF-8, and a comparison whose time depends on neither contents nor lengths.allowed_hosts(patterns, next), request_host(req), host_allowed(patterns, host): Django-style host allowlists against DNS rebinding and host header injection.https_redirect(next), forwarded_proto(req): 301/308 to HTTPS when the proxy says the client used HTTP.Ip, parse_ip(s), ip_text(ip), strip_brackets(s), unmap(ip): IPv4 without ambiguous leading zeros, IPv6 with :: and embedded IPv4, printed as RFC 5952 says.Cidr, parse_cidr(s), cidr_contains(c, ip), private_networks(): networks, with IPv4-mapped addresses matching IPv4 networks.forwarded_for(req), client_ip(req, trusted): the client address from Forwarded (RFC 7239) or X-Forwarded-For, walking from the newest hop past your proxies.IpRules, ip_allowed(rules, ip), ip_filter(rules, next): allow and deny lists.FetchPolicy, fetch_allowed(p, req), fetch_metadata(p, next): a Fetch Metadata resource isolation policy against CSRF, XSSI and cross-site leaks.OriginGuard, origin_ok(g, req), origin_guard(g, next), origin_of(url): OWASP's Origin and Referer check for unsafe methods.client_ip, ip_filter, forwarded_proto and https_redirect are only meaningful when every connection comes through your proxies: listen on 127.0.0.1 or a private network, and list the proxies in trusted.secure sends HSTS on every response; browsers only honour it over HTTPS, which behind a TLS-terminating proxy is the browser's connection.deadline can't stop a handler that is still running; it tells the handler its budget and replaces late answers only for GET, HEAD and OPTIONS, since other methods may have changed something.with_nonce replaces any x-csp-nonce the client sent, so a page can't be tricked into a nonce an attacker knows.Response.body is UTF-8 text, so a wrapper can't produce gzip or zstd bytes. Set gzip := true on the Server (text responses of 1 KB or more); negotiate_encoding is here for handlers that choose representations.recover wrapper for raised errors (a checker limitation; use internal_error in a catch).sessions package. Rate limiting, caching and health endpoints are separate packages (ratelimit, cache, health); routing beyond svc is in router.compose and only_when take layers that keep the effect row; wrappers that add effects are nested directly.tests/it/main.ssp as a real server on a free port in both tiers; they need sspur on the machine (the SSPUR variable sspur test sets).MIT, see LICENSE.
fn header_in(hs: List[(Str, Str)], name: Str) -> Opt[Str]The first value of the header name in hs, matched case-insensitively.
fn header_values(hs: List[(Str, Str)], name: Str) -> List[Str]Every value of the header name in hs, in order, matched case-insensitively.
fn valid_header_name(s: Str) -> BoolWhether s is a field name (an RFC 9110 token: letters, digits and !#$%&'*+-.^_|~`).
fn valid_header_value(s: Str) -> BoolWhether s can be sent as a field value: no CR, LF or NUL, so it can't split the response.
fn set_header(r: Response, name: Str, value: Str) -> Responser with the header name set to value, replacing every earlier value of it.
fn add_header(r: Response, name: Str, value: Str) -> Responser with one more name: value header, keeping the values it has (for set-cookie and link).
fn remove_header(r: Response, name: Str) -> Responser without any header called name.
fn default_header(r: Response, name: Str, value: Str) -> Responser with name: value unless it already has a header called name.
fn set_request_header(req: Request, name: Str, value: Str) -> Requestreq with the header name set to value, replacing every earlier value of it.
fn remove_request_header(req: Request, name: Str) -> Requestreq without any header called name.
fn split_list(s: Str) -> List[Str]The elements of a comma-separated field value, trimmed, without empty ones (RFC 9110 5.6.1).
fn add_vary(r: Response, field: Str) -> Responser with field in its Vary header, merged with what is there; Vary: * is left alone.
fn reason_phrase(status: Int) -> StrThe reason phrase of a status code from RFC 9110 and its companions, or "" for an unknown one.
fn problem(status: Int where _ >= 400 and _ <= 599, detail: Str) -> ResponseAn RFC 9457 problem details response (application/problem+json) with type about:blank, the status's reason phrase as its title and detail (left out when empty).
fn problem_with(status: Int where _ >= 400 and _ <= 599, detail: Str, members: List[(Str, Str)]) -> ResponseA problem details response like problem, with extension members: each pair is a member name and its value, already encoded as JSON (json.encode(x)).
fn redirect(status: Int where _ == 301 or _ == 302 or _ == 303 or _ == 307 or _ == 308, location: Str) -> ResponseA redirect to location with status (301, 302, 303, 307 or 308) and an empty body.
fn compose[e](layers: List[(Request -> Response ! e) -> Request -> Response ! e], h: Request -> Response ! e) -> Request -> Response ! eThe handler h inside layers, the first layer outermost: compose([a, b], h) is a(b(h)). Every layer must keep the effect row as it is (cors, secure, limits, guards); nest wrappers that add log, time or rand directly around the result, request_ids(p, compose([...], h)).
fn only_when[e](pred: Request -> Bool, layer: (Request -> Response ! e) -> Request -> Response ! e, next: Request -> Response ! e) -> Request -> Response ! elayer around next for requests that satisfy pred; others go straight to next. The layer keeps the effect row, as for compose.
fn under(prefix: Str, req: Request) -> BoolWhether the path of req is prefix or below it (/api covers /api and /api/x, not /apix).
fn before[e](f: Request -> Request, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that changes the request before next sees it.
fn after[e](f: (Request, Response) -> Response, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that changes the response next gives, seeing the request too.
fn with_headers[e](hs: List[(Str, Str)], next: Request -> Response ! e) -> Request -> Response ! eA wrapper that sets each of hs on every response (for example a fixed server header), replacing the handler's values.
fn strip_headers[e](names: List[Str], next: Request -> Response ! e) -> Request -> Response ! eA wrapper that removes the headers called names from every response (x-powered-by, server).
fn no_store[e](next: Request -> Response ! e) -> Request -> Response ! eA wrapper that marks responses cache-control: no-store unless the handler set cache-control.
fn percent_decode(s: Str, plus: Bool) -> Opt[Str]s with %XX escapes decoded, and + as a space when plus is true (form encoding). none for a truncated or non-hex escape, or bytes that are not UTF-8.
fn percent_encode(s: Str) -> Strs percent-encoded for a URL component: every byte except the RFC 3986 unreserved characters (letters, digits, -, ., _, ~) becomes %XX.
fn query_string(url: Str) -> StrThe query string of a request target, without the ?, or "".
fn query_pairs(url: Str) -> List[(Str, Str)]The decoded name=value pairs of a request target's query string, in order. A pair without = has the value ""; a pair that does not decode is kept as it was sent.
fn redact_query(url: Str, names: List[Str]) -> StrThe request target url with the values of the query parameters called names (compared case-insensitively) replaced by REDACTED, for logs.
pub type Weighted = {value: Str, params: List[(Str, Str)], q: F64} derive Eq, ShowOne element of an Accept-style field: its value, its parameters (lowercased names) and its weight from q, 1.0 when absent.
fn parse_weighted(header: Str) -> List[Weighted]The elements of an Accept, Accept-Encoding, Accept-Language or Accept-Charset value in the order sent. Elements with a malformed or repeated q are dropped (RFC 9110 12.4.2).
fn negotiate_media(header: Str, offers: List[Str]) -> Opt[Str]The offer (a media type, possibly with parameters) that best fits the Accept value header: the most specific matching range gives each offer its weight, the highest weight wins and ties go to the earlier offer. An empty header accepts the first offer; none means 406.
fn negotiate_encoding(header: Str, offers: List[Str]) -> Opt[Str]The content coding to use for the Accept-Encoding value header among offers (RFC 9110 12.5.3): x-gzip is gzip, * covers unlisted codings, and identity is acceptable unless excluded. A missing header (pass "") prefers identity when offered. none means nothing offered is acceptable.
fn negotiate_language(header: Str, offers: List[Str]) -> Opt[Str]The language tag among offers that best fits the Accept-Language value header, by RFC 4647 basic filtering: en matches en and en-GB, * matches anything, the longest matching range gives the weight, and ties go to the earlier offer. An empty header picks the first offer.
fn media_type(content_type: Str) -> Opt[Str]The media type of a Content-Type value, lowercased, without parameters: type/subtype, or none when it has no /.
fn media_matches(pattern: Str, mt: Str) -> BoolWhether the media type mt matches pattern: an exact type, type/*, */*, or a structured suffix such as application/*+json.
pub type Cors = {origins: List[Str] where _.all(valid_origin_pattern), any_origin: Bool, methods: List[Str] where _.all(valid_header_name), allow_headers: List[Str] where _.all(valid_header_name), any_header: Bool, expose_headers: List[Str] where _.all(valid_header_name), credentials: Bool, max_age_s: Int where _ >= -1 and _ <= 86400, private_network: Bool} derive Eq, ShowA CORS policy (the Fetch standard's CORS protocol). origins are exact origins (https://app.example.com, http://localhost:3000), wildcard subdomains (https://*.example.com, which does not match example.com itself) or null, which must be listed to be allowed. any_origin allows every origin and can't be combined with credentials. methods and allow_headers are what preflights may ask for (any_header allows any header), expose_headers what scripts may read, max_age_s how long a browser may cache a preflight (-1 sends no Access-Control-Max-Age), and private_network answers Private Network Access preflights.
fn cors_for(origins: List[Str]) -> CorsA policy for origins without credentials: the methods GET, HEAD, POST, PUT, PATCH and DELETE, the request headers content-type, authorization and x-requested-with, nothing exposed, and preflights cached for 10 minutes.
fn cors_public() -> CorsA policy that lets any origin read responses without credentials, for public APIs.
fn valid_origin_pattern(s: Str) -> BoolWhether s is a valid entry of Cors.origins: null, or scheme://host[:port] where the host may start with *., with no path, query, fragment or user info.
fn origin_matches(pattern: Str, origin: Str) -> BoolWhether the Origin value origin matches the pattern pattern (see Cors). Schemes and hosts compare case-insensitively and default ports are the same as none.
fn cors_allows(p: Cors, origin: Str) -> BoolWhether p lets the origin origin read responses.
fn is_preflight(req: Request) -> BoolWhether req is a CORS preflight: OPTIONS with Origin and Access-Control-Request-Method.
fn cors_preflight(p: Cors, req: Request) -> ResponseThe answer to the preflight req under p: 204 with the Access-Control-Allow-* headers when the origin, the method and every requested header are allowed, otherwise 403 without them, so the browser blocks the request. Either way it varies on the request's CORS headers.
fn cors_headers(p: Cors, req: Request) -> List[(Str, Str)]The CORS headers for the response to the non-preflight request req under p: the allowed origin (or * for a public policy), credentials and exposed headers when the origin is allowed, and Vary: Origin whenever the answer depends on it.
fn cors[e](p: Cors, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that answers preflights itself and adds CORS headers to the other responses of next. Use it instead of the Server.cors field when you need credentials, wildcard subdomains, exposed headers or per-route policies.
pub type Hsts = {max_age_s: Int where _ >= 0, include_subdomains: Bool, preload: Bool} derive Eq, ShowStrict-Transport-Security (RFC 6797). Preloading needs a max age of at least a year and include_subdomains.
fn hsts_value(h: Hsts) -> StrThe Strict-Transport-Security value for h.
pub type Csp = {directives: List[(Str, List[Str])], report_only: Bool} derive Eq, ShowA Content Security Policy (CSP Level 3): directives in order, each with its sources, sent as Content-Security-Policy, or Content-Security-Policy-Report-Only when report_only.
fn valid_directive(s: Str) -> BoolWhether s is a CSP directive name: lowercase letters and -.
fn valid_source(s: Str) -> BoolWhether s can be a CSP source expression: printable, without whitespace, ; or ,, which would end the directive or the policy.
fn csp_empty() -> CspAn empty policy.
fn csp_strict() -> CspA strict starting policy: default-src 'self', base-uri 'none', object-src 'none', frame-ancestors 'none', form-action 'self'.
fn csp_helmet() -> CspThe policy helmet sends by default: default-src 'self', base-uri 'self', font-src 'self' https: data:, form-action 'self', frame-ancestors 'self', img-src 'self' data:, object-src 'none', script-src 'self', script-src-attr 'none', style-src 'self' https: 'unsafe-inline', upgrade-insecure-requests.
fn csp_set(c: Csp, name: Str, sources: List[Str]) -> Cspc with the directive name set to sources, replacing it if present (a directive with no sources, such as upgrade-insecure-requests, is written as its name alone).
fn csp_add(c: Csp, name: Str, sources: List[Str]) -> Cspc with sources added to the directive name (created when missing), without duplicates. A directive that only had 'none' loses it.
fn csp_remove(c: Csp, name: Str) -> Cspc without the directive name.
fn csp_report_only(c: Csp, on: Bool) -> Cspc sent in report-only mode (or enforced when on is false).
fn csp_value(c: Csp) -> StrThe policy text of c: directives separated by ; .
fn csp_header(c: Csp) -> (Str, Str)The header for c: its name (enforcing or report-only) and value.
fn src_self() -> StrThe source 'self'.
fn src_none() -> StrThe source 'none'.
fn src_unsafe_inline() -> StrThe source 'unsafe-inline'.
fn src_unsafe_eval() -> StrThe source 'unsafe-eval'.
fn src_wasm_unsafe_eval() -> StrThe source 'wasm-unsafe-eval'.
fn src_strict_dynamic() -> StrThe source 'strict-dynamic'.
fn src_report_sample() -> StrThe source 'report-sample'.
fn src_nonce(nonce: Str where _.len > 0 and _.chars.all(c => c.is_alpha or "0123456789+/-_=".contains(c))) -> StrThe source 'nonce-<nonce>'; nonce must be base64 or base64url text.
fn src_sha256(content: Str) -> StrThe source 'sha256-<base64>' that allows the inline script or style content exactly.
fn src_sha512(content: Str) -> StrThe source 'sha512-<base64>' that allows the inline script or style content exactly.
fn permissions_policy(features: List[(Str, List[Str])]) -> StrA Permissions-Policy value (a structured field dictionary): each feature with its allowlist, where self and src are keywords, * allows every origin and other entries are origins. An empty allowlist disables the feature.
pub type Security = {hsts: Opt[Hsts], csp: Opt[Csp], frame: Str where _.frame_ok, referrer: Str where _.referrer_ok, nosniff: Bool, coop: Str where _.coop_ok, coep: Str where _.coep_ok, corp: Str where _.corp_ok, permissions: Str where valid_header_value(_), dns_prefetch: Str where _.onoff_ok, cross_domain: Str where _.xdomain_ok, origin_agent_cluster: Bool, xss_off: Bool, overwrite: Bool} derive Eq, ShowThe security headers to send, after OWASP's HTTP Headers Cheat Sheet and helmet. A field that is none or "" sends nothing: frame is X-Frame-Options (DENY or SAMEORIGIN), referrer is Referrer-Policy, coop, coep and corp are the Cross-Origin-*-Policy headers, permissions is a permissions_policy value, dns_prefetch is X-DNS-Prefetch-Control, cross_domain is X-Permitted-Cross-Domain-Policies. nosniff sends X-Content-Type-Options: nosniff, origin_agent_cluster sends Origin-Agent-Cluster: ?1, and xss_off sends X-XSS-Protection: 0, which turns off the legacy filter that caused leaks. With overwrite false, headers the handler set are kept.
fn security_defaults() -> Securityhelmet's defaults: csp_helmet, HSTS for a year with subdomains, SAMEORIGIN framing, no-referrer, nosniff, COOP and CORP same-origin, no COEP, DNS prefetching off, no cross-domain policies, Origin-Agent-Cluster, the XSS filter off, and handler headers kept.
fn security_api() -> SecurityDefaults for a JSON API that no browser should render or frame: csp_strict with default-src 'none', DENY, no-referrer, nosniff, CORP same-origin, HSTS for a year.
fn security_headers(s: Security) -> List[(Str, Str)]The headers s sends, in a fixed order.
fn apply_security(s: Security, r: Response) -> Responser with the headers of s: replacing the handler's when s.overwrite, otherwise only where the handler set none.
fn secure[e](s: Security, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that adds the security headers of s to every response of next. HSTS only takes effect over HTTPS; behind a TLS-terminating proxy that is the browser's connection, so it is sent regardless.
fn nonce_header() -> StrThe request header that carries the per-request CSP nonce to the handler.
fn request_nonce(req: Request) -> Opt[Str]The CSP nonce with_nonce gave this request, for <script nonce="..."> in the page.
fn with_nonce[e](c: Csp, next: Request -> Response ! e) -> Request -> Response ! e, randA wrapper that makes a fresh 128-bit nonce for each request, passes it to next in the x-csp-nonce request header (any such header from the client is replaced), and sends c with 'nonce-...' added to script-src and style-src.
pub type RequestIds = {header: Str where valid_header_name(_), trust_incoming: Bool, max_len: Int where _ >= 8 and _ <= 200} derive Eq, ShowHow request ids are handled: the header that carries them in both directions, whether an id the client sent is kept, and the longest one accepted.
fn request_ids_default() -> RequestIdsx-request-id, client ids kept when valid, up to 128 characters.
fn valid_request_id(id: Str, max_len: Int) -> BoolWhether id is an acceptable request id: 1 to max_len characters from letters, digits and -_.:, so it is safe to log and to echo.
fn request_id(req: Request) -> StrThe request id of req under http.serve, which always sets x-request-id, or "".
fn request_ids[e](p: RequestIds, next: Request -> Response ! e) -> Request -> Response ! e, rand, timeA wrapper that gives each request an id in p.header: the client's when trusted and valid, otherwise a fresh UUIDv7, and echoes it on the response.
pub type AccessEntry = {method: Str, target: Str, status: Int, ms: Int, size: Int, id: Str, agent: Str, referer: Str} derive Eq, Show, JsonOne access log line: method, path with the query (secrets redacted), status, milliseconds, response body bytes, request id, user agent and referrer.
pub type AccessLog = {skip: List[Str], redact: List[Str], slow_ms: Int where _ >= 0} derive Eq, ShowWhat the access log writes: paths not to log (/healthz, matched like under), query parameters to redact, and the time from which a line is a warning (0 never). 5xx answers are errors and 4xx answers are warnings.
fn access_log_default() -> AccessLogNo skipped paths; token, code, password, secret, key and signature redacted; slow from 1 second.
fn access_entry(p: AccessLog, req: Request, r: Response, ms: Int) -> AccessEntryThe entry for req answered with r in ms milliseconds.
fn access_level(p: AccessLog, a: AccessEntry) -> StrThe level an entry is logged at: error for 5xx, warn for 4xx or slow answers, else info.
fn access_log[e](p: AccessLog, next: Request -> Response ! e) -> Request -> Response ! e, log, timeA wrapper that times next on the monotonic clock and logs one JSON line per request through log.info, log.warn or log.error, so SSPUR_LOG=json gives its fields as the line's fields. Turn off the server's own access_log to avoid two lines.
fn server_timing[e](next: Request -> Response ! e) -> Request -> Response ! e, timeA wrapper that reports the handler's time on the monotonic clock in Server-Timing (app;dur= milliseconds with one decimal), which browsers show in their developer tools. Only use it where timings may be public.
fn content_length(req: Request) -> Opt[Int]The Content-Length of req: some(n) when present and a valid decimal number, some(-1) when present and invalid, none when absent.
fn body_problem(req: Request, max: Int where _ >= 0) -> Opt[Response]The error response for a body over max bytes (413), or a malformed Content-Length (400), or none when the body is acceptable.
fn body_limit[e](max: Int where _ >= 0, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that answers 413 for bodies over max bytes, for routes that need less than the server's max_body.
fn media_problem(req: Request, types: List[Str]) -> Opt[Response]The 415 response for a POST, PUT or PATCH request with a body whose media type matches none of types (patterns as in media_matches), with Accept-Post or Accept-Patch naming them; none when the request is acceptable.
fn require_media[e](types: List[Str], next: Request -> Response ! e) -> Request -> Response ! eA wrapper that answers 415 unless request bodies are of one of types.
pub type Deadline = {ms: Int where _ > 0, header: Str where valid_header_name(_), replace_late: Bool} derive Eq, ShowA time budget per request: ms milliseconds, announced to the handler in the request header header as the absolute deadline in Unix milliseconds. With replace_late, a response that comes after the deadline is replaced by 503 (only for GET, HEAD and OPTIONS, since other methods may have changed something).
fn deadline_of(ms: Int where _ > 0) -> Deadlinems milliseconds in x-request-deadline, late safe responses replaced.
fn remaining_ms(req: Request, header: Str, now_ms: Int) -> Opt[Int]The milliseconds left at now_ms before the deadline in header, at least 0, or none when the request has no deadline. Pass it on as the timeout_ms of outgoing calls.
fn deadline[e](d: Deadline, next: Request -> Response ! e) -> Request -> Response ! e, timeA wrapper that sets the deadline header (replacing any the client sent) and, with replace_late, turns late answers to safe requests into 503. The handler is not stopped: it should read remaining_ms and give up itself.
fn overridden_method(req: Request) -> Opt[Str]The method a POST asks to be treated as, from X-HTTP-Method-Override, X-HTTP-Method or X-Method-Override, or a form field _method; only PUT, PATCH and DELETE are honoured, so an override can't turn a form into a safe or tracing request.
fn method_override[e](next: Request -> Response ! e) -> Request -> Response ! eA wrapper that applies overridden_method, keeping the original in x-original-method.
fn internal_error[E](req: Request, err: E) -> Response ! logThe answer to a request whose handler raised err: a 500 problem that carries only the request id, with err logged under that id, so internals never reach the client. Use it in the handler lambda: req => catch api(req) | e => middleware.internal_error(req, e). (Traps, such as overflow or a failed contract, can't be caught; the server answers those with 500 itself.)
pub type Ip = {v6: Bool, octets: List[Int]} derive Eq, ShowAn IP address: 4 bytes for IPv4, 16 for IPv6.
pub type Cidr = {net: Ip, prefix: Int} derive Eq, ShowA network: an address and a prefix length (0 to 32, or 0 to 128).
fn parse_ip(s: Str) -> Opt[Ip]The address s: dotted IPv4 (no leading zeros, which some parsers read as octal), or IPv6 with optional :: compression and a trailing dotted quad. Zones (%eth0) and brackets are not accepted; strip_brackets first.
fn strip_brackets(s: Str) -> Strs without surrounding [ and ].
fn unmap(ip: Ip) -> IpThe address in IPv4 form when ip is an IPv4-mapped IPv6 address (::ffff:a.b.c.d), else ip.
fn ip_text(ip: Ip) -> StrThe text of ip: dotted IPv4, or IPv6 as RFC 5952 recommends (lowercase hex, no leading zeros, the longest run of two or more zero groups as ::, IPv4-mapped addresses as ::ffff:a.b.c.d).
fn parse_cidr(s: Str) -> Opt[Cidr]The network s: address/prefix, or a bare address as a single host. Host bits below the prefix are cleared.
fn cidr_contains(c: Cidr, ip: Ip) -> BoolWhether ip is inside c. IPv4-mapped IPv6 addresses match IPv4 networks.
fn private_networks() -> List[Cidr]The usual networks of proxies and load balancers in front of a service: loopback and the private ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7).
fn forwarded_for(req: Request) -> List[Opt[Ip]]The client addresses a request passed through, oldest first: the for= parameters of Forwarded (RFC 7239) when present, else X-Forwarded-For. Entries that are not addresses (unknown, obfuscated names) are none.
fn client_ip(req: Request, trusted: List[Cidr]) -> Opt[Ip]The client's address: walking the forwarding chain from the newest hop, the first address not in trusted (your proxies). An entry that is not an address stops the walk with none, since nothing before it can be trusted; a chain of only trusted proxies gives its oldest entry. The server only sees headers, so this is only meaningful when every connection comes through those proxies (listen on 127.0.0.1 or a private network).
pub type IpRules = {allow: List[Cidr], deny: List[Cidr], trusted: List[Cidr], allow_unknown: Bool} derive Eq, ShowWho may call: addresses in deny are refused, then if allow is not empty only addresses in it pass. trusted are the proxies whose forwarding headers are believed (see client_ip), and allow_unknown says whether a request without a usable address passes.
fn ip_allowed(rules: IpRules, ip: Opt[Ip]) -> BoolWhether rules let ip (or an unknown address, none) through.
fn ip_filter[e](rules: IpRules, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that answers 403 to callers rules don't allow.
fn basic_credentials(req: Request) -> Opt[(Str, Str)]The user id and password of an Authorization: Basic header (RFC 7617): base64 of UTF-8 user:password, split at the first colon. none when absent or malformed.
fn basic_challenge(realm: Str) -> (Str, Str)The WWW-Authenticate header that asks for Basic credentials in realm, in UTF-8.
fn secret_equal(a: Str, b: Str) -> BoolWhether the secrets a and b are equal, in time that depends on neither their contents nor their lengths (both are hashed first, then compared with crypto.equal).
fn basic_auth[e](realm: Str, check: (Str, Str) -> Bool ! e, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that lets through requests whose Basic credentials check accepts and answers the others with 401 and a challenge for realm. Compare passwords with secret_equal, or better against a stored hash; Basic credentials are only safe over HTTPS.
fn request_host(req: Request) -> Opt[Str]The host req was sent to, from Host, lowercased, without the port and a trailing dot (IPv6 keeps its brackets); none when missing or not a valid host.
fn host_allowed(patterns: List[Str], host: Str) -> BoolWhether host matches one of patterns: * matches any host, .example.com matches example.com and its subdomains, anything else must be equal (case-insensitively).
fn allowed_hosts[e](patterns: List[Str], next: Request -> Response ! e) -> Request -> Response ! eA wrapper that answers 400 when the Host header is missing or not one of patterns, which protects against DNS rebinding and host header injection into links and redirects.
fn forwarded_proto(req: Request) -> Opt[Str]The scheme the client used according to the proxy in front: proto= of the first Forwarded element, else the first X-Forwarded-Proto value, lowercased. Only believe it behind a proxy that sets it.
fn https_redirect[e](next: Request -> Response ! e) -> Request -> Response ! eA wrapper that redirects requests the proxy received over plain HTTP to HTTPS on the same host and target: 301 for GET and HEAD, 308 for other methods so they are repeated as sent. Requests without forwarding information are served as they are. Combine it with allowed_hosts, since the redirect uses the Host header.
pub type FetchPolicy = {allow_same_site: Bool, allow_paths: List[Str]} derive Eq, ShowA Fetch Metadata resource isolation policy: same-origin requests, requests the user started (none) and plain navigations pass; allow_same_site lets sibling subdomains through too, and paths under allow_paths are open to everyone (for example public assets or OAuth callbacks).
fn fetch_allowed(p: FetchPolicy, req: Request) -> BoolWhether p lets req through. Browsers that send no Sec-Fetch-Site are allowed.
fn fetch_metadata[e](p: FetchPolicy, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that answers 403 to requests p refuses, a defence against CSRF, XSSI and cross-site leaks in browsers that send Fetch Metadata.
fn origin_of(url: Str) -> Opt[Str]The origin of a URL (scheme://host[:port]), or none.
pub type OriginGuard = {origins: List[Str] where _.all(valid_origin_pattern), allow_missing: Bool} derive Eq, ShowOWASP's CSRF check with standard headers: requests with unsafe methods must come from one of origins according to Origin, or Referer when Origin is missing. allow_missing lets through requests that carry neither (some privacy tools strip both).
fn origin_ok(g: OriginGuard, req: Request) -> BoolWhether g lets req through: GET, HEAD and OPTIONS always pass; Origin: null never does.
fn origin_guard[e](g: OriginGuard, next: Request -> Response ! e) -> Request -> Response ! eA wrapper that answers 403 to requests origin_ok refuses.
| Version | Published | Commit | Source | Effects |
|---|---|---|---|---|
| 0.1.0 | 2026-10-16 | 6aa6917a31 | snapshot |