Skip to content

Commit ddac623

Browse files
committed
fix: reject unlisted HTTP methods on multi-method PAT action routes
1 parent a4de6d5 commit ddac623

4 files changed

Lines changed: 34 additions & 7 deletions

File tree

apps/webapp/app/routes/api.v1.orgs.$orgParam.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ const RenameOrgRequestBody = z.object({
1313
title: z.string().min(1),
1414
});
1515

16-
// Multi-method (PATCH rename / DELETE), so no `method` and no builder body
17-
// schema — the handler branches and parses the PATCH body itself.
16+
// Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and
17+
// the handler branches. No builder body schema — DELETE has none, so the PATCH
18+
// branch parses its own body.
1819
export const action = createActionPATApiRoute(
1920
{
21+
method: ["PATCH", "DELETE"],
2022
params: ParamsSchema,
2123
// Resolve the org (id only, no membership) so the plugin can compute the
2224
// caller's role floor for the manage:organization gate below.

apps/webapp/app/routes/api.v1.projects.$projectRef.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ const RenameProjectRequestBody = z.object({
6969
name: z.string().min(1),
7070
});
7171

72-
// Multi-method (PATCH rename / DELETE), so no `method` and no builder body
73-
// schema — the handler branches and parses the PATCH body itself.
72+
// Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and
73+
// the handler branches. No builder body schema — DELETE has none, so the PATCH
74+
// branch parses its own body.
7475
export const action = createActionPATApiRoute(
7576
{
77+
method: ["PATCH", "DELETE"],
7678
params: ParamsSchema,
7779
// Resolve the org (id only, no membership) so the plugin can compute the
7880
// caller's role floor for the manage:project gate below.

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -644,13 +644,16 @@ export function createLoaderPATApiRoute<
644644
// can raise typed 4xx errors instead of the route string-matching messages.
645645
// Deliberately self-contained (not sharing internals with the loader) so
646646
// existing PAT loader routes are untouched.
647+
type PATActionMethod = "POST" | "PUT" | "DELETE" | "PATCH";
648+
647649
type PATActionRouteBuilderOptions<
648650
TParamsSchema extends AnyZodSchema | undefined = undefined,
649651
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
650652
THeadersSchema extends AnyZodSchema | undefined = undefined,
651653
TBodySchema extends AnyZodSchema | undefined = undefined,
652654
> = PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema> & {
653-
method?: "POST" | "PUT" | "DELETE" | "PATCH";
655+
// A single verb, or a list for multi-method routes (e.g. ["PATCH", "DELETE"]).
656+
method?: PATActionMethod | PATActionMethod[];
654657
body?: TBodySchema;
655658
};
656659

@@ -710,10 +713,14 @@ export function createActionPATApiRoute<
710713
return apiCors(request, json({}));
711714
}
712715

713-
if (method && request.method.toUpperCase() !== method) {
716+
const allowedMethods = method ? (Array.isArray(method) ? method : [method]) : undefined;
717+
if (allowedMethods && !(allowedMethods as string[]).includes(request.method.toUpperCase())) {
714718
return await wrapResponse(
715719
request,
716-
json({ error: "Method not allowed" }, { status: 405, headers: { Allow: method } }),
720+
json(
721+
{ error: "Method not allowed" },
722+
{ status: 405, headers: { Allow: allowedMethods.join(", ") } }
723+
),
717724
corsStrategy !== "none"
718725
);
719726
}

apps/webapp/test/auth-api.e2e.full.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3036,4 +3036,20 @@ describe("API", () => {
30363036
expect(res.status).toBe(400);
30373037
});
30383038
});
3039+
3040+
// Multi-method PAT action routes declare their allowed verbs, so a verb they
3041+
// don't handle (e.g. POST on a PATCH/DELETE route) is rejected rather than
3042+
// falling through to the rename branch.
3043+
describe("Multi-method routes reject other verbs", () => {
3044+
it("POST to the org rename/delete route: 405", async () => {
3045+
const server = getTestServer();
3046+
const { organization, pat } = await seedTestUserProject(server.prisma);
3047+
const res = await server.webapp.fetch(`/api/v1/orgs/${organization.id}`, {
3048+
method: "POST",
3049+
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
3050+
body: JSON.stringify({ title: "New name" }),
3051+
});
3052+
expect(res.status).toBe(405);
3053+
});
3054+
});
30393055
});

0 commit comments

Comments
 (0)