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.
| Property | How |
|---|---|
| The server decides | A client evaluation is advice. Hosts must evaluate on the server for every state-changing operation |
| Server-only rules stay on the server | The client manifest omits enforcement: server rules, all action rules, and every function, parameter and message only those rules use |
| Fail closed | A 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 rulesets | Rules 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 evaluation | No loops beyond the collection operators, no recursion, at most 32 nested function calls, patterns limited in syntax, length and repetition |
| Tamper evidence | Every manifest and every result carries the checksum of the ruleset that produced it |
| Governed change | Parents mark rules and parameters locked or tighten-only; violations fail the load |
Trust boundaries
| Artefact | Treat it as | Why |
|---|---|---|
| Client manifest | Public | It is served to browsers without authentication, by design. Everything in it can be read by any user: expressions, parameter values, messages |
| Server manifest | Internal | It contains the server-only rules and parameters, for example sanction lists and risk thresholds |
| Bundle | Internal, and trusted input | It 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 rulesets | Internal, reviewed like code | A ruleset change is a change to what the business permits |
| Evaluation request | Untrusted, except actor | Entity 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
actorfrom 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 byactor.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 requireAuthorization: Bearer <token>. The server refuses to start without a token unlessRULE_SERVER_ALLOW_OPEN=1is 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.nowand every other member ofctxcome 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.
| Risk | What the engine does | What the host does |
|---|---|---|
| Regular expressions | A 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 points | See below |
| Deep or endless evaluation | Functions cannot recurse (checked at load); a call nested more than 32 deep is an evaluation error | Nothing |
| Large lists | Collection operators visit every element; nested ones multiply | Limit list sizes in the API schema (maxItems) and the size of the request body |
| Large requests | The 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 memory | Limit the size of what you pass to an engine |
| Slow requests | No runtime has an evaluation time limit | Use the request time limits of your server or gateway |
| Numbers | NaN, infinities and numbers beyond the range of a double are refused; a result beyond it is an evaluation error | Nothing |
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
maxLengthin 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
| Component | Dependencies at run time |
|---|---|
Java library (rule-cascade-core) | None |
Go library (rulecascade) | The standard library only |
rule-cascade command and WebAssembly module | The 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 runtime | decimal.js; ajv only for the compiler level; react only for the hook |
| Rule server | The TypeScript runtime, ajv, yaml |
| Python package | jsonschema |
- 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.
extendsaccepts achecksum, 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.