Commit370407e
fix(security): close api-v2 workflow IDOR + private-repo runs leak
fix(security): close api-v2 workflow IDOR + private-repo runs leak
Two authorization holes in the actions API (confirmed in the 2026-07-09
re-audit of the 07-07 findings):
- POST /repos/:owner/:repo/actions/workflows/:filename/dispatches only
enforced requireScope("repo") — which proves the TOKEN has the scope,
not that the USER may write THIS repo. Anyone with a repo-scoped token
could dispatch workflows on any repo (run code, touch secrets/deploys).
Now gated on resolveRepoAccess >= write; private repos 404, public
non-writers 403.
- GET /repos/:owner/:repo/actions/workflows/:filename/runs had only soft
auth and no privacy check, so a private repo's run history was
world-readable. Now gated on read access; public repos still read
anonymously, private non-collaborators 404 (no existence leak).
Reuses resolveRepoAccess/satisfiesAccess from src/middleware/repo-access.ts.
api-v2 tests: 91 pass, 0 fail.
Note: the broader api-v2 write surface (issues/PR/label/topic/webhook
POSTs under requireScope("repo")) needs a per-endpoint authorization audit
— access level varies (e.g. any authed user may open an issue on a public
repo), so a blanket require-write would be wrong. Tracked as follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>1 file changed+34−0370407edcee51a12a9967d4306e455f65e5d4e9c
1 changed file+34−0
Modifiedsrc/routes/api-v2.ts+34−0View fileUnifiedSplit
@@ -60,6 +60,7 @@ import {
6060import { config } from "../lib/config";
6161import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
6262import type { ApiAuthEnv } from "../middleware/api-auth";
63import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
6364import {
6465 agentAuth,
6566 enforceAgentBranchNamespace,
@@ -2626,6 +2627,24 @@ apiv2.post(
26262627 if (!resolved) return c.json({ error: "Not Found" }, 404);
26272628
26282629 const repoRow = resolved.repo as any;
2630
2631 // IDOR guard: requireScope("repo") only proves the TOKEN carries the repo
2632 // scope — not that this user may write THIS repo. Dispatching a workflow is
2633 // a write action (runs code, can touch secrets/deploys), so gate on actual
2634 // write access. Private repos 404 for non-collaborators to avoid leaking
2635 // existence; public repos without write get a 403.
2636 const dispatchAccess = await resolveRepoAccess({
2637 repoId: repoRow.id,
2638 userId: user.id,
2639 isPublic: !repoRow.isPrivate,
2640 });
2641 if (!satisfiesAccess(dispatchAccess, "write")) {
2642 return c.json(
2643 { error: repoRow.isPrivate ? "Not Found" : "You do not have write access to this repository." },
2644 repoRow.isPrivate ? 404 : 403
2645 );
2646 }
2647
26292648 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
26302649 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
26312650
@@ -2697,6 +2716,21 @@ apiv2.get(
26972716 if (!resolved) return c.json({ error: "Not Found" }, 404);
26982717
26992718 const repoRow = resolved.repo as any;
2719
2720 // Privacy guard: this GET has only soft auth, so a private repo's workflow
2721 // runs would otherwise be world-readable. Public repos still read
2722 // anonymously (resolveRepoAccess → "read"); private repos require read
2723 // access, and non-collaborators get 404 (don't leak existence).
2724 const user = c.get("user");
2725 const runsAccess = await resolveRepoAccess({
2726 repoId: repoRow.id,
2727 userId: user?.id ?? null,
2728 isPublic: !repoRow.isPrivate,
2729 });
2730 if (!satisfiesAccess(runsAccess, "read")) {
2731 return c.json({ error: "Not Found" }, 404);
2732 }
2733
27002734 const workflowRow = await findWorkflowByFilename(repoRow.id, filename);
27012735 if (!workflowRow) return c.json({ error: "Not Found" }, 404);
27022736
27032737