The HTTP guards both servers hold to — vaelii.impl.web (the browser) and
vaelii.impl.serve (the daemon).
The browser authenticates nobody and the daemon only when a token is set
(api-token), and both bind loopback for that reason. Loopback is what makes the
checks here necessary rather than sufficient: a browser running on the same machine
is a local client, so "only this machine may reach it" does not mean "only this
machine's owner may drive it". Two attacks follow from that, and each guard below
closes one.
Cross-site request forgery. Any page the operator visits can fetch a loopback
URL. same-origin? rejects the write whenever the browser stamps Origin.
edn-body? closes the case where it does not: application/edn is not a
CORS-simple content type, so a browser must preflight it, and a server answering
no CORS headers fails that preflight before the request is ever sent.
DNS rebinding. same-origin? compares Origin against the request's own
Host, so an attacker controlling both — a domain that re-resolves to 127.0.0.1
once the page is loaded — satisfies it. host-allowed? is the check that does not
fold, because the Host header must then name the interface the server was actually
started on.
The HTTP guards both servers hold to — `vaelii.impl.web` (the browser) and `vaelii.impl.serve` (the daemon). The browser authenticates nobody and the daemon only when a token is set (`api-token`), and both bind loopback for that reason. Loopback is what makes the checks here necessary rather than sufficient: a browser running on the same machine *is* a local client, so "only this machine may reach it" does not mean "only this machine's owner may drive it". Two attacks follow from that, and each guard below closes one. **Cross-site request forgery.** Any page the operator visits can `fetch` a loopback URL. `same-origin?` rejects the write whenever the browser stamps `Origin`. `edn-body?` closes the case where it does not: `application/edn` is not a CORS-*simple* content type, so a browser must preflight it, and a server answering no CORS headers fails that preflight before the request is ever sent. **DNS rebinding.** `same-origin?` compares `Origin` against the request's own `Host`, so an attacker controlling both — a domain that re-resolves to 127.0.0.1 once the page is loaded — satisfies it. `host-allowed?` is the check that does not fold, because the `Host` header must then name the interface the server was actually started on.
(allowed-hosts bound-host)The allowlist for a server bound to bound-host.
VAELII_ALLOWED_HOSTS (comma-separated) overrides everything. Otherwise a
loopback bind — the default — answers only to loopback names, which is what closes
rebinding. A bind that named an address is already an explicit choice made against
a documented warning, and the operator reaches it under a name only they know, so
it is left open rather than guessed at — refusing it would trip a daemon fronted by
a reverse proxy that legitimately sets its own Host, which the operator cannot
always enumerate in advance. Left unset, serve's startup line warns once rather
than staying silent about it (allowlist-open?).
The allowlist for a server bound to `bound-host`. `VAELII_ALLOWED_HOSTS` (comma-separated) overrides everything. Otherwise a loopback bind — the default — answers only to loopback names, which is what closes rebinding. A bind that named an address is already an explicit choice made against a documented warning, and the operator reaches it under a name only they know, so it is left open rather than guessed at — refusing it would trip a daemon fronted by a reverse proxy that legitimately sets its own `Host`, which the operator cannot always enumerate in advance. Left unset, `serve`'s startup line warns once rather than staying silent about it (`allowlist-open?`).
(allowlist-open? allowed)Does allowed — as allowed-hosts returns it — admit every Host? True only for
a public bind with no VAELII_ALLOWED_HOSTS: the loopback default and any
VAELII_ALLOWED_HOSTS value both resolve to a concrete set instead. What a caller
above this namespace uses to turn the sentinel into an operator-facing word — serve's
startup line names the posture rather than logging the keyword.
Does `allowed` — as `allowed-hosts` returns it — admit every `Host`? True only for a public bind with no `VAELII_ALLOWED_HOSTS`: the loopback default and any `VAELII_ALLOWED_HOSTS` value both resolve to a concrete set instead. What a caller above this namespace uses to turn the sentinel into an operator-facing word — `serve`'s startup line names the posture rather than logging the keyword.
(api-token)The daemon's shared bearer token, VAELII_API_TOKEN, or nil when it is unset or
blank.
Read here rather than at either end of the wire, for max-body-bytes' reason:
the daemon that requires the token and the client that presents it are two readers of
one variable, and two readings of "set" is one of them wrong. A whitespace-only
value is unset — an exported-but-empty variable is the shell's way of saying
nothing — and anything else is the token byte for byte, untrimmed, because trimming a
secret silently changes it.
An environment variable rather than an option or a file: it is what a process manager injects without the value reaching the repo, and nothing that reads a KB's opts can leak it into a store.
The daemon's shared bearer token, `VAELII_API_TOKEN`, or nil when it is unset or blank. **Read here rather than at either end of the wire**, for `max-body-bytes`' reason: the daemon that requires the token and the client that presents it are two readers of one variable, and two readings of "set" is one of them wrong. A whitespace-only value is *unset* — an exported-but-empty variable is the shell's way of saying nothing — and anything else is the token byte for byte, untrimmed, because trimming a secret silently changes it. An environment variable rather than an option or a file: it is what a process manager injects without the value reaching the repo, and nothing that reads a KB's opts can leak it into a store.
(edn-body? req)Is this request's body declared application/edn?
The daemon requires it, and the requirement is a CSRF guard rather than a parsing
one: the three content types a cross-site fetch may set without a preflight are
text/plain, application/x-www-form-urlencoded and multipart/form-data, so
demanding anything else forces a preflight the daemon cannot answer.
Is this request's body declared `application/edn`? The daemon requires it, and the requirement is a CSRF guard rather than a parsing one: the three content types a cross-site `fetch` may set without a preflight are `text/plain`, `application/x-www-form-urlencoded` and `multipart/form-data`, so demanding anything else forces a preflight the daemon cannot answer.
(host-allowed? allowed req)Does this request's Host name an interface allowed covers?
A request carrying no Host is allowed: HTTP/1.1 requires the header and every
browser sends it, so its absence marks a non-browser client (curl, a test's request
map) — which has no ambient browser context to ride, and is not the request
rebinding is about. Same carve-out same-origin? makes, for the same reason.
Does this request's `Host` name an interface `allowed` covers? A request carrying **no** `Host` is allowed: HTTP/1.1 requires the header and every browser sends it, so its absence marks a non-browser client (curl, a test's request map) — which has no ambient browser context to ride, and is not the request rebinding is about. Same carve-out `same-origin?` makes, for the same reason.
The Host values that name this machine. Both bracketed and bare IPv6 spellings,
since a client picks either.
The `Host` values that name this machine. Both bracketed and bare IPv6 spellings, since a client picks either.
The cap on a request body, VAELII_MAX_BODY_BYTES or 16 MiB.
The browser authenticates nobody and the daemon need not, so an unbounded body is heap an anonymous caller can spend by streaming one — and the legitimate bodies are tiny either side: the daemon's is a sentence and its context, the browser's is a form. One constant and one variable for both, because two servers with two ceilings is one of them wrong, and an operator who lowers the limit means the machine rather than a route.
A value that is not a positive integer is refused at load, naming itself. This
namespace is read by both servers, so a silent fallback would leave an operator who
meant 16m believing a cap they never set — and a raw NumberFormatException out of
a def reports as a namespace that would not load rather than as the typo it is.
The cap on a request body, `VAELII_MAX_BODY_BYTES` or 16 MiB. The browser authenticates nobody and the daemon need not, so an unbounded body is heap an anonymous caller can spend by streaming one — and the legitimate bodies are tiny either side: the daemon's is a sentence and its context, the browser's is a form. **One constant and one variable for both**, because two servers with two ceilings is one of them wrong, and an operator who lowers the limit means the machine rather than a route. A value that is not a positive integer is **refused at load**, naming itself. This namespace is read by both servers, so a silent fallback would leave an operator who meant `16m` believing a cap they never set — and a raw `NumberFormatException` out of a `def` reports as a namespace that would not load rather than as the typo it is.
(read-capped-body req)req's body as a UTF-8 string, refusing past max-body-bytes.
`req`'s body as a UTF-8 string, refusing past `max-body-bytes`.
(read-capped-body-bytes req)req's body as a byte array, refusing past max-body-bytes. slurp — and ring's
own params middleware — read an unbounded body into the heap before anything gets to
look at it, which is the whole of what this replaces.
`req`'s body as a byte array, refusing past `max-body-bytes`. `slurp` — and ring's own params middleware — read an unbounded body into the heap before anything gets to look at it, which is the whole of what this replaces.
(same-origin? req)Does this request come from the browser's own copy of this site? A write route
with no session to authenticate has to ask who asked: a browser stamps Origin
(falling back to Referer) on a form or fetch POST and a page on another site
cannot forge it, so comparing it to the request's own Host rejects a cross-site
write while leaving the browser's own pages alone.
A request carrying neither header is same-origin by default: that is a
non-browser client, which has no ambient browser context for another site to ride.
It is host-allowed? that keeps this from being the whole story — on its own this
check folds under DNS rebinding, where both headers are the attacker's.
Does this request come from the browser's own copy of this site? A write route with no session to authenticate has to ask **who asked**: a browser stamps `Origin` (falling back to `Referer`) on a form or fetch POST and a page on another site cannot forge it, so comparing it to the request's own `Host` rejects a cross-site write while leaving the browser's own pages alone. A request carrying **neither** header is same-origin by default: that is a non-browser client, which has no ambient browser context for another site to ride. It is `host-allowed?` that keeps this from being the whole story — on its own this check folds under DNS rebinding, where both headers are the attacker's.
(url-origin url)The scheme://authority a URL names, or ::opaque when it names none — a
sandboxed frame sends Origin: null, which is a real origin claim that matches
nothing and must not be read as "no header".
The `scheme://authority` a URL names, or `::opaque` when it names none — a sandboxed frame sends `Origin: null`, which is a real origin claim that matches nothing and must not be read as "no header".
(wrap-body-limit handler refusal)Wrap handler so a request body past max-body-bytes gets refusal (a fn of the
request) instead — the 413.
For a server that does not read its own bodies. The browser reads its forms
through ring's params middleware, which slurps the body itself and has no ceiling, so
the cap has to be applied outside it; the buffered copy this leaves on :body is what
that middleware then reads. The daemon reads its own body and calls
read-capped-body directly.
Wrap `handler` so a request body past `max-body-bytes` gets `refusal` (a fn of the request) instead — the 413. For a server that does **not** read its own bodies. The browser reads its forms through ring's params middleware, which slurps the body itself and has no ceiling, so the cap has to be applied outside it; the buffered copy this leaves on `:body` is what that middleware then reads. The daemon reads its own body and calls `read-capped-body` directly.
(wrap-host-allowed handler allowed refusal)Wrap handler so a request whose Host falls outside allowed gets refusal
instead. Applied to the whole server rather than to its write routes: a rebound
page reads as well as it writes, and the KB is what it came for.
Wrap `handler` so a request whose `Host` falls outside `allowed` gets `refusal` instead. Applied to the whole server rather than to its write routes: a rebound page reads as well as it writes, and the KB is what it came for.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |