12. Security considerations
Status: part of Rayfold Core 0.1, frozen with the 0.1.0 release (process.md). Every conforming server MUST meet the requirements marked MUST. The reference runtimes enforce them by default, and e2e/security.test.ts sends each attack below to a running server.
No protocol can make an API impossible to attack. What Rayfold can do is make the safe behaviour the default, put it in the contract instead of in each handler, and test it on every change. This chapter lists the threats Rayfold Core addresses, the rule that addresses each one, and what stays the application's job.
1. Threat model
Rayfold assumes:
- Clients are untrusted. Every byte of a request, including shapes, variables,
$refpaths, idempotency keys, deadlines and binary (RB) input, may be hostile. - Browsers are a special kind of client. They attach cookies automatically and let any web page send some requests to any site. A server reached from a browser must not let one site act on another's behalf.
- The schema and resolvers are trusted. They are written by the service's owners. Their mistakes, such as a resolver that ignores
ctx.simulate, should fail safe where the runtime can detect them.
Out of scope for Rayfold Core:
- Transport encryption: use TLS.
- Authentication itself: the transport's viewer hook turns credentials into a viewer.
- Rate limits across many requests: Rayfold bounds the cost of each batch, and a gateway or the viewer hook limits request rates.
2. Browser-facing requests
Rules for requests reaching a Rayfold server over HTTP, including REST-style bindings, the MCP endpoint and WebSocket handshakes:
- Body types. A POST or QUERY to the batch endpoint MUST carry
application/rayfold+json,application/jsonorapplication/rayfold. Any other type gets415with an RFC 9457 problem, before the body is parsed. A binding with a body MUST accept onlyapplication/json, and PATCH alsoapplication/merge-patch+json. The MCP endpoint MUST accept onlyapplication/json. Browsers let any page sendtext/plain, form and multipart bodies to any site without asking. JSON media types force a CORS preflight, which a foreign page cannot pass. - Origin. A request that can change data and carries an
Originheader MUST be refused with403unless the origin is the server's own or is listed in the server's allowed origins. That covers POST withoutRayfold-Safe, PUT, PATCH, DELETE, MCP calls and WebSocket handshakes. Safe requests are exempt: GET, QUERY, and POST withRayfold-Safe: true, which may hold only queries. They cannot change data, a foreign page cannot send QUERY or theRayfold-Safeheader without a CORS preflight, and it cannot read the answer. Requests withoutOrigincome from non-browser clients and are unaffected. Behind a proxy that rewrites Host, list the public origin, or writes are refused while reads keep working. - Host. A server reached on a loopback address (127.0.0.0/8 or ::1) MUST answer only loopback host names (
localhost,127.0.0.1,[::1]) unless it is configured with an explicit host list. Other hosts get403. This defeats DNS rebinding, where a page renames its own domain to 127.0.0.1 and would otherwise count as same origin. - POST bindings need an
Idempotency-Keyheader (spec 04 section 8). A plain HTML form cannot send one. - Headers. Every response MUST carry
X-Content-Type-Options: nosniff. Problem responses MUST carryCache-Control: no-store. A304needs neither, because it has no body.
3. Resource limits
- Body size. A server MUST cap request bodies (default 1 MiB) and answer an oversized one with
413 Content Too Large(problem typepayload_too_large, coderesource_exhausted), because retrying the same body cannot help. After refusing, it SHOULD keep draining the upload up to a bound, so the refusal reaches the client, then close the connection. WebSocket frames and assembled messages MUST be capped too (default 1 MiB); an oversized one closes the connection with code 1009. - Nesting. Arguments and variables nested deeper than 64 levels MUST fail the batch with
invalid_argumentbefore anything walks them recursively. Shape text nested deeper than 64 levels MUST be refused while parsing. RB values nested deeper than 64 levels MUST be refused while decoding, and an RB length prefix larger than the remaining input MUST be refused before allocating. - Cost. The batch budget (spec 06 section 5) is computed as follows:
- Each op's arguments are coerced first. An op whose arguments fail validation never runs and costs 0; it reports its error when its turn comes.
- Arguments containing
$refcannot be coerced before earlier ops run. For those, any page size that is not a whole number from 0 to 200 counts as 200. - Every op costs at least 1, and arithmetic MUST NOT wrap.
- Scalar fields cost nothing by default, but every row a page can return costs 1 (spec 06 section 5), so a shape of scalars alone cannot make a large page cheap.
- The result: bad input can raise the estimate but never lower it.
- Depth and fields. The execution limits (
maxDepth,maxFields) apply as specified in spec 02 section 5. - Deadlines.
meta.deadlineand an op'sdeadlineMUST be whole milliseconds from 0 to 600,000. Anything else isinvalid_argument. - Bounded server memory. Shapes learned from requests MUST be kept only after the op passed planning, and in a bounded store (default 10,000, least recently used first out). Shapes the server registers itself are never evicted. Idempotency records MUST expire (default 24 hours), and the store MUST be bounded (default 100,000, expired records first, then the oldest). A key held by a command that is running now is never evicted, since evicting it would let a second request run the same command; a key whose lease has run out with nothing recorded holds no command and MUST NOT keep older entries from being evicted behind it. Keys in flight count against the bound, so the room left for records is the bound minus the commands running at that moment: a bound below the number of commands a server runs at once leaves nothing to replay from.
4. Idempotency and replays
- Scope. Records are scoped to the viewer. A command that carries an idempotency key from a caller with no viewer MUST be refused with
unauthenticated, because anonymous callers cannot be told apart and would share one replay scope. Applications that serve guests give them a viewer, such as a guest session. Commands without a key (@idempotent(false), or idempotent HTTP methods on bindings) and dry runs still run for anonymous callers. - Binding. A record is bound to the operation as well as its coerced arguments. Reusing a key for another operation or other arguments is
already_exists: "Idempotency key K was used for another operation or other arguments". - Authorization first. The operation's write policy MUST be checked before a replay is served.
- One execution. Two requests with the same scope and key that arrive together MUST execute once. The later request waits for the first, then replays its result. A command that failed before it changed anything leaves no record and releases the key, so a retry runs it. A command that failed after its effect MUST record that failure, and one whose op was canceled or ran out of time after its effect MUST record a
canceledanswer saying the command committed. Retries are answered with the record rather than running the command a second time. - Form. A replay answers in the form the retry asks for (compact or full), under the retrying op's id.
- Leases. A server that takes a key holds it for a bounded lease and renews the lease while the command runs, so that a key is not held forever by a server that stopped. A request MAY take over a key whose lease has run out. This is the one case where a command can run twice: a server that stops between its effect and its record leaves nothing to replay. A lease MUST be longer than the commands the server serves (default 30 seconds).
5. Authorization and data exposure
- Existence. When a type-level read policy denies an entity at a nullable position, the value is
null, the same as for an entity that does not exist, even with an explicit shape. Non-null positions and list elements report the error, and field-level denials are unchanged (spec 06). - Comparisons. In policy expressions:
- Numbers and numeric text (Decimal and Long travel as text) compare exactly by value, including beyond 2^53.
- Two non-numeric texts compare by code point.
- Ordering values that cannot be ordered, such as text against a boolean, is an evaluation error.
- A policy whose expression errors fails closed:
@allowdoes not allow,@denydenies.
$refpaths MUST read only the earlier result's own data. Keys such as__proto__,constructorortoStringresolve to nothing. Decoders MUST store a__proto__key as plain data.- Numbers. An integer outside the safe range (beyond 2^53) sent as a JSON number, or a Decimal whose text form is not plain decimal (such as
1e21), MUST be refused. Such values travel as text. - Errors. Unexpected failures MUST reach the client as
internalwith a generic message, never with messages or stack traces from the server (spec 05). - Manifest.
GET /rayfold/manifestSHOULD serve the schema without policy expressions: annotations namedallowanddenykeep their names, and their arguments are removed. Clients need names and types, not how access is decided. Serving the full IR MUST be an explicit choice, and the manifest MAY be switched off.
6. Dry runs
A command accepts simulate: true only if it declares @simulate (spec 01), which is the author's statement that its resolver honours ctx.simulate. Otherwise the op fails with failed_precondition: "X() does not support dry runs". The runtime cannot stop resolver code from writing, so the declaration is required before an agent is offered a dry run. The MCP bridge lists a .simulate tool only for such commands (spec 10). A dry run never records an idempotency result and never publishes events or live-query changes.
7. Checklist for deployments
- Serve Rayfold over TLS.
- Give every caller who may write a viewer, including guests.
- Keep
allowedOriginsto the web apps that call the API. - Turn on trusted shapes in production (spec 02 section 3) when the client set is known.
- Keep the manifest redacted, or switch it off on public servers.
- Put request-rate limits in front of the server; Rayfold limits the cost of each batch, not the number of batches.