SECURITY_GUIDE.md
This document defines the security invariants of SurrealDB and the review triggers that gate any change touching them. It is the canonical source of truth for what "secure" means in this codebase.
Audience. Contributors and maintainers writing code, human reviewers approving PRs, and AI tooling assisting with development or code review.
When to consult. Before opening or reviewing any change that touches files or behaviours matched by a section's Review Triggers — authentication, sessions, permissions, RPC/HTTP transport, the parser, function execution, storage keys, imports, or anything in the Cross-Cutting Concerns.
How to apply. Treat each invariant as a requirement that must still hold after
the change. If a code path deviates from an invariant, it must carry an explicit
// SECURITY: (or equivalent) comment explaining why and why it is safe — see
Intentional Exceptions. Otherwise flag the deviation at the severity defined in
Severity Triage.
For the general (non-security) review checklist — performance, error handling, concurrency, test coverage, dependencies — see REVIEW.md.
File references use short module names. Core engine modules (iam/, dbs/, fnc/,
buc/, rpc/, sql/, kvs/, doc/) live under surrealdb/core/src/. Server
modules (ntw/, rpc/) live under surrealdb/server/src/. When both crates
contain the same module name (e.g. rpc/), both locations are security-relevant.
When a diff touches a security-sensitive area identified by a section's Review Triggers, the reviewer should:
When multiple sections are triggered, prioritize findings as follows:
Code paths may intentionally deviate from an invariant with explicit justification.
When reviewing, do not flag a violation if the code contains an accompanying comment
(e.g. // SAFETY:, // SECURITY:) that documents why the deviation is necessary
and why it is safe. Flag the deviation if the justification is absent, unclear, or
does not address the specific invariant being violated.
Test code (#[cfg(test)], files under tests/, language-tests/) may use patterns
that are forbidden in production code (e.g. unwrap, perms bypass). These are
acceptable in test context.
SurrealDB's security model rests on four pillars:
perms=false)
must be strictly bounded.Files: iam/, sql/access*.rs, session/token handling, JWT signing/verification
Flag for detailed review when changes touch:
is_allowed)Files: iam/, dbs/options.rs (Options struct, perms flag, with_import),
ctx/context.rs (check_perms, is_allowed), document processing pipeline in
doc/ (check_permissions_table, process_table_fields, pluck_generic,
pluck_select)
perms=false, this
suppression must not extend to user-supplied expressions or user-controlled data
lookups beyond the strictly required scope.OPTION IMPORT and OPTION FORCE must require Editor-or-above at Database scope.
No Record-scoped or anonymous actor may activate import mode.USE statement that changes namespace or database must verify the actor's
Auth level grants access to the target scope.Permission enum defaults to Full. All code constructing permission objects
must do so intentionally. Omitting a PERMISSIONS clause results in full access.process_table_fields) and read path (pluck_generic, pluck_select),
including for computed fields, reduced documents, and all output modes (AFTER,
BEFORE, DIFF, FIELDS).Permissions::has_direct_write, checked in every
DEFINE that stores permissions), and a runtime guard that rejects any mutating
statement reached while a predicate is evaluated. Predicate evaluation always
uses Options::new_for_permission_predicate (legacy path) or carries
skip_fetch_perms (streaming path), both of which set Options::permission_predicate
so Expr::compute blocks CREATE/UPDATE/DELETE/RELATE/INSERT/UPSERT and DDL —
including writes reached through custom-function bodies.Flag for detailed review when changes touch:
Options::check_perms, Options::is_allowed, or perms flag propagationPermission enum, its Default impl, or the Permissions structnew_with_perms(false) or with_import(true)DEFINE EVENT, DEFINE TABLE ... AS, or DEFINE FIELD ... REFERENCE processingAuth, AuthLimit, Actor, or role-checking methodsUSE statement handling in the executorprocess_table_fields, pluck_generic, or pluck_selectpurge_references or cascade processing logicauth_enabled is evaluated or propagatedreduced document mechanism or computed field handlingFiles: Executor, session management, iam/reset.rs, context resolution
use call, and HTTP header-based context selection must
verify the authenticated principal is authorized for the target namespace and
database before the switch takes effect.$auth, $token, $session, $access) must not be set,
overwritten, or removed by LET, UNSET, RPC set, or RPC unset. Protection
must apply symmetrically to both set and unset operations.surreal-ns and surreal-db headers must be validated against the
principal's authorized scope.reset, the session must be in the most restrictive state. If auth is
required, the reset session must not permit data operations until re-authentication.Flag when changes touch:
USE statement gains new semantics or session manipulationSession struct gains new security-relevant fieldsPROTECTED_PARAM_NAMES list changesFiles: Document processing pipeline (create, update, upsert, insert, delete, relate), Key API, field evaluation, events, views, cascades
Flag when changes touch:
Files: ntw/ (Axum router, middleware stack), CORS, WebSocket handlers,
GraphQL, error formatting
pub must
be documented with security implications.Flag when changes touch:
set_session, or session lifecycleclient_ip module (header sources, proxy validation)/gql GQL endpoint (ntw/gql.rs: route, body-size limit, and the
RouteTarget::Gql / ExperimentalTarget::Gql capability gates)ResponseError implementationRequestBodyLimitLayer applicationFiles: rpc/, WebSocket handling, CBOR/JSON/FlatBuffers deserialization,
transaction management, notification routing
begin RPC method must cap maximum concurrent transactions per WebSocket
connection. Unbounded transaction accumulation must not be possible.Flag when changes touch:
gql RPC method (GQL: must keep allows_query_by_subject and the
ExperimentalTarget::Gql gate in Datastore::parse_gql)check_protected_param or PROTECTED_PARAM_NAMESFiles: Live query creation, notification dispatch, KILL handling, cleanup
Flag when changes touch:
Files: Schema statement processing, catalog writes, expression validation
is_allowed(Action::Edit, ResourceKind, Base)
before execution. Schema operations must require explicit namespace/database
context.Flag when changes touch:
Files: ntw/api.rs, API routing, middleware, handler execution
/api/:ns/:db/:endpoint) must be
validated against the session's authenticated context. Mismatches must be rejected..., %2e%2e, encoded slashes) must be rejected or
normalized before routing.Flag when changes touch:
ntw/api.rs routing, path matching, or parameter extractionFiles: Import endpoint, export endpoint, streaming parser, CLI import
Flag when changes touch:
OPTION IMPORT authorization or flag propagationFiles: fnc/, scripting runtime (QuickJS), HTTP client, capabilities,
Surrealism/WASM
$array.map(...)) must be correctly
canonicalized for capability checking.inherit_env) must be reviewed for secret exposure.eval::surql / eval::gql functions (fnc/eval.rs) evaluate a runtime
query string in the caller's transaction. They must remain gated by all of:
the function-family capability (enforced by the engine before dispatch), the
arbitrary-query subject gate (Capabilities::allows_query — an eval call is
an arbitrary query, so it cannot bypass the front-door gate from inside a
DEFINE FUNCTION / DEFINE API body), and the dedicated eval subject gate
(Capabilities::allows_eval_query, EvalQueryTarget), which defaults to denied
for every subject even under Capabilities::all() / --allow-all. eval::gql
additionally inherits the gql experimental gate via
gql::parse_with_capabilities.Auth::new_limited (the auth-limiting applied to user-defined
function bodies) never raises the subject class — a record/guest caller is
returned unchanged and a system caller is only ever narrowed. A record-scoped
user invoking an owner-defined function that calls eval is therefore still
seen as record and remains denied.MAX_EVAL_DEPTH), and honour PROTECTED_PARAM_NAMES for caller bindings.Flag when changes touch:
fnc/mod.rs)eval::* functions, the EvalQueryTarget capability, or how the eval
subject gates derive the subject from execution authFiles: buc/, BucketController, object store backends
check_permission()
with the correct BucketOperation.Flag when changes touch:
buc/ module, BucketController, or bucket permission checkingDEFINE BUCKET processing or backend URL handlingFiles: INFO statements, health/status/version endpoints, metrics
Flag when changes touch:
Files: kvs/, key encoding/decoding, transaction handling, datastore
implementations
Flag when changes touch:
kvs/ key types)Files: syn/, sql/, gql/, parser entry points, expression construction
Result types.Flag when changes touch:
gql/ parsing and lowering, the gql RPC method,
the /gql HTTP route)Files: gql/ (lexer, parser, lower/), expr/match_plan.rs,
the binding-table operators — exec/operators/graph/ (expand.rs, endpoint.rs,
path_expand.rs, distinct_edges.rs), exec/operators/join/hash_join.rs,
exec/operators/{bind.rs, distinct.rs}, and the FieldState-aware fetch helper
exec/operators/scan/fetch.rs —
exec/planner/match_plan.rs, kvs/ds.rs (parse_gql / process_gql /
execute_gql), rpc/protocol.rs (gql method, QueryForm::Plan),
ntw/gql.rs (/gql route)
GQL is a second query language lowered to a MatchPlan IR and executed by
the streaming engine. The lowering constructs only a single top-level
Expr::Match (never any other Expr, and never a sql::Ast). It supports reads
(MATCH … RETURN) and the four ISO data-modifying statements (INSERT, SET,
REMOVE, DELETE), which the MatchPlan carries as trailing mutation stages
executed through the native document pipeline. Its security posture rests on five
invariants.
SELECT on that table would return for
the same caller. All fetched node/edge bindings (Expand targets and edges,
EndpointBind nodes, PathExpand intermediate and terminal nodes/edges) must go
through the FieldState-aware helper in exec/operators/scan/fetch.rs
(resolve_with_field_state), which applies, in order: table-level SELECT
permission (deny ⇒ record dropped, neither existence nor contents leak),
computed-field evaluation, and field-level SELECT permission (unreadable fields
cut). The bare scan::common::resolve_record_batch must not be substituted
— it applies only the table-level permission and skips the field-level
machinery, which would leak restricted fields into binding rows.gql cargo feature compiled in; for HTTP, the RouteTarget::Gql capability
on the /gql route; the ExperimentalTarget::Gql experimental capability
(checked in Datastore::parse_gql and gql::parse_with_capabilities
— the language gates itself, not relying on the caller); allows_query_by_subject
for the session's auth subject (the gql RPC method); and a valid, non-anonymous
session under the namespace/database authorization context. Removing or
reordering any layer is a regression.SURREAL_GQL_MAX_PATH_ROWS (default 1,000,000) bounds live+emitted
rows in PathExpand per source row (the counter resets for each input row,
so it caps the genuinely dangerous single-source combinatorial explosion and
keeps live DFS memory at O(longest_path × fan-out); the aggregate output is
bounded by N_source_rows × SURREAL_GQL_MAX_PATH_ROWS, where N_source_rows is
itself bounded by the anchor scan's cardinality — size the knob with that
per-source ceiling in mind), SURREAL_GQL_MAX_JOIN_BUILD_ROWS (default
1,000,000) bounds the HashJoin build side (and the Distinct seen-set), and
SURREAL_GQL_MAX_OUTPUT_ROWS (default 1,000,000) bounds the cumulative rows
emitted by HashJoin and single-hop Expand — a distinct axis, because a
Cross product or high-fan-out join emits far more rows than either side
holds while the build set stays small. Exceeding any must abort the query with
an error naming the knob — quantifiers, multi-pattern joins, cartesian (no
shared variable) joins, and variable-length paths are amplification vectors and
must stay bounded.HashJoin fully drains its build side then fans out the
probe; PathExpand runs a per-source DFS; Expand scans a vertex's whole
adjacency), so upstream cancellation cannot propagate through them. Each must
poll ctx.cancellation() in its hot loops (the streaming buffer/monitor
wrappers inject none) or a long-running MATCH ignores client disconnect / query
timeout — a DoS. Dropping a poll is a regression.MatchPlan must run only under the
streaming engine; its compute-only arm is a hard error (Expr::Match compute() returns
"GQL MATCH requires the streaming execution engine; it cannot run under the
compute-only planner strategy"). Expr::Match must never enter a sql::Ast,
the catalog, or Revisioned serialization — the From<expr::Expr> for sql::Expr
conversion logs, debug_assert!s, and emits a placeholder rather than
round-tripping it. A mutation-bearing plan reports read_only() == false
(MatchPlan::has_mutations), so the executor opens a write transaction.exec/plan_or_compute::legacy_compute — a synthetic core Create/Update/Delete/Relate
statement per resolved record id — never by writing the KV store directly, so record/field
permissions, field validation, events, indexes, references, and live-query notifications all
apply exactly as for a native mutation. The mutation operators (exec/operators/mutate.rs)
must stay pipeline breakers: they fully drain their input before any write (avoiding the
Halloween problem of a still-open scan re-observing a freshly written row), then apply per
row in textual order (row-scoped, last-write-wins on a fan-out). A RETURN after a mutation
must re-bind only the permission-filtered
post-mutation image (the value the native mutation returns), never raw fields the caller
cannot read. NODETACH DELETE (the ISO default) must probe for connected edges and error if
any exist (native DELETE always cascades = DETACH); that integrity probe (has_connected_edges)
must peek the record's graph-key range directly off the transaction — the same
permission-independent check the native cascade uses (doc::purge::purge_edges), NOT a
SELECT/idiom read — so it reflects the record's actual adjacency, not the caller's SELECT
visibility. Otherwise a record-scoped user who cannot see the incident edges would slip past the
guard and trigger a silent cascade. Per-property SET a.p and the SET a = {…} surface both
reject the reserved keys (id, edge in/out), since the native write path silently re-stamps
them. Label mutations are rejected (one table per record).MATCH/OPTIONAL clause that follows a mutation must observe
that mutation's writes (read-your-writes within the one transaction), and must never race them.
The streaming engine's read-only buffering (buffer.rs spawn_buffered) opens scan cursors
eagerly at stream-construction time for pipeline parallelism; that eager read is buried
throughout a subtree (every read-only operator buffers its child), so it cannot be defeated by
buffering choices at the join level alone. HashJoin (the operator that joins a read clause onto
the accumulated bindings) handles this by ensuring the writing side drains before the reading
side is constructed, and which side mutates depends on the fold: fold_mandatory puts the
mutating accumulator on the build side (then DEFER the probe's construction until the build
drains), while fold_optional's left-join puts it on the probe side (then PRE-DRAIN the probe
— a pipeline breaker, so draining executes its writes — before constructing the build, and replay
the buffered probe rows). Both directions are load-bearing: reverting either to eager construction
silently reintroduces stale reads (a MATCH/OPTIONAL after SET/DELETE/INSERT seeing
pre-write data). Pure-read joins keep eager construction on both sides (one shared snapshot;
overlap is safe and faster).Flag when changes touch:
exec/operators/scan/fetch.rs or any binding-fetch call site (a switch to
resolve_record_batch, or a new fetch path that bypasses FieldState, is a
field-permission leak)ExperimentalTarget::Gql, RouteTarget::Gql,
allows_query_by_subject in the gql RPC method, or the experimental check in
Datastore::parse_gqlSURREAL_GQL_MAX_PATH_ROWS / SURREAL_GQL_MAX_JOIN_BUILD_ROWS /
SURREAL_GQL_MAX_OUTPUT_ROWS defaults or their enforcement in PathExpand /
HashJoin / Expand / Distinct, or removal of a ctx.cancellation() poll
from any match operator's hot loopHashJoin residual (on) predicate or fold_optional's routing of a
correlated OPTIONAL-block predicate into it — a correlated predicate that
becomes a post-join Filter instead silently turns an OPTIONAL into an inner
join (drops rows that must be null-filled)HashJoin::execute's build-vs-probe construction order — both the deferred-probe
branch (build mutates ⇒ construct the probe only after the build drains) and the
pre-drain branch (probe mutates ⇒ drain the probe, executing its writes, before
constructing the build) are load-bearing for read-after-write correctness in
interleaved MATCH … SET/DELETE/INSERT … MATCH/OPTIONAL … programs (see Mutation
safety, above); fold_mandatory vs fold_optional decide which side mutatesExpr other
than a top-level Expr::Match)exec/operators/mutate.rs (a write that bypasses
legacy_compute, a dropped pipeline-breaker drain, a re-bound RETURN image that
is not permission-filtered, or a NODETACH path that skips the edge probe),
gql/lower/mutation.rs (a dropped rejection — label mutation, group/path or
unbound target, reserved id/in/out keys), and Expr::Match read_only()
in expr/expression.rs (a mutation plan that reports read-only would run in a
read transaction)Expr::Match compute-arm error or the sql::Expr conversion guardAny code path where system-internal execution (perms=false) processes
user-influenced expressions is a potential confused deputy. This includes:
All such paths must have an explicit, documented authority context. The perms=false
scope must be minimized and must not extend to evaluating arbitrary user inputs.
Flag when changes touch new_with_perms(false), authority context propagation in
event/view/cascade processing, or any path that evaluates user-defined expressions
during system-internal operations.
All error responses visible to clients must be sanitized. Review any change to error formatting, error types, or error propagation paths. Watch for:
Flag when changes touch error type definitions, Display/ResponseError impls,
error conversion (From/Into) chains, or any code path that formats errors for
client responses.
Every operation that processes user-controlled input must have bounded resource consumption. Watch for:
Flag when changes add new allocation paths for user-controlled data, remove or increase existing limits, add recursive processing, or introduce new external call sites without timeouts.
The import flag bypasses field validation, event processing, live query
notifications, and view computation. Any change involving:
import flagOPTION IMPORT authorization gate...must be reviewed for authorization correctness and flag lifetime scoping.
Flag when changes touch with_import, OPTION IMPORT handling, or any document
processing pipeline condition that checks the import flag.
Every operation must be verified against the principal's authorized namespace and database. Watch for:
Flag when changes touch key encoding/decoding, cache key construction, USE statement handling, HTTP header-based context resolution, or any data structure that is indexed or partitioned by namespace/database.