Skip to content

fix(auth): forbid board-key fallback on run-scoped agent requests#9733

Open
mvanhorn wants to merge 2 commits into
paperclipai:masterfrom
mvanhorn:fix/8020-forbid-board-key-fallback-run-scoped
Open

fix(auth): forbid board-key fallback on run-scoped agent requests#9733
mvanhorn wants to merge 2 commits into
paperclipai:masterfrom
mvanhorn:fix/8020-forbid-board-key-fallback-run-scoped

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Every control-plane request is authenticated by actorMiddleware (server/src/middleware/auth.ts), which resolves the caller as a board actor, an agent actor, or none
  • Agent-run requests carry the run-scoped header x-paperclip-run-id and must authenticate only as the agent — never as a board user
  • But when a bearer token was present, actorMiddleware resolved a board API key (source: "board_key") before it tried agent credentials, and it did so unconditionally, even when x-paperclip-run-id was set
  • So a run-scoped request that presented a board token was attributed board authority instead of being rejected — a privilege-boundary escalation (and board_key actors also skip the board mutation guard in board-mutation-guard.ts, so the escalation is not merely cosmetic)
  • This pull request skips the board API key branch whenever x-paperclip-run-id is present, so a run-scoped request can only authenticate with agent credentials (agent API key or local agent JWT)
  • The benefit is that agent execution can no longer silently escalate into board authority, while non-run-scoped board-key authentication stays byte-for-byte unchanged

Linked Issues or Issue Description

Fixes: #8020

Related / superseded PR: #8016 — a prior attempt on this issue that diverged into large, unrelated repository churn and was closed. This PR supersedes it by shipping only the focused auth-boundary change plus its regression tests, with no unrelated changes. A search of the open and closed PR list found no other PRs touching this area.

What Changed

  • server/src/middleware/auth.ts: guarded the board API key branch (the boardAuth.findBoardApiKeyByToken lookup that resolves a type: "board" / source: "board_key" actor) behind if (!runIdHeader). When x-paperclip-run-id is present the branch is skipped and control falls through to the existing agent API key lookup and local agent JWT path; a run-scoped request presenting only a board token resolves no actor and is rejected. No other logic was reordered.
  • server/src/__tests__/agent-auth-middleware.test.ts: added regression tests for the run-scoped bearer path — a board token with X-Paperclip-Run-Id set does not resolve a board actor, a board token without the header still resolves the board actor (source: "board_key") as before, and a valid agent JWT with the header resolves an agent actor and never a board one.

Verification

Run from the repo root:

# Type check the server package (passes)
pnpm --filter @paperclipai/server typecheck

# Run the new/updated auth middleware tests (11 passed)
pnpm exec vitest run server/src/__tests__/agent-auth-middleware.test.ts

# Broader related auth suites still green (34 passed)
pnpm exec vitest run \
  server/src/__tests__/auth-session-route.test.ts \
  server/src/__tests__/cli-auth-routes.test.ts \
  server/src/__tests__/board-mutation-guard.test.ts \
  server/src/__tests__/agent-auth-middleware.test.ts

The core regression test (does not resolve a board key as a board actor for a run-scoped request) fails on master and passes with this change.

Risks

  • Low risk. The only behavioral change is that a request presenting a board API key token together with x-paperclip-run-id is now rejected instead of being attributed board authority — which is the intended security fix.
  • Non-run-scoped board-key authentication is unchanged (verified by a dedicated regression test).
  • No database migration, no API surface change, no change to the session, agent-key, or agent-JWT code paths.

For core feature work, check ROADMAP.md first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See CONTRIBUTING.md.

Model Used

AI was used for assistance.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (e.g. docs/..., fix/...) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • I have updated relevant documentation to reflect my changes (none required — internal auth behavior, no user-facing docs affected)
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

A request carrying the run-scoped header x-paperclip-run-id could
authenticate as a board actor: actorMiddleware resolved a board API key
(source: board_key) before trying agent credentials, so a run-scoped
request presenting a board token was attributed board authority instead
of being rejected. This let agent execution silently escalate into board
authority.

