Skip to content

fix(gates): deny default-tier wildcard bypass of more-specific gated tiers#60

Open
ucekmez wants to merge 2 commits into
fix/auth-adapters-fail-closedfrom
fix/gates-default-tier-specificity-bypass
Open

fix(gates): deny default-tier wildcard bypass of more-specific gated tiers#60
ucekmez wants to merge 2 commits into
fix/auth-adapters-fail-closedfrom
fix/gates-default-tier-specificity-bypass

Conversation

@ucekmez

@ucekmez ucekmez commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes a default-tier wildcard specificity bypass in the access resolver of
@eep-dev/gates (TypeScript) and eep-gates (Python) — Critical task C3
from the EEP protocol audit.

A gate config commonly publishes a broad scope on the no-requirements default
tier and carves out a narrower, gated path:

{
  "default_tier": "public",
  "tiers": {
    "public": { "requirements": [], "access": ["content.*", "profile.*"] },
    "paid":   { "requirements": [{ "type": "trust", "min_score": 20 }], "access": ["content.premium.*"] }
  }
}

Because content.* covers content.premium.X, resolveAccess / resolve_access
granted the gated resource through the default tier's broad match even when the
gated tier's requirements were unmet
— a request with no proofs could read
premium content. This is the same class of bypass already patched in the more.md
Go/TS data plane; this PR brings the fix upstream into the reference library.

What changed

  • The resolver now applies a specificity override: when the winning tier is
    the default tier and a gated (requirements-bearing) tier targets the same
    resource with an equal-or-more-specific access pattern, the default grant
    is suppressed and the request is denied with the gated tier's unmet
    requirements (a 402, not a leak).
  • Behaviour preserved everywhere else:
    • a strictly more specific default pattern keeps its grant;
    • resources no gated tier touches stay public;
    • resources the default tier does not cover at all fail closed;
    • resource-less resolution and tiers satisfied by valid proofs are unchanged.
  • Specificity ranks an exact pattern (a.b.c) > scope wildcard (a.b.*, longer
    prefix wins) > universal wildcard (*).
  • New exported helpers: patternSpecificity, bestSpecificityFor,
    defaultTierOverriddenByGatedTier (TS) and pattern_specificity,
    best_specificity_for, default_tier_overridden_by_gated_tier (Python).

Why >= (deny on a specificity tie)

The equal-or-more-specific tie-breaking matches the deployed more.md Go
(internal/eep/gates/http402.go) and TypeScript (api/src/lib/services/gates.ts)
data-plane implementations, so re-vendoring this library does not change
enforcement. It is also the fail-closed choice when a publisher lists the same
pattern on both the public and a gated tier.

Testing

  • TypeScript: vitest run511 passed (new specificity-override.test.ts
    with 24 cases + resolution-parity.test.ts); tsc --noEmit clean; 100% of the
    changed lines covered (resource-matcher.ts 100%; the added access-resolver.ts
    lines verified via the coverage JSON).
  • Python: pytest77 passed (new TestPatternSpecificity,
    TestBestSpecificityFor, TestDefaultTierOverriddenByGatedTier,
    TestSpecificityOverrideResolution + test_resolution_parity.py);
    resource_matcher.py 100%, added access_resolver.py lines 100%.
  • Cross-language parity: a shared fixture
    (tests/parity/gate-resolution-specificity-fixtures.json) is executed by both
    suites so the two resolvers cannot drift.
  • Runtime smoke confirms identical output across languages (premium denied with
    unmet=[trust]; content.blog.* and profile.* stay public).

Pre-existing coverage gaps in unrelated parts of the resolver
(requirementToUnmet hint switch, combined-requirement handling) are left as-is
to keep this PR surgical; they are not touched by this change.

Docs

  • CHANGELOG.md (Security entry), both gates READMEs (TS + Python), and a
    non-normative tier-matching-precedence note in docs/current/SPECIFICATION.md.

Stacked on C2 (fix/auth-adapters-fail-closed); please merge C2 first. The
diff against that base is only this commit.

ucekmez added 2 commits June 10, 2026 23:48
…tiers

`@eep-dev/gates` (TypeScript) and `eep-gates` (Python) let a no-requirements
default tier silently bypass a gated tier through a broader wildcard.

A gate config commonly publishes a broad scope on the default tier and carves
out a narrower, gated path, for example:

    default "public": access = ["content.*", "profile.*"]
    "paid":            access = ["content.premium.*"], requirements = [trust>=20]

Because `content.*` covers `content.premium.X`, `resolveAccess` / `resolve_access`
granted the gated resource via the default tier's broad match even when the
gated tier's requirements were unmet, so a request with no proofs read premium
content (the same class of bypass already patched in the more.md data plane).

The resolver now applies a specificity override: when the winning tier is the
default tier and a gated (requirements-bearing) tier targets the same resource
with an equal-or-more-specific access pattern, the default grant is suppressed
and the request is denied with the gated tier's unmet requirements (a 402
instead of a leak). Behaviour is preserved everywhere else:

- a strictly more specific default pattern keeps its grant (owners can still
  open a narrower path explicitly);
- resources no gated tier touches stay public;
- resources the default tier does not cover at all fail closed;
- resource-less resolution and tiers satisfied by valid proofs are unchanged.

Specificity ranks an exact pattern (`a.b.c`) above any scope wildcard (`a.b.*`,
longer prefix wins) above the universal wildcard (`*`). The equal-or-more-specific
(`>=`) tie-breaking matches the deployed more.md Go and TypeScript data-plane
implementations, so re-vendoring the library does not change enforcement.

New exported helpers: `patternSpecificity`, `bestSpecificityFor`,
`defaultTierOverriddenByGatedTier` (TypeScript) and `pattern_specificity`,
`best_specificity_for`, `default_tier_overridden_by_gated_tier` (Python).

Tests: dedicated unit suites in both languages (100% of the changed lines),
plus a shared cross-language fixture
(`tests/parity/gate-resolution-specificity-fixtures.json`) executed by both the
TypeScript and Python suites so the two resolvers cannot drift. The
SPECIFICATION gains a non-normative tier-matching-precedence note; both gates
READMEs and the CHANGELOG document the new behaviour.

Surfaced by the EEP protocol audit.

Signed-off-by: Ugur Cekmez <ucekmez@gmail.com>
The tier-matching-precedence note added in the previous commit was labelled
"Informative (non-normative)" but used RFC 2119 keywords (SHOULD / MUST NOT),
which is contradictory in an informative note. Reword it as plain descriptive
guidance (what the reference implementations do, with an encouragement for
custom resolvers) so it carries no normative weight.

Signed-off-by: Ugur Cekmez <ucekmez@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant