Documentation menu

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

On this page

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

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

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

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

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

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

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

Server and upstream timeouts
TimeoutValueWhat it stops
Header read10sA client that opens a connection and dribbles headers cannot hold a slot indefinitely.
Request read60sA slow body upload is bounded; the handler never starts on a half-delivered request.
Response write120sA handler that wedges cannot keep the connection open beyond a known ceiling.
Idle120sKeep-alive connections are reclaimed instead of accumulating.
Graceful drain on shutdown10sIn-flight requests finish; new ones are refused; then the process exits.
AI rewrite callsLonger than an ordinary read, still finiteA 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:

What the endpoint guard refuses
CheckRefusal codeDetail
No endpoint givenmissing_endpointNothing to guard and nothing to probe.
Not http or httpsendpoint_invalidSchemes such as file:, gopher: or ftp: are never a model provider.
A hostname that only means "this machine"endpoint_blockedlocalhost, metadata, metadata.google.internal, instance-data, metadata.azure.com, metadata.aws.internal.
A literal loopback, link-local, unspecified, multicast, private or reserved addressendpoint_blockedCloud metadata lives at 169.254.169.254 — link-local, and blocked on that basis.
A name that resolves into the host’s own networksendpoint_blockedEvery address the name resolves to is checked, so localhost.evil.example cannot become a port scan.
A name that does not resolveendpoint_unreachableReported as unreachable rather than retried.
A refused probe — the network was never touched
{
  "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

Where the size limit is, and what it answers
SurfaceCapAnswer
Durable task payload and result64 KiB each, JSON only400 payload_too_large, naming which side exceeded it.
Task error text8 KiBTruncated at the store, so a hostile or verbose upstream cannot bloat a row.
Bot token and defaults writesPer-route limiting readerRefused while reading, before the body is buffered.
Logging-policy writesA small fixed capThe policy document is a short JSON object; anything larger is not a policy.
Rule metadataBounded shape and size, not just a byte countNested blobs are capped by depth and key count, so a valid-JSON bomb is still refused.
Query boundsClamped before narrowing to an integerlimit 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.