Skip the board API key branch when x-paperclip-run-id is present, so a
run-scoped request can only authenticate with agent credentials (agent
API key or local agent JWT). Non-run-scoped board-key behavior is
unchanged. Adds regression tests covering board-token rejection on a
run-scoped request, unchanged non-run-scoped board resolution, and
valid agent-JWT resolution with the header set.
@commitperclip

commitperclip Bot commented Jul 17, 2026

Copy link
Copy Markdown

✅ All checks passing — ready for Greptile review and maintainer approval.

— commitperclip

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a privilege-boundary bug in actorMiddleware where a run-scoped request (carrying x-paperclip-run-id) that presented a board API key bearer token was incorrectly attributed board authority instead of being rejected.

  • server/src/middleware/auth.ts: Guards the boardAuth.findBoardApiKeyByToken lookup behind if (!runIdHeader). Run-scoped requests skip the board-key branch entirely and fall through to agent credential resolution; a board token on a run-scoped request resolves as type: "none" and is rejected.
  • server/src/__tests__/agent-auth-middleware.test.ts: Adds four regression tests covering board-token-with-run-id rejection, board-token-without-run-id preservation, and agent-JWT run-scoped resolution — plus a dedicated vi.mock of board-auth.js and a new createDb helper for the new describe block.

Confidence Score: 5/5

Safe to merge — the change is a one-line guard that only affects requests presenting both a board API key and x-paperclip-run-id, which is exactly the boundary that was broken; non-run-scoped board-key auth is byte-for-byte unchanged and regression-tested.

The fix is surgical: a single if (!runIdHeader) wrapper around an isolated branch. No logic was reordered; the session, agent-key, and agent-JWT paths are untouched. The new tests reproduce the pre-fix failure and confirm both the fixed path and the preserved non-run-scoped path. No migration, no API surface change, no risk of data loss.

No files require special attention — both changed files are straightforward and well-tested.

Important Files Changed

Filename Overview
server/src/middleware/auth.ts Wraps the board API key lookup in if (!runIdHeader) to prevent run-scoped bearer requests from escalating to board authority; the fix is minimal and correct
server/src/tests/agent-auth-middleware.test.ts Adds four targeted regression tests for the run-scoped bearer auth boundary: board token with run-id is rejected, board token without run-id is still resolved, and agent JWT with run-id resolves correctly

Reviews (2): Last reviewed commit: "chore: re-run PR quality gates" | Re-trigger Greptile

Comment on lines +235 to +246
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user?.name ?? null,
userEmail: access.user?.email ?? null,
companyIds: access.companyIds,
memberships: access.memberships,
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
runId: runIdHeader || undefined,
source: "board_key",
};

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.

P2 Inside the if (!runIdHeader) block, runIdHeader is guaranteed to be falsy, so runIdHeader || undefined always evaluates to undefined. The field is effectively dead code here — it can be removed or replaced with a literal undefined.

Suggested change
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user?.name ?? null,
userEmail: access.user?.email ?? null,
companyIds: access.companyIds,
memberships: access.memberships,
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
runId: runIdHeader || undefined,
source: "board_key",
};
req.actor = {
type: "board",
userId: boardKey.userId,
userName: access.user?.name ?? null,
userEmail: access.user?.email ?? null,
companyIds: access.companyIds,
memberships: access.memberships,
isInstanceAdmin: access.isInstanceAdmin,
keyId: boardKey.id,
source: "board_key",
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/middleware/auth.ts
Line: 235-246

Comment:
Inside the `if (!runIdHeader)` block, `runIdHeader` is guaranteed to be falsy, so `runIdHeader || undefined` always evaluates to `undefined`. The field is effectively dead code here — it can be removed or replaced with a literal `undefined`.

```suggestion
          req.actor = {
            type: "board",
            userId: boardKey.userId,
            userName: access.user?.name ?? null,
            userEmail: access.user?.email ?? null,
            companyIds: access.companyIds,
            memberships: access.memberships,
            isInstanceAdmin: access.isInstanceAdmin,
            keyId: boardKey.id,
            source: "board_key",
          };
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Trigger commitperclip re-review after adding the dedup-search
affirmation and public issue/PR links to the PR description.
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.

fix(auth): forbid board-key fallback on run-scoped agent requests

1 participant