Skip to content

fix(authz): make Cedar URI entity IDs collision-free - #6239

Merged
jhrozek merged 2 commits into
stacklok:mainfrom
SashaMIT:fix/cedar-uri-entity-id-collision
Aug 13, 2026
Merged

fix(authz): make Cedar URI entity IDs collision-free#6239
jhrozek merged 2 commits into
stacklok:mainfrom
SashaMIT:fix/cedar-uri-entity-id-collision

Conversation

@SashaMIT

@SashaMIT SashaMIT commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

A permission granted to one resource URI can silently apply to a different, colliding URI.

Problem

authorizeResourceRead turned resource URIs into Cedar entity IDs with a lossy sanitizer that rewrote :, /, \, ?, &, =, #, space, and . to _. The mapping is many-to-one, so distinct URIs collide onto one entity ID:

  • file:///etc/passwd and file://_etc/passwd both became Resource::"file____etc_passwd"
  • mcp://srv/config:admin and mcp://srv/config/admin both became Resource::"mcp___srv_config_admin"

A policy granting Resource::"file____etc_passwd" therefore authorized reads of every URI in the collision class. The confused-deputy condition: an MCP server that serves both a permitted URI and a colliding sensitive URI (or one that lets users create resource URIs) gets the grant applied to a resource the policy author never named.

Severity framing, honestly stated: exploitation needs a policy author who grants by entity ID, plus a colliding pair where one side is permitted and the other is sensitive and server-distinguished. Attribute-based policies (resource.name == ..., resource.uri == ...) match on the exact URI and are unaffected.

Fix

Entities are built programmatically with cedar.NewEntityUID, which accepts any string, so no character rewriting is needed at all. The exact URI is now the entity ID and the sanitizer is removed. Policy authors can name the real URI (for example Resource::"file:///etc/passwd") instead of computing a mangled form.

Note for existing policies: a policy that references a sanitized ID (only possible for URIs containing the rewritten characters) must be updated to name the exact URI. Policies using plain IDs or attribute matching are unchanged.

Tests

Replaced TestSanitizeURIForCedar with TestAuthorizeResourceReadEntityIDsCollisionFree: for both collision pairs above, a grant on the exact URI authorizes that URI and denies the formerly colliding URI. The pkg/authz/... and pkg/vmcp/core suites pass.

Made with Cursor

@github-actions github-actions Bot added the size/XS Extra small PR: < 100 lines changed label Aug 10, 2026
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.82%. Comparing base (c86cb8a) to head (325d907).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6239      +/-   ##
==========================================
- Coverage   72.83%   72.82%   -0.01%     
==========================================
  Files         743      743              
  Lines       77649    77635      -14     
==========================================
- Hits        56556    56541      -15     
- Misses      17125    17129       +4     
+ Partials     3968     3965       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix looks right to me. I traced both sides: the request UID comes from cedar.NewEntityUID (core.go:513-516) and the entity-map key comes from the same parseCedarEntityID split (entity.go:152,187), so the two stay in sync and arbitrary URI characters are safe. parseCedarEntityID uses SplitN(..., "::", 2), so a URI containing "::" still parses with the full URI as the ID. And since every resource-read path (middleware.go:49-53, response_filter.go:781, vmcp/core/admission.go:199,218) funnels through authorizeResourceRead, this fixes it in one place rather than per caller.

Two comments below, both about documenting the new form rather than the code itself. Non-blocking.

Comment thread pkg/authz/authorizers/cedar/core.go
Comment thread pkg/authz/authorizers/cedar/core.go
authorizeResourceRead sanitized resource URIs into Cedar entity IDs
by rewriting reserved characters to "_". The mapping is many-to-one:
"file:///etc/passwd" and "file://_etc/passwd" both became
"file____etc_passwd", and "mcp://srv/config:admin" and
"mcp://srv/config/admin" both became "mcp___srv_config_admin". A
policy grant on one entity ID therefore authorized every URI in the
collision class.

Entities are built programmatically via cedar.NewEntityUID, which
accepts any string, so no rewriting is needed. Use the exact URI as
the entity ID and drop the sanitizer. Attribute-based policies
(resource.name / resource.uri) already matched on the exact URI and
are unaffected. Policies that referenced sanitized IDs must be
updated to name the exact URI.

Added TestAuthorizeResourceReadEntityIDsCollisionFree: a grant on an
exact URI authorizes that URI and denies the URI that used to
collide with it.

Signed-off-by: Sasha Mitchell <sash.t.mitchell@gmail.com>
Address review: docs/authz.md now shows Resource::"file:///..." form,
notes that policy IDs are exact URIs, and mentions Cedar string escaping
for " and \. Soften the authorizeResourceRead comment accordingly.

Signed-off-by: Sasha Mitchell <sash.t.mitchell@gmail.com>
@SashaMIT

Copy link
Copy Markdown
Contributor Author

Thanks @jhrozek. Added a docs/authz.md note that read_resource entity IDs are the exact URI (with a Resource::"file:///..." example), plus the Cedar string-literal escape caveat for " / \. Softened the code comment to match. Happy to tweak the wording if you want it shorter.

@SashaMIT
SashaMIT force-pushed the fix/cedar-uri-entity-id-collision branch from b16b4a2 to 325d907 Compare August 12, 2026 02:10

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Housekeeping first: my two comments above were against a stale revision — I had an old local commit checked out and didn't re-check the remote head before posting. 325d907 already addressed both, and did it better than I asked. Sorry for the noise; ignore them.

On the fix itself, I traced both sides of the entity ID. The request UID comes from cedar.NewEntityUID (core.go:513-516) and the entity-map key comes from the same parseCedarEntityID split (entity.go:152,187), so the two agree for arbitrary URI characters. parseCedarEntityID uses SplitN(..., "::", 2), so a URI containing "::" still parses with the full URI as the ID. And since every resource-read path — middleware.go:49-53, response_filter.go:781, vmcp/core/admission.go:199,218 — funnels through authorizeResourceRead, this is fixed once rather than per caller.

One thing worth recording: the sanitization was never documented, so writing a policy against a mangled ID required reading the entity ID off a debug log line and copying it back. The likelier experience is pkg/vmcp/core/core_calls_test.go:718, where the policy is written as Resource::"file:///ok" — the intuitive exact-URI form, which matched nothing under the old code, and nobody noticed because the test only asserts the deny half. That permit is live now. So the migration note in the description is appropriately hedged; I doubt there is anyone to migrate.

The docs paragraph is the part I like most — the escaping example resolves to a single backslash in Cedar source, which is right, and it saves the next person the dead end above.

Nit, not blocking: core_test.go:3379 interpolates the URI into policy source with fmt.Sprintf. Fine for both current rows, but a row containing a quote or backslash would fail at NewCedarAuthorizer rather than at the assertion, pointing the reader at the wrong file. A comment naming the constraint would keep the table safe to extend.

@jhrozek
jhrozek merged commit bacac58 into stacklok:main Aug 13, 2026
44 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 14, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XS Extra small PR: < 100 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants