Documentation menu
Getting started
New in v2.0
Feature deep dives
Reference
Request hardening
Request hardening
The limits a hostile request meets before it reaches a handler: body caps, server timeouts, the AI endpoint guard that blocks SSRF, and the shutdown drain.
Verified against the checkout built for v2.2.4 · updated 20 Oct 2026
Every one of these limits exists because of a specific failure it prevents: an unbounded body that exhausts memory, a request that holds a connection until the process is replaced, a tenant-supplied URL that turns the core into a port scanner. The numbers below are the values in the source, not targets.
At the edge
Method and path
Routes are registered on a pattern mux with an explicit verb. A request to a known path with the wrong verb never reaches a handler.
405 method not allowed
Body size
JSON bodies pass through a limiting reader with a per-route cap before decoding, so an oversized body is refused while it is being read rather than after it has been buffered.
413 / 400 payload_too_large
Decode and bind
The decoder is strict about types and rejects trailing garbage. A field the handler does not accept is not silently ignored where the contract says it matters.
400 invalid JSON or field
Identity and rate class
The Bearer token is validated with the core, then the class budget is charged against the verified identity or the address.
401 · 403 · 429
Outbound target check
Any request the core makes to a tenant-supplied URL — the AI provider probe — is validated first, including a DNS resolution check.
400 endpoint_blocked · endpoint_invalid · missing_endpoint
Bounded upstream call
Every core call runs under its own timeout, and a write path that touches an external provider gets a longer, still finite, budget.
502 core unavailable · 504 timeout
Timeouts
| Timeout | Value | What it stops |
|---|---|---|
| Header read | 10s | A client that opens a connection and dribbles headers cannot hold a slot indefinitely. |
| Request read | 60s | A slow body upload is bounded; the handler never starts on a half-delivered request. |
| Response write | 120s | A handler that wedges cannot keep the connection open beyond a known ceiling. |
| Idle | 120s | Keep-alive connections are reclaimed instead of accumulating. |
| Graceful drain on shutdown | 10s | In-flight requests finish; new ones are refused; then the process exits. |
| AI rewrite calls | Longer than an ordinary read, still finite | A model call is slow by nature, so it gets its own budget rather than raising the global one. |
The AI endpoint guard
The tenant names their own model endpoint, and the core calls it. That is a server-side request aimed at a host the tenant chose — which is exactly the shape of an SSRF primitive. So the endpoint is validated before the probe is allowed to leave the process:
| Check | Refusal code | Detail |
|---|---|---|
| No endpoint given | missing_endpoint | Nothing to guard and nothing to probe. |
| Not http or https | endpoint_invalid | Schemes such as file:, gopher: or ftp: are never a model provider. |
| A hostname that only means "this machine" | endpoint_blocked | localhost, metadata, metadata.google.internal, instance-data, metadata.azure.com, metadata.aws.internal. |
| A literal loopback, link-local, unspecified, multicast, private or reserved address | endpoint_blocked | Cloud metadata lives at 169.254.169.254 — link-local, and blocked on that basis. |
| A name that resolves into the host’s own networks | endpoint_blocked | Every address the name resolves to is checked, so localhost.evil.example cannot become a port scan. |
| A name that does not resolve | endpoint_unreachable | Reported as unreachable rather than retried. |
{
"ran": false,
"healthy": false,
"latency_ms": 0,
"detail": "endpoint resolves to a private address",
"error_code": "endpoint_blocked"
}Probe budget
A probe is a person waiting on a form, so it runs under a 12-second timeout — far tighter than an actual rewrite.
What comes back
A status class, a latency and at most 300 characters of the provider's own message — with the credential replaced by [REDACTED] if the provider echoed it.
Refused ≠ broken
ran: false means the guard refused to ask. It is never reported as an unhealthy provider, so the probe cannot be used to map the private network.
Payload caps
| Surface | Cap | Answer |
|---|---|---|
| Durable task payload and result | 64 KiB each, JSON only | 400 payload_too_large, naming which side exceeded it. |
| Task error text | 8 KiB | Truncated at the store, so a hostile or verbose upstream cannot bloat a row. |
| Bot token and defaults writes | Per-route limiting reader | Refused while reading, before the body is buffered. |
| Logging-policy writes | A small fixed cap | The policy document is a short JSON object; anything larger is not a policy. |
| Rule metadata | Bounded shape and size, not just a byte count | Nested blobs are capped by depth and key count, so a valid-JSON bomb is still refused. |
| Query bounds | Clamped before narrowing to an integer | limit 1–200 on task lists; an out-of-range value cannot wrap into a huge page. |
Privileged writes
The logging policy is changed where it is enforced
PUT /api/logs/policy is administrator-only and routed through the process that applies the policy. Writing it straight into a config file would let two components disagree about what is being collected, and a disagreement about retention is a disagreement about evidence.
Administrator grants are not additive by accident
POST /api/admin/admins grants the flag to an existing account; DELETE revokes it, and the last administrator cannot be removed. Losing the last admin would leave an installation no one could administer — a lockout, not a security improvement.
Shutdown
A deploy should not turn into a burst of 502s. On a termination signal the server stops accepting new connections, gives in-flight requests up to ten seconds to finish, and only then exits. Work that outlives a request is already a durable task row, so nothing is lost when a write is interrupted after the response was sent.