Rule Cascade
Reference

Security

Please do not open a public issue. Use GitHub's private vulnerability reporting on this repository (Security tab, Report a vulnerability).

Reporting a vulnerability

Please do not open a public issue. Use GitHub's private vulnerability reporting on this repository (Security tab, Report a vulnerability). Include the affected version, a minimal ruleset or request that shows the problem, and what an attacker gains.

Security model

Rule Cascade decides whether an operation is allowed. These are the properties it is built to keep.

PropertyHow
The server decidesA client evaluation is advice. Hosts must evaluate on the server for every state-changing operation
Server-only rules stay on the serverThe client manifest omits enforcement: server rules, all action rules, and every function, parameter and message only those rules use
Fail closedA ruleset that fails any load check is not served. A rule that cannot be evaluated produces a blocking finding. A malformed request is refused before anything is evaluated. A missing or failing custom operator fails the rule
No code execution from rulesetsRules are data. Expressions are JSON with a fixed operator list; nothing is compiled to code or passed to an interpreter of the host language
Bounded evaluationNo loops beyond the collection operators, no recursion, at most 32 nested function calls, patterns limited in syntax, length and repetition
Tamper evidenceEvery manifest and every result carries the checksum of the ruleset that produced it
Governed changeParents mark rules and parameters locked or tighten-only; violations fail the load

Trust boundaries

ArtefactTreat it asWhy
Client manifestPublicIt is served to browsers without authentication, by design. Everything in it can be read by any user: expressions, parameter values, messages
Server manifestInternalIt contains the server-only rules and parameters, for example sanction lists and risk thresholds
BundleInternal, and trusted inputIt contains the server manifest. An engine checks its format and nothing else: the compiler ran the checks. Whoever can replace a bundle can change what is allowed
Source rulesetsInternal, reviewed like codeA ruleset change is a change to what the business permits
Evaluation requestUntrusted, except actorEntity data and resolutions come from the caller. The actor comes from the host

What follows for authors is in the authoring guidelines, section 8: what must never be enforcement: client or both.

Expressions are data; custom operators are host code

A ruleset cannot introduce code. An expression is a literal, a variable reference, a call of one of the operators of the specification, or a call of a function that is itself such an expression. The engine reads no clock, file, network or environment while it evaluates.

A custom operator (x-...) is the exception, and it is not part of the ruleset: it is a function of the host application, registered by name. A ruleset can only call an operator the host registered, with JSON values as arguments. Review custom operators as you review any code that handles untrusted input: they receive values from the request. They must be pure functions.

What the host must do

  • Take actor from its own authentication. Never accept an actor or roles from the request body, a header the caller controls, or a tool call of a language model. Acceptance of risk is decided by actor.roles.
  • Evaluate on the server, with the server manifest, for every state-changing operation, whatever the client reported.
  • Validate payload shape first. Check types, formats, string lengths and list sizes with the API schema before evaluating rules. The rules assume it, and it bounds the cost of evaluation.
  • Protect the rule server. Set RULE_SERVER_TOKEN: evaluations, the server manifest, bundles and server-only rule details then require Authorization: Bearer <token>. The server refuses to start without a token unless RULE_SERVER_ALLOW_OPEN=1 is set, which leaves every endpoint open and should only be used on a private network for development. The server speaks plain HTTP: keep it on a private network and terminate TLS at your gateway or mesh.
  • Protect bundles. Build them in CI from the reviewed commit, store them immutably, restrict who can write to the store, and verify the digest or signature of the file when deploying it. Never serve a bundle to a browser.
  • Pass facts, not trust. ctx.now and every other member of ctx come from the host.
  • Render messages as text. A message can contain values from the request through its placeholders. Escape it like any other user-supplied text.
  • Review ruleset changes like code, with owners for each ruleset (.github/CODEOWNERS).

Denial of service

Evaluation cost depends on the ruleset, which the organisation controls, and on the request, which a caller controls. The engine bounds the first; the host has to bound the second.

