Skip to content

Commit 8246234

Browse files
matt-aitkenclaude
andcommitted
Address Devin review: 4 RBAC auth path fixes
#1 internal-packages/rbac/src/ability.ts (severity: 🔴 silent privilege escalation): buildJwtAbility was treating any scope starting with `admin:` as a universal wildcard. Pre-RBAC, the legacy checkAuthorization string-matched superScopes — `admin:sessions` only granted access to routes that explicitly listed it. After the JWT- ability split, the same scope was returning true for every action on every resource. Restrict the bypass to bare `admin` (no second segment); `admin:<type>` now flows through normal matching as action="admin" against resources of that type. Adds 2 regression tests in ability.test.ts. #2 apps/webapp/app/services/routeBuilders/apiBuilder.server.ts (status discard): authenticateRequestForApiBuilder hardcoded `status: 401` even though BearerAuthResult.status is `401 | 403`. A plugin returning 403 (e.g. suspended account, IP block) would silently get downgraded to 401 — semantically wrong (401 = "who are you?", 403 = "you're not allowed") and confusing for client retry logic. Plumb result.status through. #3 apps/webapp/app/services/routeBuilders/apiBuilder.server.ts (everyResource([]) vacuous truth): [].every() returns true, so everyResource([]) was passing auth for any token. Not exploitable today (Zod rejects empty bodies before auth), but the auth layer should never grant on empty input. Same defensive guard added to anyResource() for symmetry — only PERMISSIVE_ABILITY would have granted there, but the pattern shouldn't depend on the ability's choice. #4 internal-packages/rbac/src/fallback.ts (PREVIEW env regression): the fallback's authenticateBearer looked up environments by apiKey only, skipping the branch-aware resolution that findEnvironmentByApiKey does for PREVIEW envs. Self-hosters using preview/branch envs would either fail or operate against the parent env. Mirror the legacy path: read x-trigger-branch, include matching child env, and pivot the resolved env to the child (apiKey/orgMember/organization/project inherited from parent). sanitizeBranchName inlined here because internal-packages can't import webapp code; comment notes the duplication. All four flagged by Devin's PR review. Cloud plugin's buildJwtAbility gets the same #1 fix in a sibling commit on this PR's cloud branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5063f84 commit 8246234

4 files changed

Lines changed: 96 additions & 3 deletions

