Package

middleware

Composable HTTP handler wrappers: CORS, security headers and CSP, request ids, access logs, limits, deadlines, negotiation, auth and host checks

0.1.0 MIT

Install

sspur add middleware@^0.1.0

or in sspur.toml:

middleware = { version = "^0.1.0" }

README

middleware

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.

Install

$ sspur add middleware

Example

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")

Building the stack

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.

API

Headers

  • 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.

Responses

  • 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.

Composing

  • 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.

URLs

  • 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.

Content negotiation (RFC 9110 section 12)

  • 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 (Fetch standard)

  • 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 headers (OWASP HTTP Headers Cheat Sheet, helmet)

  • 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.

Request ids, logs and timing

  • 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=....

Limits and deadlines

  • 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.

Methods, authentication, hosts and schemes

  • 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.

Addresses

  • 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.

Cross-site requests

  • 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.

Security notes

  • The server only sees headers, not the connecting address, so 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.

Out of scope

  • Compressing responses: 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.
  • Catching traps, and a generic recover wrapper for raised errors (a checker limitation; use internal_error in a catch).
  • Sessions, CSRF tokens and bearer tokens: see the sessions package. Rate limiting, caching and health endpoints are separate packages (ratelimit, cache, health); routing beyond svc is in router.

Known limitations

  • compose and only_when take layers that keep the effect row; wrappers that add effects are nested directly.
  • The tests start 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).

License

MIT, see LICENSE.

API

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.

header_values fn

fn header_values(hs: List[(Str, Str)], name: Str) -> List[Str]

Every value of the header name in hs, in order, matched case-insensitively.

valid_header_name fn

fn valid_header_name(s: Str) -> Bool

Whether s is a field name (an RFC 9110 token: letters, digits and !#$%&'*+-.^_|~`).

valid_header_value fn

fn valid_header_value(s: Str) -> Bool

Whether s can be sent as a field value: no CR, LF or NUL, so it can't split the response.

set_header fn

fn set_header(r: Response, name: Str, value: Str) -> Response

r with the header name set to value, replacing every earlier value of it.

add_header fn

fn add_header(r: Response, name: Str, value: Str) -> Response

r with one more name: value header, keeping the values it has (for set-cookie and link).

remove_header fn

fn remove_header(r: Response, name: Str) -> Response

r without any header called name.

default_header fn

fn default_header(r: Response, name: Str, value: Str) -> Response

r with name: value unless it already has a header called name.

set_request_header fn

fn set_request_header(req: Request, name: Str, value: Str) -> Request

req with the header name set to value, replacing every earlier value of it.

remove_request_header fn

fn remove_request_header(req: Request, name: Str) -> Request

req without any header called name.

split_list fn

fn split_list(s: Str) -> List[Str]

The elements of a comma-separated field value, trimmed, without empty ones (RFC 9110 5.6.1).

add_vary fn

fn add_vary(r: Response, field: Str) -> Response

r with field in its Vary header, merged with what is there; Vary: * is left alone.

reason_phrase fn

fn reason_phrase(status: Int) -> Str

The reason phrase of a status code from RFC 9110 and its companions, or "" for an unknown one.

problem fn

fn problem(status: Int where _ >= 400 and _ <= 599, detail: Str) -> Response

An 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).

problem_with fn

fn problem_with(status: Int where _ >= 400 and _ <= 599, detail: Str, members: List[(Str, Str)]) -> Response

A problem details response like problem, with extension members: each pair is a member name and its value, already encoded as JSON (json.encode(x)).

redirect fn

fn redirect(status: Int where _ == 301 or _ == 302 or _ == 303 or _ == 307 or _ == 308, location: Str) -> Response

A redirect to location with status (301, 302, 303, 307 or 308) and an empty body.

compose fn

fn compose[e](layers: List[(Request -> Response ! e) -> Request -> Response ! e], h: Request -> Response ! e) -> Request -> Response ! e

The 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)).

only_when fn

fn only_when[e](pred: Request -> Bool, layer: (Request -> Response ! e) -> Request -> Response ! e, next: Request -> Response ! e) -> Request -> Response ! e

layer around next for requests that satisfy pred; others go straight to next. The layer keeps the effect row, as for compose.

under fn

fn under(prefix: Str, req: Request) -> Bool

Whether the path of req is prefix or below it (/api covers /api and /api/x, not /apix).

before fn

fn before[e](f: Request -> Request, next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that changes the request before next sees it.

after fn

fn after[e](f: (Request, Response) -> Response, next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that changes the response next gives, seeing the request too.

with_headers fn

fn with_headers[e](hs: List[(Str, Str)], next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that sets each of hs on every response (for example a fixed server header), replacing the handler's values.

strip_headers fn

fn strip_headers[e](names: List[Str], next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that removes the headers called names from every response (x-powered-by, server).

no_store fn

fn no_store[e](next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that marks responses cache-control: no-store unless the handler set cache-control.

percent_decode fn

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.

percent_encode fn

fn percent_encode(s: Str) -> Str

s percent-encoded for a URL component: every byte except the RFC 3986 unreserved characters (letters, digits, -, ., _, ~) becomes %XX.

query_string fn

fn query_string(url: Str) -> Str

The query string of a request target, without the ?, or "".

query_pairs fn

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.

redact_query fn

fn redact_query(url: Str, names: List[Str]) -> Str

The request target url with the values of the query parameters called names (compared case-insensitively) replaced by REDACTED, for logs.

Weighted type

pub type Weighted = {value: Str, params: List[(Str, Str)], q: F64} derive Eq, Show

One element of an Accept-style field: its value, its parameters (lowercased names) and its weight from q, 1.0 when absent.

parse_weighted fn

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).

negotiate_media fn

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.

negotiate_encoding fn

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.

negotiate_language fn

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.

media_type fn

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 /.

media_matches fn

fn media_matches(pattern: Str, mt: Str) -> Bool

Whether the media type mt matches pattern: an exact type, type/*, */*, or a structured suffix such as application/*+json.

Cors type

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, Show

A 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.

cors_for fn

fn cors_for(origins: List[Str]) -> Cors

A 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.

cors_public fn

fn cors_public() -> Cors

A policy that lets any origin read responses without credentials, for public APIs.

valid_origin_pattern fn

fn valid_origin_pattern(s: Str) -> Bool

Whether 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.

origin_matches fn

fn origin_matches(pattern: Str, origin: Str) -> Bool

Whether the Origin value origin matches the pattern pattern (see Cors). Schemes and hosts compare case-insensitively and default ports are the same as none.

cors_allows fn

fn cors_allows(p: Cors, origin: Str) -> Bool

Whether p lets the origin origin read responses.

is_preflight fn

fn is_preflight(req: Request) -> Bool

Whether req is a CORS preflight: OPTIONS with Origin and Access-Control-Request-Method.

cors_preflight fn

fn cors_preflight(p: Cors, req: Request) -> Response

The 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.

cors_headers fn

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.

cors fn

fn cors[e](p: Cors, next: Request -> Response ! e) -> Request -> Response ! e

A 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.

Hsts type

pub type Hsts = {max_age_s: Int where _ >= 0, include_subdomains: Bool, preload: Bool} derive Eq, Show

Strict-Transport-Security (RFC 6797). Preloading needs a max age of at least a year and include_subdomains.

hsts_value fn

fn hsts_value(h: Hsts) -> Str

The Strict-Transport-Security value for h.

Csp type

pub type Csp = {directives: List[(Str, List[Str])], report_only: Bool} derive Eq, Show

A 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.

valid_directive fn

fn valid_directive(s: Str) -> Bool

Whether s is a CSP directive name: lowercase letters and -.

valid_source fn

fn valid_source(s: Str) -> Bool

Whether s can be a CSP source expression: printable, without whitespace, ; or ,, which would end the directive or the policy.

csp_empty fn

fn csp_empty() -> Csp

An empty policy.

csp_strict fn

fn csp_strict() -> Csp

A strict starting policy: default-src 'self', base-uri 'none', object-src 'none', frame-ancestors 'none', form-action 'self'.

csp_helmet fn

fn csp_helmet() -> Csp

The 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.

csp_set fn

fn csp_set(c: Csp, name: Str, sources: List[Str]) -> Csp

c 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).

csp_add fn

fn csp_add(c: Csp, name: Str, sources: List[Str]) -> Csp

c with sources added to the directive name (created when missing), without duplicates. A directive that only had 'none' loses it.

csp_remove fn

fn csp_remove(c: Csp, name: Str) -> Csp

c without the directive name.

csp_report_only fn

fn csp_report_only(c: Csp, on: Bool) -> Csp

c sent in report-only mode (or enforced when on is false).

csp_value fn

fn csp_value(c: Csp) -> Str

The policy text of c: directives separated by ; .

csp_header fn

fn csp_header(c: Csp) -> (Str, Str)

The header for c: its name (enforcing or report-only) and value.

src_self fn

fn src_self() -> Str

The source 'self'.

src_none fn

fn src_none() -> Str

The source 'none'.

src_nonce fn

fn src_nonce(nonce: Str where _.len > 0 and _.chars.all(c => c.is_alpha or "0123456789+/-_=".contains(c))) -> Str

The source 'nonce-<nonce>'; nonce must be base64 or base64url text.

src_sha256 fn

fn src_sha256(content: Str) -> Str

The source 'sha256-<base64>' that allows the inline script or style content exactly.

src_sha512 fn

fn src_sha512(content: Str) -> Str

The source 'sha512-<base64>' that allows the inline script or style content exactly.

permissions_policy fn

fn permissions_policy(features: List[(Str, List[Str])]) -> Str

A 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.

Security type

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, Show

The 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.

security_defaults fn

fn security_defaults() -> Security

helmet'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.

security_api fn

fn security_api() -> Security

Defaults 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.

security_headers fn

fn security_headers(s: Security) -> List[(Str, Str)]

The headers s sends, in a fixed order.

apply_security fn

fn apply_security(s: Security, r: Response) -> Response

r with the headers of s: replacing the handler's when s.overwrite, otherwise only where the handler set none.

secure fn

fn secure[e](s: Security, next: Request -> Response ! e) -> Request -> Response ! e

A 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.

nonce_header fn

fn nonce_header() -> Str

The request header that carries the per-request CSP nonce to the handler.

request_nonce fn

fn request_nonce(req: Request) -> Opt[Str]

The CSP nonce with_nonce gave this request, for <script nonce="..."> in the page.

with_nonce fn

fn with_nonce[e](c: Csp, next: Request -> Response ! e) -> Request -> Response ! e, rand

A 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.

RequestIds type

pub type RequestIds = {header: Str where valid_header_name(_), trust_incoming: Bool, max_len: Int where _ >= 8 and _ <= 200} derive Eq, Show

How 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.

request_ids_default fn

fn request_ids_default() -> RequestIds

x-request-id, client ids kept when valid, up to 128 characters.

valid_request_id fn

fn valid_request_id(id: Str, max_len: Int) -> Bool

Whether id is an acceptable request id: 1 to max_len characters from letters, digits and -_.:, so it is safe to log and to echo.

request_id fn

fn request_id(req: Request) -> Str

The request id of req under http.serve, which always sets x-request-id, or "".

request_ids fn

fn request_ids[e](p: RequestIds, next: Request -> Response ! e) -> Request -> Response ! e, rand, time

A 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.

AccessEntry type

pub type AccessEntry = {method: Str, target: Str, status: Int, ms: Int, size: Int, id: Str, agent: Str, referer: Str} derive Eq, Show, Json

One access log line: method, path with the query (secrets redacted), status, milliseconds, response body bytes, request id, user agent and referrer.

AccessLog type

pub type AccessLog = {skip: List[Str], redact: List[Str], slow_ms: Int where _ >= 0} derive Eq, Show

What 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.

access_log_default fn

fn access_log_default() -> AccessLog

No skipped paths; token, code, password, secret, key and signature redacted; slow from 1 second.

access_entry fn

fn access_entry(p: AccessLog, req: Request, r: Response, ms: Int) -> AccessEntry

The entry for req answered with r in ms milliseconds.

access_level fn

fn access_level(p: AccessLog, a: AccessEntry) -> Str

The level an entry is logged at: error for 5xx, warn for 4xx or slow answers, else info.

access_log fn

fn access_log[e](p: AccessLog, next: Request -> Response ! e) -> Request -> Response ! e, log, time

A 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.

server_timing fn

fn server_timing[e](next: Request -> Response ! e) -> Request -> Response ! e, time

A 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.

content_length fn

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.

body_problem fn

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.

body_limit fn

fn body_limit[e](max: Int where _ >= 0, next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that answers 413 for bodies over max bytes, for routes that need less than the server's max_body.

media_problem fn

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.

require_media fn

fn require_media[e](types: List[Str], next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that answers 415 unless request bodies are of one of types.

Deadline type

pub type Deadline = {ms: Int where _ > 0, header: Str where valid_header_name(_), replace_late: Bool} derive Eq, Show

A 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).

deadline_of fn

fn deadline_of(ms: Int where _ > 0) -> Deadline

ms milliseconds in x-request-deadline, late safe responses replaced.

remaining_ms fn

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.

deadline fn

fn deadline[e](d: Deadline, next: Request -> Response ! e) -> Request -> Response ! e, time

A 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.

overridden_method fn

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.

method_override fn

fn method_override[e](next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that applies overridden_method, keeping the original in x-original-method.

internal_error fn

fn internal_error[E](req: Request, err: E) -> Response ! log

The 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.)

Ip type

pub type Ip = {v6: Bool, octets: List[Int]} derive Eq, Show

An IP address: 4 bytes for IPv4, 16 for IPv6.

Cidr type

pub type Cidr = {net: Ip, prefix: Int} derive Eq, Show

A network: an address and a prefix length (0 to 32, or 0 to 128).

parse_ip fn

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.

strip_brackets fn

fn strip_brackets(s: Str) -> Str

s without surrounding [ and ].

unmap fn

fn unmap(ip: Ip) -> Ip

The address in IPv4 form when ip is an IPv4-mapped IPv6 address (::ffff:a.b.c.d), else ip.

ip_text fn

fn ip_text(ip: Ip) -> Str

The 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).

parse_cidr fn

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.

cidr_contains fn

fn cidr_contains(c: Cidr, ip: Ip) -> Bool

Whether ip is inside c. IPv4-mapped IPv6 addresses match IPv4 networks.

private_networks fn

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).

forwarded_for fn

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.

client_ip fn

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).

IpRules type

pub type IpRules = {allow: List[Cidr], deny: List[Cidr], trusted: List[Cidr], allow_unknown: Bool} derive Eq, Show

Who 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.

ip_allowed fn

fn ip_allowed(rules: IpRules, ip: Opt[Ip]) -> Bool

Whether rules let ip (or an unknown address, none) through.

ip_filter fn

fn ip_filter[e](rules: IpRules, next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that answers 403 to callers rules don't allow.

basic_credentials fn

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.

basic_challenge fn

fn basic_challenge(realm: Str) -> (Str, Str)

The WWW-Authenticate header that asks for Basic credentials in realm, in UTF-8.

secret_equal fn

fn secret_equal(a: Str, b: Str) -> Bool

Whether 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).

basic_auth fn

fn basic_auth[e](realm: Str, check: (Str, Str) -> Bool ! e, next: Request -> Response ! e) -> Request -> Response ! e

A 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.

request_host fn

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.

host_allowed fn

fn host_allowed(patterns: List[Str], host: Str) -> Bool

Whether host matches one of patterns: * matches any host, .example.com matches example.com and its subdomains, anything else must be equal (case-insensitively).

allowed_hosts fn

fn allowed_hosts[e](patterns: List[Str], next: Request -> Response ! e) -> Request -> Response ! e

A 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.

forwarded_proto fn

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.

https_redirect fn

fn https_redirect[e](next: Request -> Response ! e) -> Request -> Response ! e

A 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.

FetchPolicy type

pub type FetchPolicy = {allow_same_site: Bool, allow_paths: List[Str]} derive Eq, Show

A 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).

fetch_allowed fn

fn fetch_allowed(p: FetchPolicy, req: Request) -> Bool

Whether p lets req through. Browsers that send no Sec-Fetch-Site are allowed.

fetch_metadata fn

fn fetch_metadata[e](p: FetchPolicy, next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that answers 403 to requests p refuses, a defence against CSRF, XSSI and cross-site leaks in browsers that send Fetch Metadata.

origin_of fn

fn origin_of(url: Str) -> Opt[Str]

The origin of a URL (scheme://host[:port]), or none.

OriginGuard type

pub type OriginGuard = {origins: List[Str] where _.all(valid_origin_pattern), allow_missing: Bool} derive Eq, Show

OWASP'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).

origin_ok fn

fn origin_ok(g: OriginGuard, req: Request) -> Bool

Whether g lets req through: GET, HEAD and OPTIONS always pass; Origin: null never does.

origin_guard fn

fn origin_guard[e](g: OriginGuard, next: Request -> Response ! e) -> Request -> Response ! e

A wrapper that answers 403 to requests origin_ok refuses.

Versions

VersionPublishedCommitSourceEffects
0.1.0 2026-10-166aa6917a31snapshot