RiskWhat the engine doesWhat the host does
Regular expressionsA pattern is a literal in the ruleset, never a value from the request. The portable subset (specification 4.4) has no back-references or look-around, limits counts and nested counts to 1000 and the pattern to 1000 code pointsSee below
Deep or endless evaluationFunctions cannot recurse (checked at load); a call nested more than 32 deep is an evaluation errorNothing
Large listsCollection operators visit every element; nested ones multiplyLimit list sizes in the API schema (maxItems) and the size of the request body
Large requestsThe rule server refuses a body over 1 MiB with 413 (maxBodyBytes when embedded). The libraries, the command and the WebAssembly module have no limit: a request is held in memoryLimit the size of what you pass to an engine
Slow requestsNo runtime has an evaluation time limitUse the request time limits of your server or gateway
NumbersNaN, infinities and numbers beyond the range of a double are refused; a result beyond it is an evaluation errorNothing

Patterns and backtracking

The limits of the portable subset bound what a pattern can ask for. They do not make every engine fast on every input. The JavaScript, Java and Python runtimes use the backtracking engine of their platform, and an ambiguous pattern, one that can match the same text in many ways, can take time that grows exponentially, or as a high power, with the length of the input when the match fails. Before 1.0.0-alpha.2 such patterns were inside the subset: measured then, the TypeScript engine on Node.js 22 needed about 12 seconds for ^(a+)+$ against 28 letters a followed by !. A group that can repeat may no longer contain an unbounded quantifier ((a+)+, (a*)*, (a+){2,} fail to load with PATTERN_NOT_PORTABLE), and matches refuses a subject longer than 10,000 code points with an evaluation error. Ambiguous patterns remain possible inside the subset: overlapping alternatives under repetition ((a|a)*, (a|ab)+) and large bounded counts (^(.*a){12}$, for which the Java engine needed more than a minute against 40 characters).

The Go runtime, and with it the rule-cascade command and the WebAssembly module, uses a linear-time engine: the same pattern against 100,000 letters is answered in milliseconds.

For rulesets that are evaluated by a backtracking engine, including every ruleset whose client manifest goes to a browser:

  • Bound the input. Give every string a maxLength in the API schema and enforce the schema before the rules. A pattern applied to 40 characters cannot be made slow by a caller in the way a pattern applied to 40,000 can.
  • Review patterns. Nested unbounded repetition ((a+)+, (.*)*) is now refused at load time; still reject repeated alternatives that overlap ((a|ab)+) and large counts over a wildcard ((.*a){n}). This is guideline 6.5 of the authoring guidelines.
  • Prefer anchored patterns with character classes and bounded counts: ^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$. With bounded counts there is little to backtrack over.
  • Evaluate hostile input with the Go engine when neither of the above is possible.

The Java runtime retries a match that exhausts the stack on a thread with a larger stack and fails the rule closed if that is not enough; see its README.

Supply chain

ComponentDependencies at run time
Java library (rule-cascade-core)None
Go library (rulecascade)The standard library only
rule-cascade command and WebAssembly moduleThe Go library and gopkg.in/yaml.v3, used to read YAML sources. Static binaries; packages/go/scripts/build-all.sh writes SHA256SUMS for them
TypeScript runtimedecimal.js; ajv only for the compiler level; react only for the hook
Rule serverThe TypeScript runtime, ajv, yaml
Python packagejsonschema
  • Bundles are build artefacts. A service that loads a bundle needs no YAML parser and no schema validator, which removes those from its attack surface. The bundle carries the checksum of the ruleset it was compiled from; the engine reports it in every result and does not verify the file. File integrity is checked where artefacts are stored and deployed.
  • Parents can be pinned. extends accepts a checksum, so a ruleset loads only against the exact parent that was reviewed (EXTENDS_CHECKSUM_MISMATCH).
  • The rule server reads only its rules directory. Entity schema references that point outside it are not followed.

Known limits

  • Backtracking engines can be slow on ambiguous patterns, as described above. The portable subset is checked for portability, not for worst-case running time.
  • The shared token of the rule server is a minimum. Use your platform's service identity where you have one.
  • The engine does not verify that a bundle is the one CI built, and does not apply acceptance.expiresAfter: the host stores acceptances and decides how long one stays valid.
  • A custom operator is as safe as its implementation. The engine checks that its result is a JSON value and nothing more.

Rendered from SECURITY.md in the repository. Edit it there.

On this page