Skip to content

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 most of the attacks below to a running server.

No protocol can make an API impossible to attack. Rayfold makes the safe behaviour the default, puts it in the contract instead of in each handler, and tests 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, $ref paths, 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:

  1. Body types. A POST or QUERY to the batch endpoint MUST carry application/rayfold+json, application/json or application/rayfold. Any other type gets 415 with an RFC 9457 problem, before the body is parsed. A binding with a body MUST accept only application/json, and PATCH also application/merge-patch+json. The MCP endpoint MUST accept only application/json. Browsers let any page send text/plain, form and multipart bodies to any site without asking. JSON media types force a CORS preflight, which a foreign page cannot pass.
  2. Origin. A request that can change data and carries an Origin header MUST be refused with 403 unless the origin is the server's own or is listed in the server's allowed origins. That covers POST without Rayfold-Safe, PUT, PATCH, DELETE, MCP calls and WebSocket handshakes. Safe requests are exempt: GET, QUERY, and POST with Rayfold-Safe: true, which may hold only queries. They cannot change data, a foreign page cannot send QUERY or the Rayfold-Safe header without a CORS preflight, and it cannot read the answer. Requests without Origin come 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. * in the allowed origins allows any origin. The server's own origin is the one whose host and port equal the Host header. When the request reached the server over TLS, as the transport says or as a proxy's X-Forwarded-Proto: https says, an http: origin is not the server's own: a network attacker can write a page served over plain http. The scheme is not compared the other way, because a server behind a TLS-terminating proxy that sends no X-Forwarded-Proto sees plain http for its own https pages. X-Forwarded-Proto is only ever believed when it says https, so a forged one can make the rule stricter and never looser.
  3. 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 get 403. This defeats DNS rebinding, where a page renames its own domain to 127.0.0.1 and would otherwise count as same origin.
  4. POST bindings need an Idempotency-Key header (spec 04 section 8). A plain HTML form cannot send one.
  5. Headers. Every response MUST carry X-Content-Type-Options: nosniff. Problem responses MUST carry Cache-Control: no-store. A 304 needs neither, because it has no body.

3. Resource limits ​

  1. Body size. A server MUST cap request bodies (default 1 MiB) and answer an oversized one with 413 Content Too Large (problem type payload_too_large, code resource_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. A text message that is not valid UTF-8 closes the connection with code 1007 (RFC 6455 section 8.1) and runs nothing.
  2. Nesting. Arguments and variables nested deeper than 64 levels MUST fail the batch with invalid_argument before 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.
  3. 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 is not counted against the batch at all; it reports its error when its turn comes. The floor of 1 below applies to the ops that are actually estimated.
    • Arguments containing $ref cannot 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.
  4. Depth and fields. The execution limits (maxDepth, maxFields) apply as specified in spec 02 section 5.
  5. Deadlines. meta.deadline and an op's deadline MUST be whole milliseconds from 0 to 600,000. Anything else is invalid_argument.
  6. 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, at which age a record has expired: 03 §4), 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 ​

  1. 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.
  2. 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". A store shared by servers of more than one implementation needs them to agree on what the binding is, so it MUST be the SHA-256, in lower-case hexadecimal, of the canonical JSON (01 §9: keys sorted, no insignificant whitespace, UTF-8) of { "op": <operation name>, "args": <coerced arguments> }. The scope is the same hash of the viewer. In both, a number MUST be written in the form ECMAScript writes it: the shortest decimal that reads back as the same value, with no trailing .0, in exponent form only below 1e-6 or from 1e21 up (2.50 and 2.5 are one number and hash alike). Elsewhere canonical JSON keeps the number as it was written, so an implementation that normalises only here changes none of its other hashes.
  3. Authorization first. The operation's write policy MUST be checked before a replay is served.
  4. 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 canceled answer saying the command committed. Retries are answered with the record rather than running the command a second time. A store that fails to record a committed command's answer does not change that answer: the op has succeeded, its dependents run, and its key stays held until the lease runs out.
  5. Form. A replay answers in the form the retry asks for (compact or full), under the retrying op's id.
  6. 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 ​

  1. 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).
  2. 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: @allow does not allow, @deny denies.
  3. $ref paths MUST read only the earlier result's own data. Keys such as __proto__, constructor or toString resolve to nothing. Decoders MUST store a __proto__ key as plain data.
  4. 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.
  5. Errors. Unexpected failures MUST reach the client as internal with a generic message, never with messages or stack traces from the server (spec 05).
  6. Manifest. GET /rayfold/manifest SHOULD serve the schema without policy expressions: annotations named allow and deny keep 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. The write policy is checked first, so a caller it refuses gets permission_denied and learns nothing about dry runs. 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 allowedOrigins to 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.

Released under the Apache-2.0 license.