Skip to content

Prevent header injection into anonymous session JWT tenant/project claims#2927

Merged
amikofalvy merged 2 commits intomainfrom
codex/fix-authorization-bypass-in-jwt-handling
Mar 31, 2026
Merged

Prevent header injection into anonymous session JWT tenant/project claims#2927
amikofalvy merged 2 commits intomainfrom
codex/fix-authorization-bypass-in-jwt-handling

Conversation

@robert-inkeep
Copy link
Copy Markdown
Collaborator

Motivation

  • Close an authorization bypass where unauthenticated callers could mint anonymous JWTs scoped to arbitrary tenant/project values by supplying x-inkeep-tenant-id / x-inkeep-project-id headers for global web_client apps.

Description

  • Stop reading x-inkeep-tenant-id and x-inkeep-project-id from the request when creating new anonymous session tokens so tid/pid claims are only populated from the app binding or a previously validated refresh token; change applied in agents-api/src/domains/run/routes/auth.ts.

Testing

  • Ran targeted test attempts: pnpm -C agents-api test --run src/__tests__/run/routes/auth.test.ts failed due to a duplicate --run option from the wrapper, and pnpm -C agents-api exec vitest --run src/__tests__/run/routes/auth.test.ts failed 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 preventing pnpm download.

Codex Task

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 31, 2026

⚠️ No Changeset found

Latest commit: 7f10d62

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a 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 4:42am
agents-manage-ui Ready Ready Preview, Comment Mar 31, 2026 4:42am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agents-docs Skipped Skipped Mar 31, 2026 4:42am

Request Review

@vercel vercel Bot temporarily deployed to Preview – agents-docs March 31, 2026 04:37 Inactive
@vercel vercel Bot temporarily deployed to Preview – agents-docs March 31, 2026 04:37 Inactive
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Mar 31, 2026

TL;DR — Closes an authorization bypass where unauthenticated callers could forge anonymous session JWTs scoped to arbitrary tenants/projects by injecting x-inkeep-tenant-id / x-inkeep-project-id request headers on global web_client apps.

Key changes

  • Remove header-based tenantId / projectId fallback in anonymous session token mintingtid and pid JWT claims are now sourced exclusively from the app binding or a validated refresh token, eliminating the injection vector through request headers.

Summary | 1 file | 2 commits | base: maincodex/fix-authorization-bypass-in-jwt-handling


Before: When creating a new anonymous session (non-refresh path), tenantId fell back to c.req.header('x-inkeep-tenant-id') and projectId fell back to c.req.header('x-inkeep-project-id') if the app record had no binding — allowing any caller to set these headers and mint a JWT scoped to an arbitrary tenant or project.
After: Both values resolve strictly to appRecord.tenantId / appRecord.projectId. If the app has no tenant or project binding, the claims are undefined rather than caller-controlled.

The refresh path is unchanged — it still prefers the previously validated refreshTenantId / refreshProjectId from the existing JWT, falling back to the app record.

agents-api/src/domains/run/routes/auth.ts

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

@amikofalvy amikofalvy enabled auto-merge March 31, 2026 04:38
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.

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/pid claims) 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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;
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.

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

Same issue as tenantId — global apps will get projectId: undefined here, producing unscoped anonymous JWTs.

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


🔴❗ 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:

  1. Creates a global web_client app with null tenantId/projectId (may require direct DB insertion or modifying createTestWebClientApp helper)
  2. Makes an anonymous-session request with malicious x-inkeep-tenant-id: 'attacker-tenant' and x-inkeep-project-id: 'attacker-project' headers
  3. Verifies the issued JWT's tid/pid claims 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.

@github-actions github-actions Bot deleted a comment from claude Bot Mar 31, 2026
@amikofalvy amikofalvy closed this Mar 31, 2026
auto-merge was automatically disabled March 31, 2026 05:09

Pull request was closed

@amikofalvy amikofalvy reopened this Mar 31, 2026
@amikofalvy amikofalvy enabled auto-merge March 31, 2026 05:09
@amikofalvy amikofalvy added this pull request to the merge queue Mar 31, 2026
Merged via the queue into main with commit f61fcd3 Mar 31, 2026
33 of 41 checks passed
@amikofalvy amikofalvy deleted the codex/fix-authorization-bypass-in-jwt-handling branch March 31, 2026 05:26
tim-inkeep pushed a commit that referenced this pull request Mar 31, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants