Skip to content

Fix anonymous auth tenant isolation for global apps#2922

Merged
amikofalvy merged 1 commit intomainfrom
fix/anon-auth-tenant-isolation
Mar 31, 2026
Merged

Fix anonymous auth tenant isolation for global apps#2922
amikofalvy merged 1 commit intomainfrom
fix/anon-auth-tenant-isolation

Conversation

@amikofalvy
Copy link
Copy Markdown
Collaborator

Summary

  • Security fix: The anonymous/fallback auth path in tryAppCredentialAuth() resolved tenantId/projectId using app.tenantId || reqData.tenantId, which fell back to attacker-controlled x-inkeep-tenant-id/x-inkeep-project-id headers for global apps (where app.tenantId is null)
  • Now extracts tid/pid from the verified anonymous JWT payload instead of request headers, mirroring the authenticated path's approach
  • Pre-existing anonymous tokens without tid/pid claims get empty strings (safe default) rather than header values

Test plan

  • Verify global app (e.g., app_playground) anonymous auth resolves tenant from JWT, not headers
  • Verify tenant-scoped apps still use app.tenantId/app.projectId (non-null, fallback never reached)
  • Verify pre-fix anonymous tokens (with null tid/pid) result in empty strings, not header injection
  • pnpm typecheck passes
  • Pre-commit hooks (biome + vitest) pass

🤖 Generated with Claude Code

…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-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 31, 2026

🦋 Changeset detected

Latest commit: 10e9f6c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@inkeep/agents-api Patch
@inkeep/agents-manage-ui Patch
@inkeep/agents-cli Patch
@inkeep/agents-core Patch
@inkeep/agents-email Patch
@inkeep/agents-mcp Patch
@inkeep/agents-sdk Patch
@inkeep/agents-work-apps Patch
@inkeep/ai-sdk-provider Patch
@inkeep/create-agents Patch

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

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 31, 2026 2:17am
agents-docs Ready Ready Preview, Comment Mar 31, 2026 2:17am
agents-manage-ui Ready Ready Preview, Comment Mar 31, 2026 2:17am

Request Review

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Mar 31, 2026

TL;DR — Fixes a tenant isolation vulnerability where anonymous auth on global apps (e.g. app_playground) fell back to attacker-controlled x-inkeep-tenant-id / x-inkeep-project-id request headers. Tenant and project IDs are now extracted from the verified anonymous JWT payload instead.

Key changes

  • Resolve tenantId/projectId from anonymous JWT claims instead of request headers — In tryAppCredentialAuth(), global apps (app.tenantId === null) previously fell through to reqData.tenantId/reqData.projectId which are parsed from request headers. The fix extracts tid/pid from the verified JWT payload, eliminating the header injection vector.

Summary | 2 files | 1 commit | base: mainfix/anon-auth-tenant-isolation

Before: When app.tenantId was null (global apps), tryAppCredentialAuth() fell back to reqData.tenantId / reqData.projectId — values sourced from request headers that any caller can set.
After: Fallback reads tid and pid claims from the verified anonymous JWT. Pre-existing tokens without these claims safely resolve to empty strings.

The anonymous auth path already verifies the JWT signature and extracts sub (end user ID) from the payload. This change extends that extraction to also pull tid and pid, then uses those values in the returned authResult instead of the untrusted header values. Tenant-scoped apps are unaffected because app.tenantId is non-null and the fallback is never reached.

agents-api/src/middleware/runAuth.ts

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

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

@github-actions github-actions Bot deleted a comment from claude Bot Mar 31, 2026
@amikofalvy amikofalvy enabled auto-merge March 31, 2026 02:19
@amikofalvy amikofalvy added this pull request to the merge queue Mar 31, 2026
Merged via the queue into main with commit cfcdc30 Mar 31, 2026
25 checks passed
@amikofalvy amikofalvy deleted the fix/anon-auth-tenant-isolation branch March 31, 2026 02:32
@itoqa
Copy link
Copy Markdown

itoqa Bot commented Mar 31, 2026

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)
Category Summary Screenshot
Adversarial Forged tid/pid JWT payload tampering was rejected (401), and replay mismatch checks were rejected with no data exposure. ADV-1
Adversarial Re-executed with seeded tenant conversation; global anonymous token remained unable to access tenant-scoped conversation data even with injected tenant/project headers. ADV-2
Adversarial Re-executed with two distinct anonymous users; USER_B could not access USER_A conversation by ID and did not see USER_A conversations in list results. ADV-3
Adversarial Disallowed origin was rejected for anonymous session bootstrap and no token was issued. ADV-4
Edge Baseline and injected-header global-token requests matched (400 Missing tenantId), and seeded tenant conversation data stayed inaccessible. EDGE-1
Edge Cross-app token/app combinations and tampered-token replay were consistently rejected (401). Environment required origin correction and tenant-app fixture creation before executing rejection checks. EDGE-2
Edge Parallel conflicting-header stress run completed with consistent authorized responses and no scope escalation signal. EDGE-4
Edge Token refresh interleaving preserved anonymous identity and maintained authorization under conflicting headers. EDGE-5
Logic Baseline and injected-header /run/api/chat calls under the same tenant token returned the same non-auth failure envelope, showing no header-based auth escalation regression. LOGIC-1
Happy-path Re-execution confirmed anonymous session issuance succeeds and protected-route behavior before/after token issuance is deterministic for the tested flow. ROUTE-1
Happy-path Re-verification showed global anonymous token requests stay constrained; injected tenant/project headers did not grant broader access. ROUTE-2
Happy-path Tenant-scoped app creation, anonymous session issuance, and baseline vs injected-header protected route checks all returned authorized, scope-consistent responses. ROUTE-3
Happy-path With a valid tenant-app token, baseline and attacker-header conversation requests remained scope-consistent with no access change. ROUTE-4

Commit: 10e9f6c

View Full Run


Tell us how we did: Give Ito Feedback

tim-inkeep pushed a commit that referenced this pull request Mar 31, 2026
…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]>
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