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/aclis total. - A fix must survive every path by which patterns reach the matcher, not just the one where policies are created.
Considered Options
- Anchor all patterns implicitly at compile time by wrapping them in
^(?:...)$. - Do nothing in code; fix the documentation only.
- 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.
| Pattern | Describes spike/system/acl? |
|---|---|
^spike/system/acl$ | Yes |
spike/system/acl | Yes |
^spike/system/.*$ | Yes |
.* | Yes |
acl | No, substring only |
system | No, substring only |
spike | No, 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/aclunanchored 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.