Prevent header injection into anonymous session JWT tenant/project claims#2927
Conversation
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
|
TL;DR — Closes an authorization bypass where unauthenticated callers could forge anonymous session JWTs scoped to arbitrary tenants/projects by injecting Key changes
Summary | 1 file | 2 commits | base:
The refresh path is unchanged — it still prefers the previously validated
|
There was a problem hiding this comment.
Severity: Critical — this fix is directionally correct but incomplete, and will break global app anonymous sessions.
The vulnerability is real: commit 704026c introduced a header injection vector where unauthenticated callers can mint anonymous JWTs with arbitrary tid/pid by supplying x-inkeep-tenant-id / x-inkeep-project-id headers. This PR correctly removes that fallback.
However, the removal also disables the legitimate functionality 704026c was trying to enable — global apps (where appRecord.tenantId is null) need tid/pid populated from somewhere. After this PR, global app anonymous JWTs will have tid: null, pid: null, and when used downstream in runAuth.ts:832-833 the execution context will resolve to tenantId: '', projectId: '' — effectively breaking scope resolution for global app anonymous sessions.
The proper fix requires a different approach for populating tid/pid on global app anonymous sessions that doesn't trust raw request headers. For example:
- Require the caller to obtain an anonymous session through the authenticated path (asymmetric JWT with
tid/pidclaims) rather than the anonymous path for global apps. - Reject anonymous session creation entirely for global apps (
!appRecord.tenantId) and require all global app users to authenticate with a signed JWT containing scope claims.
As-is, this PR trades a security vulnerability for a functional regression on global apps. Both need to be addressed together.
Claude Opus | 𝕏
| const tenantId = isRefresh | ||
| ? (refreshTenantId ?? appRecord.tenantId) | ||
| : appRecord.tenantId || c.req.header('x-inkeep-tenant-id') || null; | ||
| const tenantId = isRefresh ? (refreshTenantId ?? appRecord.tenantId) : appRecord.tenantId; |
There was a problem hiding this comment.
For tenant-scoped apps (appRecord.tenantId is set), this is correct and sufficient.
For global apps (appRecord.tenantId is null/undefined), this line now produces tenantId = undefined. That value flows into the JWT tid claim, and later in runAuth.ts:832 the anonymous auth path resolves tenantId: app.tenantId || anonTid || '' — both app.tenantId and anonTid will be falsy, so the execution context gets tenantId: ''.
This breaks the global app anonymous session use case that 704026c was designed to support. The right fix is likely to reject anonymous session creation for global apps (require authenticated JWT path instead) rather than silently producing unscoped tokens.
| const projectId = isRefresh | ||
| ? (refreshProjectId ?? appRecord.projectId) | ||
| : appRecord.projectId || c.req.header('x-inkeep-project-id') || null; | ||
| : appRecord.projectId; |
There was a problem hiding this comment.
Same issue as tenantId — global apps will get projectId: undefined here, producing unscoped anonymous JWTs.
There was a problem hiding this comment.
PR Review Summary
(1) Total Issues | Risk: Low
🔴❗ Critical (0) ❗🔴
None.
🟠⚠️ Major (1) 🟠⚠️
🟠 1) auth.test.ts Missing regression test for header injection vulnerability
Issue: No test validates that x-inkeep-tenant-id / x-inkeep-project-id headers cannot inject arbitrary claims into anonymous session JWTs. The fix removes the header fallback for global apps (where appRecord.tenantId/projectId is null), but no test guards this security boundary.
Why: Without a regression test, a future developer could reintroduce the header fallback pattern (e.g., for "convenience" or when refactoring), re-enabling the authorization bypass. This is a security vulnerability that could enable tenant impersonation and cross-tenant data access.
Fix: Add a test that:
- Creates a global
web_clientapp with nulltenantId/projectId(may require direct DB insertion or modifyingcreateTestWebClientApphelper) - Makes an anonymous-session request with malicious
x-inkeep-tenant-id: 'attacker-tenant'andx-inkeep-project-id: 'attacker-project'headers - Verifies the issued JWT's
tid/pidclaims are null (matching the app) and NOT the attacker-supplied values
it('should not allow request headers to inject tenant/project claims for global apps', async () => {
// Setup: Create global app with null tenantId/projectId
const globalApp = await createTestWebClientApp({
tenantId: null,
projectId: null,
allowedDomains: ['https://example.com']
});
const response = await app.request(
`/auth/apps/${globalApp.id}/anonymous-session`,
{
method: 'POST',
headers: {
Origin: 'https://example.com',
'x-inkeep-tenant-id': 'attacker-tenant',
'x-inkeep-project-id': 'attacker-project',
},
}
);
expect(response.status).toBe(200);
const { token } = await response.json();
const decoded = decodeJwt(token);
// Critical assertion: headers must NOT influence JWT claims
expect(decoded.tid).toBeNull(); // NOT 'attacker-tenant'
expect(decoded.pid).toBeNull(); // NOT 'attacker-project'
});Refs:
🟡 Minor (0) 🟡
None.
💭 Consider (1) 💭
💭 1) auth.test.ts Additional defense-in-depth test for bound apps
Issue: The existing tests verify that tid/pid claims match app record values, but none explicitly test that supplying conflicting headers does not override them.
Why: Adds defense-in-depth to prove headers are completely ignored even when apps have bound tenant/project values.
Fix: Add a test that creates a normal web_client app bound to tenant-A/project-A, makes a request with conflicting headers, and verifies the JWT uses the app's bound values, not the headers.
💡 APPROVE WITH SUGGESTIONS
Summary: This is a well-targeted security fix that correctly closes an authorization bypass vulnerability. The code change is minimal, precise, and addresses the root cause. The fix removes the ability for unauthenticated callers to inject arbitrary tenant/project claims into anonymous session JWTs via HTTP headers.
Security assessment: ✅ The fix is sound. JWT tid/pid claims now only come from trusted sources: the app record binding or a previously-validated refresh token's claims. The vulnerability analysis confirms this is part of a coordinated fix series (PRs #2922, #2924, and this one).
The one gap is test coverage: Adding a regression test for the header injection attack vector would prevent reintroduction of this vulnerability. This is the main recommendation before merging.
Discarded (0)
No findings were discarded.
Reviewers (4)
| Reviewer | Returned | Main Findings | Consider | While You're Here | Inline Comments | Pending Recs | Discarded |
|---|---|---|---|---|---|---|---|
pr-review-security-iam |
0 | 0 | 0 | 0 | 0 | 0 | 0 |
pr-review-standards |
0 | 0 | 0 | 0 | 0 | 0 | 0 |
pr-review-precision |
0 | 0 | 0 | 0 | 0 | 0 | 0 |
pr-review-tests |
2 | 1 | 1 | 0 | 0 | 0 | 0 |
| Total | 2 | 1 | 1 | 0 | 0 | 0 | 0 |
Note: Security, standards, and precision reviewers all confirmed the fix is correct with no issues. Test reviewer identified missing regression tests.
Pull request was closed
…aims (#2927) * Fix anonymous session tenant/project claim injection * style: auto-format with biome --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Motivation
x-inkeep-tenant-id/x-inkeep-project-idheaders for globalweb_clientapps.Description
x-inkeep-tenant-idandx-inkeep-project-idfrom the request when creating new anonymous session tokens sotid/pidclaims are only populated from the app binding or a previously validated refresh token; change applied inagents-api/src/domains/run/routes/auth.ts.Testing
pnpm -C agents-api test --run src/__tests__/run/routes/auth.test.tsfailed due to a duplicate--runoption from the wrapper, andpnpm -C agents-api exec vitest --run src/__tests__/run/routes/auth.test.tsfailed due to workspace package resolution for@inkeep/agents-core, so automated unit tests could not complete in this environment; pre-commit checks also failed due to network/corepack issues preventingpnpmdownload.Codex Task