Skip to content

Commit 188fa43

Browse files
committed
fix(webapp): only mask 404 as 403 when authorization fails
Previous fix unconditionally returned 403 when findResource was null on a route with authorization, breaking PRIVATE-key callers (e.g. server SDK) hitting the existing api.v2.runs.cancel route — they always pass authorization but the new code returned 403 with a factually wrong message ('Unauthorized: missing required scopes') even though they had full permissions. New ordering: run authorization first (with the resolved resource as the 5th arg, so cross-form session auth still works), then check resource-null → 404. This gives: - PRIVATE key + missing resource: auth passes → 404 (correct) - Underscoped JWT + missing resource: auth fails (resource not in scope) → 403 (no info leak vs existing resource) - Underscoped JWT + existing resource: auth fails → 403 (unchanged) Only auth callbacks that destructure the resource (loader for realtime.v1.sessions.$session.$io) need to handle null — they all already do, since findResource was already nullable in pre-PR loaders.
1 parent 0caa55a commit 188fa43

1 file changed

Lines changed: 17 additions & 28 deletions

File tree

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

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -682,34 +682,15 @@ export function createActionApiRoute<
682682
? await options.findResource(parsedParams, authenticationResult, parsedSearchParams)
683683
: undefined;
684684

685-
if (options.findResource && !resource) {
686-
// When the route also declares `authorization`, mask "resource
687-
// doesn't exist" as 403 — same shape as the auth-failed branch
688-
// below — so an authenticated-but-underscoped caller can't
689-
// probe resource existence by observing 404 vs 403. Routes
690-
// without an `authorization` block keep returning 404.
691-
if (authorization) {
692-
return await wrapResponse(
693-
request,
694-
json(
695-
{
696-
error: `Unauthorized: missing required scopes`,
697-
code: "unauthorized",
698-
param: "access_token",
699-
type: "authorization",
700-
},
701-
{ status: 403 }
702-
),
703-
corsStrategy !== "none"
704-
);
705-
}
706-
return await wrapResponse(
707-
request,
708-
json({ error: "Resource not found" }, { status: 404 }),
709-
corsStrategy !== "none"
710-
);
711-
}
712-
685+
// Run authorization first — but with the resolved resource available
686+
// as the 5th arg so the auth scope check can expand to alternate
687+
// identifiers of the same row (e.g. a Session is addressable by both
688+
// `friendlyId` and `externalId`). Resource-null is checked AFTER auth
689+
// so:
690+
// - underscoped JWT + missing resource → 403 (no info leak)
691+
// - underscoped JWT + existing resource → 403 (existing behavior)
692+
// - PRIVATE key + missing resource → auth passes → 404 (correct)
693+
// - PRIVATE key + existing resource → auth passes → handler runs
713694
if (authorization) {
714695
const { action, resource: authResource, superScopes } = authorization;
715696
const $resource = authResource(
@@ -751,6 +732,14 @@ export function createActionApiRoute<
751732
}
752733
}
753734

735+
if (options.findResource && !resource) {
736+
return await wrapResponse(
737+
request,
738+
json({ error: "Resource not found" }, { status: 404 }),
739+
corsStrategy !== "none"
740+
);
741+
}
742+
754743
const result = await handler({
755744
params: parsedParams,
756745
searchParams: parsedSearchParams,

0 commit comments

Comments
 (0)