Fix anonymous auth tenant isolation for global apps#2922
Conversation
…apps The anonymous/fallback auth path in tryAppCredentialAuth() was falling back to request headers (x-inkeep-tenant-id / x-inkeep-project-id) for tenant and project resolution when app.tenantId was null (global apps like app_playground). This allowed attackers to inject arbitrary tenant/project IDs via headers, breaking tenant isolation. Now the anonymous path extracts tid/pid from the verified anonymous JWT payload (same source of truth as the authenticated path), with empty string fallback for tokens that predate this fix. Header values are never used. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
🦋 Changeset detectedLatest commit: 10e9f6c The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
TL;DR — Fixes a tenant isolation vulnerability where anonymous auth on global apps (e.g. Key changes
Summary | 2 files | 1 commit | base:
The anonymous auth path already verifies the JWT signature and extracts
|
There was a problem hiding this comment.
Solid security fix. The anonymous/fallback auth path in tryAppCredentialAuth() was using attacker-controlled x-inkeep-tenant-id/x-inkeep-project-id headers as fallback for global apps (where app.tenantId is null). The fix correctly extracts tid/pid from the verified JWT payload instead, with proper typeof guards. Pre-existing anonymous tokens for global apps carry null tid/pid claims (from appRecord.tenantId/appRecord.projectId), which safely resolve to undefined → '' rather than header-injected values.
Claude Opus | 𝕏
There was a problem hiding this comment.
PR Review Summary
(1) Total Issues | Risk: Low
💭 Consider (1) 💭
💭 1) runAuth-appCredentialAuth.test.ts Add regression test for anonymous auth header injection
Issue: The fix correctly closes the tenant injection vulnerability, but there's no explicit test demonstrating that header injection is blocked in the anonymous auth path for global apps.
Why: Security-critical fixes benefit from explicit regression tests that prove the attack vector is closed. The existing tests cover global app authenticated paths (lines 567-672) and tenant-scoped anonymous paths (lines 709-848), but not the specific scenario where header injection was previously possible: anonymous JWT on a global app.
Fix: Add a test case like:
it('global app anonymous auth should use JWT tid/pid, not request headers', async () => {
// 1. Create global app (tenantId: null, projectId: null)
// 2. Create anonymous JWT with tid: 'jwt-tenant', pid: 'jwt-project'
// 3. Send request with x-inkeep-tenant-id: 'attacker-tenant', x-inkeep-project-id: 'attacker-project'
// 4. Assert ctx.tenantId === 'jwt-tenant' (not 'attacker-tenant')
// 5. Assert ctx.projectId === 'jwt-project' (not 'attacker-project')
});Refs:
✅ APPROVE
Summary: This is a well-targeted security fix that correctly addresses the root cause of tenant isolation bypass in anonymous auth for global apps. The fix extracts tid/pid from the verified JWT payload instead of trusting attacker-controlled headers, mirroring the approach already used in the authenticated path. The implementation is minimal (8 lines), type-safe, and follows existing patterns. The only suggestion is adding a regression test to prevent future regressions.
Discarded (1)
| Location | Issue | Reason Discarded |
|---|---|---|
runAuth.ts:832-833 |
Empty string fallback may allow unscoped execution | For global apps, anonymous JWTs are minted with tid/pid from the app record. If the app record has null values, the JWT has null claims, resulting in empty strings. However: (1) global apps are a controlled scenario, (2) empty string is safer than attacker-controlled headers, (3) downstream validation exists. This is defense-in-depth territory rather than an immediate vulnerability. |
Reviewers (3)
| Reviewer | Returned | Main Findings | Consider | While You're Here | Inline Comments | Pending Recs | Discarded |
|---|---|---|---|---|---|---|---|
pr-review-security-iam |
2 | 0 | 1 | 0 | 0 | 0 | 1 |
pr-review-standards |
0 | 0 | 0 | 0 | 0 | 0 | 0 |
pr-review-precision |
0 | 0 | 0 | 0 | 0 | 0 | 0 |
| Total | 2 | 0 | 1 | 0 | 0 | 0 | 1 |
Ito Test Report ✅13 test cases ran. 13 passed. The unified run passed all 13 of 13 test cases with stable, deterministic authentication behavior in the local non-production environment, including successful anonymous session issuance for allowed origins and correct rejection for disallowed-origin bootstrap attempts. Across protected routes, the most important security checks all held: injected tenant/project headers did not widen access for global or tenant-scoped tokens, cross-app token replay and JWT payload tampering were rejected (401), cross-user conversation isolation remained intact, and both parallel stress and token-refresh interleaving showed no scope drift or data leakage. ✅ Passed (13)Commit: Tell us how we did: Give Ito Feedback |
…apps (#2922) The anonymous/fallback auth path in tryAppCredentialAuth() was falling back to request headers (x-inkeep-tenant-id / x-inkeep-project-id) for tenant and project resolution when app.tenantId was null (global apps like app_playground). This allowed attackers to inject arbitrary tenant/project IDs via headers, breaking tenant isolation. Now the anonymous path extracts tid/pid from the verified anonymous JWT payload (same source of truth as the authenticated path), with empty string fallback for tokens that predate this fix. Header values are never used. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Summary
tryAppCredentialAuth()resolvedtenantId/projectIdusingapp.tenantId || reqData.tenantId, which fell back to attacker-controlledx-inkeep-tenant-id/x-inkeep-project-idheaders for global apps (whereapp.tenantIdis null)tid/pidfrom the verified anonymous JWT payload instead of request headers, mirroring the authenticated path's approachtid/pidclaims get empty strings (safe default) rather than header valuesTest plan
app_playground) anonymous auth resolves tenant from JWT, not headersapp.tenantId/app.projectId(non-null, fallback never reached)pnpm typecheckpasses🤖 Generated with Claude Code