chore: Block POST requests to all page routes except a whitelist (#19346)
* chore: block POST requests to next.js routes except a whitelist * update config.matcher --------- Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
co-authored by
Keith Williams
parent
1be002c33c
commit
01fc501cb9
@@ -1,62 +0,0 @@
|
||||
import { getBucket } from "abTest/utils";
|
||||
import type { NextMiddleware, NextRequest } from "next/server";
|
||||
import { NextResponse, URLPattern } from "next/server";
|
||||
|
||||
import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants";
|
||||
|
||||
const ROUTES: [URLPattern, boolean][] = [
|
||||
["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const,
|
||||
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
|
||||
].map(([pathname, enabled]) => [
|
||||
new URLPattern({
|
||||
pathname,
|
||||
}),
|
||||
enabled,
|
||||
]);
|
||||
|
||||
export const abTestMiddlewareFactory =
|
||||
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
|
||||
async (req: NextRequest) => {
|
||||
const response = await next(req);
|
||||
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);
|
||||
|
||||
const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null;
|
||||
const enabled = route !== null ? route[1] || override : false;
|
||||
|
||||
if (pathname.includes("future") || !enabled) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const bucketValue = override ? "future" : req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value;
|
||||
|
||||
if (!bucketValue || !["future", "legacy"].includes(bucketValue)) {
|
||||
// cookie does not exist or it has incorrect value
|
||||
const bucket = getBucket();
|
||||
|
||||
response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, {
|
||||
expires: Date.now() + 1000 * 60 * 30,
|
||||
httpOnly: true,
|
||||
}); // 30 min in ms
|
||||
|
||||
if (bucket === "legacy") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `future${pathname}/`;
|
||||
|
||||
return NextResponse.rewrite(url, response);
|
||||
}
|
||||
|
||||
if (bucketValue === "legacy") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `future${pathname}/`;
|
||||
|
||||
return NextResponse.rewrite(url, response);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants";
|
||||
|
||||
const cryptoRandom = () => {
|
||||
return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff;
|
||||
};
|
||||
|
||||
export const getBucket = () => {
|
||||
return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy";
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { checkPostMethod, POST_METHODS_ALLOWED_APP_ROUTES } from "./middleware";
|
||||
|
||||
describe("Middleware - POST requests restriction", () => {
|
||||
const createRequest = (path: string, method: string) => {
|
||||
return new NextRequest(
|
||||
new Request(`${WEBAPP_URL}${path}`, {
|
||||
method,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
it("should allow POST requests to /api routes", async () => {
|
||||
const req1 = createRequest("/api/auth/signup", "POST");
|
||||
const res1 = checkPostMethod(req1);
|
||||
expect(res1).toBeNull();
|
||||
|
||||
const req2 = createRequest("/api/trpc/book/event", "POST");
|
||||
const res2 = checkPostMethod(req2);
|
||||
expect(res2).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow POST requests to allowed app routes", async () => {
|
||||
POST_METHODS_ALLOWED_APP_ROUTES.forEach(async (route) => {
|
||||
const req = createRequest(route, "POST");
|
||||
const res = checkPostMethod(req);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should block POST requests to not-allowed app routes", async () => {
|
||||
const req = createRequest("/team/xyz", "POST");
|
||||
const res = checkPostMethod(req);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res?.status).toBe(405);
|
||||
expect(res?.statusText).toBe("Method Not Allowed");
|
||||
expect(res?.headers.get("Allow")).toBe("GET");
|
||||
});
|
||||
|
||||
it("should allow GET requests to app routes", async () => {
|
||||
const req = createRequest("/team/xyz", "GET");
|
||||
const res = checkPostMethod(req);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow GET requests to /api routes", async () => {
|
||||
const req = createRequest("/api/auth/signup", "GET");
|
||||
const res = checkPostMethod(req);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
+28
-4
@@ -6,9 +6,7 @@ import { NextResponse } from "next/server";
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
import { abTestMiddlewareFactory } from "./abTest/middlewareFactory";
|
||||
import { csp } from "./lib/csp";
|
||||
|
||||
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
@@ -18,7 +16,33 @@ const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup", "/api/trpc"];
|
||||
// Some app routes are allowed because "revalidatePath()" is used to revalidate the cache for them
|
||||
export const POST_METHODS_ALLOWED_APP_ROUTES = ["/settings/my-account/general"];
|
||||
|
||||
export function checkPostMethod(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
if (
|
||||
![...POST_METHODS_ALLOWED_API_ROUTES, ...POST_METHODS_ALLOWED_APP_ROUTES].some((route) =>
|
||||
pathname.startsWith(route)
|
||||
) &&
|
||||
req.method === "POST"
|
||||
) {
|
||||
return new NextResponse(null, {
|
||||
status: 405,
|
||||
statusText: "Method Not Allowed",
|
||||
headers: {
|
||||
Allow: "GET",
|
||||
},
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const postCheckResult = checkPostMethod(req);
|
||||
if (postCheckResult) return postCheckResult;
|
||||
|
||||
const url = req.nextUrl;
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-url", req.url);
|
||||
@@ -174,7 +198,7 @@ export const config = {
|
||||
};
|
||||
|
||||
export default collectEvents({
|
||||
middleware: abTestMiddlewareFactory(middleware),
|
||||
middleware,
|
||||
...nextCollectBasicSettings,
|
||||
cookieName: "__clnds",
|
||||
extend: extendEventData,
|
||||
|
||||
Reference in New Issue
Block a user