Waiting for input...
Star SPIKE on GitHub

ADR-0033: Reserved System Namespaces in Policy Matching


  • Status: accepted
  • Date: 2026-07-25
  • Tags: Security, Access Control, Policy

Context and Problem Statement

SPIKE policy SPIFFEIDPattern and PathPattern fields are regular expressions compiled with regexp.Compile and evaluated with MatchString. MatchString reports whether the pattern matches anywhere inside the candidate string, so a pattern without ^ and $ anchors is a substring test. ADR-0025 established regex (rather than glob) matching and recommended anchoring.

A responsible disclosure in July 2026 reported the substring behavior as an over-grant vulnerability. Substring matching by an unanchored regular expression is not a defect; it is what MatchString is specified to do, and SPIKE documents that patterns are compiled and used “as is”. The report nonetheless surfaced a real problem underneath it.

SPIKE authorizes its own privileged operations through the same matcher. Policy management is gated by CheckPolicyAccess(peer, "spike/system/acl", [write]). A policy whose path pattern is spike, system, or acl therefore matches that gate by substring and authorizes the workload to create and modify any policy, including one granting itself super on every path. PathPattern: "spike" is a plausible thing for an operator to write when their own secrets live under a spike/ namespace. The same applies to spike/system/secret and spike/system/cipher/exec.

The identity half compounds it: a delegation written for spiffe://example\.org/admin without anchors also admits spiffe://example.org/admin-attacker.

So a single unremarkable-looking pattern could escalate to full control of SPIKE’s authorization state, and nothing in the codebase reserved those namespaces.

Decision Drivers

  • Policy patterns are regular expressions and must stay regular expressions; operators were told that and build on it.
  • Failures in authorization should be loud at authoring time, not silent at upgrade time.
  • The blast radius of an accident on spike/system/acl is total.
  • A fix must survive every path by which patterns reach the matcher, not just the one where policies are created.

Considered Options

  1. Anchor all patterns implicitly at compile time by wrapping them in ^(?:...)$.
  2. Do nothing in code; fix the documentation only.
  3. Reserve the system namespaces and require that a policy reaching one describes it deliberately.

Decision

Option 3, with the documentation work from option 2 alongside it.

A policy may grant access to spike/system/acl, spike/system/secret, or spike/system/cipher/exec only when it describes that path rather than merely containing it. The test is semantic: a pattern describes a path when its full-match form, ^(?:pattern)$, still matches the path.

PatternDescribes spike/system/acl?
^spike/system/acl$Yes
spike/system/aclYes
^spike/system/.*$Yes
.*Yes
aclNo, substring only
systemNo, substring only
spikeNo, substring only

The SPIFFE ID pattern of such a policy must additionally be anchored, or be an unambiguous catch-all such as .*.

Enforcement happens in two places: UpsertPolicy rejects a violating policy so the operator learns at authoring time, and CheckPolicyAccess independently declines to honor one, so policies stored before this rule existed cannot escalate either.

Every other path is unaffected and keeps ordinary substring semantics.

Rationale

Why not implicit anchoring (option 1)

The reporter proposed wrapping both patterns in UpsertPolicy. Testing that patch showed two problems.

It does not fix production. UpsertPolicy is not the only compile site; sqlite/persist/regex.go recompiles both patterns from the stored strings on every load, and CheckPolicyAccess reaches that path through ListPolicies on every access check. With the patch applied, the scenario still over-granted under SQLite while appearing fixed under the in-memory backend.

It also silently revokes legitimate access. A prefix policy written ^tenants/acme/ matches tenants/acme/db/creds today; wrapped it becomes ^(?:^tenants/acme/)$ and matches nothing usable. The repository shipped eight such patterns. Turning working policies into denials with no error naming the cause is an outage, and a confusing one.

Beyond the mechanics, implicit anchoring changes PathPattern from “a regular expression” into “a regular expression that is implicitly full-match”. That is a different dialect than the one documented, and it would silently reinterpret every policy already written.

Why not documentation alone (option 2)

The documentation genuinely was part of the problem and is being fixed. But spike/system/acl is the difference between “an operator granted a wider namespace than they meant to”, which is recoverable and theirs to own, and “an operator handed out administrative control”, which is neither. A namespace whose accidental match is a full compromise deserves a guard, not only a warning in prose.

Why a semantic test rather than a syntactic one

A syntactic “must start with ^ and end with $” rule was implemented first and rejected. .* and ^.*$ are the same regular expression, so accepting one and refusing the other polices spelling without changing what is granted, and it broke the deliberate wildcard policies already in the test suite. Asking whether the pattern’s full-match form still matches the reserved path draws the line where the actual distinction lies: between an author who described the path and one who merely landed on it.

Consequences

Positive

  • An accidental grant of administrative control is refused, with an error naming the reserved path and what is required to reach it.
  • Regular expression semantics are unchanged everywhere else, so nothing already working is silently reinterpreted.
  • The guard holds across both compile sites and both backends.
  • Deliberate delegation of policy management remains possible and now reads unambiguously in the policy itself.

Negative

  • A policy that previously reached a reserved path through an unanchored pattern stops working. This is intended, and it is visible: creation fails with a specific error, and the runtime refusal is logged.
  • Operators who deliberately wrote spike/system/acl unanchored must anchor it. The error message names the required form.

Implementation Notes

The guard lives in app/nexus/internal/state/base/reserved.go and is called from both UpsertPolicy and CheckPolicyAccess in app/nexus/internal/state/base/policy.go. The reserved paths come from auth.PathSystemPolicyAccess, auth.PathSystemSecretAccess, and auth.PathSystemCipherExecute in the SDK, so the list cannot drift from the constants the authorization predicates actually use.

Regression tests in reserved_test.go cover both backends, including a SQLite-backed case that proves the guard survives recompilation on load.

See spike policy for the operator-facing description.