Skip to content

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

Open
SashaMIT wants to merge 2 commits into
stacklok:mainfrom
SashaMIT:fix/cedar-uri-entity-id-collision
Open

fix(authz): make Cedar URI entity IDs collision-free#6239
SashaMIT wants to merge 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.

// which accepts any string, so no character rewriting is needed. A lossy
// mapping would let distinct URIs collide onto one entity ID, and a
// policy grant on one URI would then silently cover every colliding URI.
resource := fmt.Sprintf("Resource::%s", resourceURI)

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.

Could we get a line in docs/authz.md to go with this? The comment explains the why nicely, but nothing user-facing says what a resource policy should now look like. The Resource examples in docs/authz.md are all Resource::"data" — a name with no special characters — so someone writing a per-URI policy has no way to know the right form is Resource::"file:///etc/passwd".

Worth doing even though I doubt anyone was relying on the old sanitized IDs. The sanitization was never documented, so to write Resource::"file____etc_passwd" you'd have had to turn on debug logging, read the entity ID off the cedar authorization check line, and copy it back into your policy. The more likely experience is the one in pkg/vmcp/core/core_calls_test.go:718, where the policy is written as Resource::"file:///ok" — the intuitive exact-URI form, which silently matched nothing under the old code. That test still passed because it only asserts the deny half. So the docs line is mostly for the next person who tries the obvious thing.

sanitizedURI := sanitizeURIForCedar(resourceURI)
resource := fmt.Sprintf("Resource::%s", sanitizedURI)
// Resource is the resource being accessed. Use the exact URI as the
// entity ID: entities are built programmatically via cedar.NewEntityUID,

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.

Small caveat on "accepts any string": that's true of the request side, but a policy still has to name the entity in Cedar source, where the ID is a double-quoted string literal. For almost every URI that's invisible — : / ? & = # . are all ordinary inside a Cedar string — but " and \ need escaping:

permit(principal, action == Action::"read_resource",
       resource == Resource::"file://C:\\share\\data");

The old sanitizer folded \ to _, so this is newly visible to policy authors. Not a reason to change anything here (a lossy mapping was strictly worse), but the same docs line from the other comment could mention it.

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
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