File tree

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,15 @@ async function authenticateRequestForApiBuilder(
5353
request: Request,
5454
{ allowJWT }: { allowJWT: boolean }
5555
): Promise<
56-
| { ok: false; status: 401; error: string }
56+
| { ok: false; status: 401 | 403; error: string }
5757
| { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility }
5858
> {
5959
const result = await rbac.authenticateBearer(request, { allowJWT });
6060
if (!result.ok) {
61-
return { ok: false, status: 401, error: result.error };
61+
// Plugin auth distinguishes 401 (who are you?) from 403 (you're not
62+
// allowed) — e.g. a suspended account or IP block returns 403.
63+
// Forwarding the status preserves that semantic for client retry logic.
64+
return { ok: false, status: result.status, error: result.error };
6265
}
6366

6467
// The fallback already filters deleted projects; this is belt-and-braces for
@@ -158,9 +161,19 @@ function checkAuth(
158161
resource: AuthResource
159162
): boolean {
160163
if (isEveryResource(resource)) {
164+
// Empty array via [].every() is vacuously true — would let any token
165+
// pass auth. Routes building everyResource() from request bodies
166+
// (e.g. batch trigger items) should never produce zero elements
167+
// because body validation rejects empty arrays first, but defending
168+
// here anyway since the auth layer should never grant on no input.
169+
if (resource.resources.length === 0) return false;
161170
return resource.resources.every((r) => ability.can(action, r));
162171
}
163172
if (isAnyResource(resource)) {
173+
// Symmetric guard: anyResource([]) is benign for most abilities
174+
// (.some() is false on empty), but PERMISSIVE_ABILITY would still
175+
// grant. Treat empty as "no resource declared" → deny.
176+
if (resource.resources.length === 0) return false;
164177
return ability.can(action, [...resource.resources]);
165178
}
166179
return ability.can(action, resource);

internal-packages/rbac/src/ability.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ describe("buildJwtAbility", () => {
6666
expect(ability.can("write", { type: "deployments" })).toBe(true);
6767
});
6868

69+
// Pre-RBAC, the legacy checkAuthorization string-matched superScopes;
70+
// a scope `admin:sessions` only granted access to routes that
71+
// explicitly listed it. After the JWT-ability split we must not let
72+
// `admin:<anything>` act as a universal wildcard — it should grant
73+
// only the `admin` action against resources of that type.
74+
it("admin:<type> is not a universal wildcard", () => {
75+
const ability = buildJwtAbility(["admin:sessions"]);
76+
expect(ability.can("read", { type: "runs" })).toBe(false);
77+
expect(ability.can("write", { type: "tasks" })).toBe(false);
78+
expect(ability.can("admin", { type: "runs" })).toBe(false);
79+
// But it does grant the admin action on its own type.
80+
expect(ability.can("admin", { type: "sessions" })).toBe(true);
81+
expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true);
82+
});
83+
84+
it("admin:<type>:<id> grants admin action only on that exact resource", () => {
85+
const ability = buildJwtAbility(["admin:sessions:ses_abc"]);
86+
expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true);
87+
expect(ability.can("admin", { type: "sessions", id: "ses_xyz" })).toBe(false);
88+
expect(ability.can("admin", { type: "runs" })).toBe(false);
89+
expect(ability.can("read", { type: "sessions", id: "ses_abc" })).toBe(false);
90+
});
91+
6992
it("never grants canSuper", () => {
7093
expect(buildJwtAbility(["admin"]).canSuper()).toBe(false);
7194
expect(buildJwtAbility(["read:all"]).canSuper()).toBe(false);

internal-packages/rbac/src/ability.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ export function buildJwtAbility(scopes: string[]): RbacAbility {
2727
const matches = (action: string, r: RbacResource): boolean =>
2828
scopes.some((scope) => {
2929
const [scopeAction, scopeType, scopeId] = scope.split(":");
30-
if (scopeAction === "admin") return true;
30+
// Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
31+
// it falls through to normal matching as action="admin" against
32+
// resources of that type. Pre-RBAC, the legacy checkAuthorization
33+
// string-matched superScopes; `admin:sessions` only granted access
34+
// to routes that explicitly listed it. Treating `admin:<anything>`
35+
// as universal here would silently broaden any such tokens.
36+
if (scopeAction === "admin" && !scopeType) return true;
3137
if (scopeAction !== action && scopeAction !== "*") return false;
3238
if (scopeType === "all") return true;
3339
if (scopeType !== r.type) return false;

internal-packages/rbac/src/fallback.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,19 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
8080
};
8181
}
8282

83+
// PREVIEW envs are parents — operating "on a branch" means routing
84+
// to a child env keyed by branchName. The customer authenticates
85+
// with the parent's apiKey + an `x-trigger-branch` header. Mirror
86+
// findEnvironmentByApiKey: include the matching child env so the
87+
// pivot below can adopt its identity.
88+
const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch"));
8389
const include = {
8490
project: true,
8591
organization: true,
8692
orgMember: { select: { userId: true } },
93+
childEnvironments: branchName
94+
? { where: { branchName, archivedAt: null } }
95+
: undefined,
8796
} as const;
8897
let env = await this.prisma.runtimeEnvironment.findFirst({
8998
where: { apiKey: rawToken },
@@ -108,6 +117,32 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
108117
return { ok: false, status: 401, error: "Invalid API key" };
109118
}
110119

120+
// PREVIEW env requires a branch header; pivot to the child env so
121+
// downstream code operates on the branch (its own id, but the
122+
// parent's apiKey/orgMember/organization/project — exactly what
123+
// findEnvironmentByApiKey does for the legacy auth path).
124+
if (env.type === "PREVIEW") {
125+
if (!branchName) {
126+
return {
127+
ok: false,
128+
status: 401,
129+
error: "x-trigger-branch header required for preview env",
130+
};
131+
}
132+
const child = env.childEnvironments?.[0];
133+
if (!child) {
134+
return { ok: false, status: 401, error: "No matching branch env" };
135+
}
136+
env = {
137+
...child,
138+
apiKey: env.apiKey,
139+
orgMember: env.orgMember,
140+
organization: env.organization,
141+
project: env.project,
142+
childEnvironments: [],
143+
};
144+
}
145+
111146
const subject: RbacSubject = {
112147
type: "user",
113148
userId: env.orgMember?.userId ?? "",
@@ -276,6 +311,22 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
276311
}
277312
}
278313

314+
// Mirror of `apps/webapp/app/v3/gitBranch.ts#sanitizeBranchName`.
315+
// Inlined here because internal-packages can't import webapp code; the
316+
// two should stay in sync. Strips common refs/* prefixes and rejects
317+
// unknown ref formats (returns undefined → no branch override).
318+
function sanitizeBranchName(ref: string | null): string | undefined {
319+
if (!ref) return undefined;
320+
if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length);
321+
if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length);
322+
if (ref.startsWith("refs/tags/")) return ref.substring("refs/tags/".length);
323+
if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length);
324+
if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length);
325+
if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length);
326+
if (ref.startsWith("refs/")) return undefined;
327+
return ref;
328+
}
329+
279330
function isPublicJWT(token: string): boolean {
280331
const parts = token.split(".");
281332
if (parts.length !== 3) return false;

0 commit comments

Comments
 (0)