8b17df4621
* refactor: migrate IP-based rate limiting from Unkey to Cloudflare
Remove Unkey rate limiting for IP-based endpoints that will now be
handled by Cloudflare Enterprise Advanced Rate Limiting:
- Regular booking creation (createBooking:{hashedIP})
- Recurring booking creation (createRecurringBooking:{hashedIP})
- Instant meeting creation (instant.event-{hashedIP})
- Booking cancellation for unauthenticated users (api:cancel-ip:{hashedIP})
- Forgot password (forgotPassword:{hashedIP})
- Reset password (api:reset-password:{hashedIP})
- Signup (api:signup:{hashedIP})
- API v1 requests ({userId} with auto-lock)
- Global proxy rate limiting (common namespace)
Rate limiting that remains in Unkey (user/entity-based):
- Login (hashedEmail)
- Booking cancellation for authenticated users (api:cancel-user:{userId})
- 2FA setup/enable/disable (api:totp-*:{userId})
- SMS sending (team/org/user based)
- Email verification (various patterns)
- Team member operations (userId based)
- Routing forms (formId:responseHash)
- AI phone calls (userId based)
Also includes Cloudflare configuration proposal document with
recommended rules using JA4 fingerprinting for enhanced protection.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: remove cloudflare proposal doc from PR
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: remove cloudflare comments and keep common rate limiting type
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: revert global middleware from PR #25080 and restore core rate limits
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: restore instantMeeting rate limiting
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: restore email fallback in forgot-password rate limiting
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Remove the static file check
* refactor: add POST_METHODS_ALLOWED_API_ROUTES to proxy matcher
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* revert: restore API v1 rate limiting to original state
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* revert: restore reset-password, cancel, book/event, book/recurring-event to original state
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: use POST_METHODS_ALLOWED_API_ROUTES spread in matcher instead of hardcoded routes
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* revert: restore signup route to original state
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test: update proxy matcher tests for POST_METHODS_ALLOWED_API_ROUTES spread
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* Rename var
* refactor: revert matcher to static strings, add sync-check test for POST_METHODS_ALLOWED_API_ROUTES
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* removed dead routing forms rewrite code
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
534 lines
16 KiB
TypeScript
534 lines
16 KiB
TypeScript
// Import mocked functions
|
|
|
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
import { get as edgeConfigGet } from "@vercel/edge-config";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import type { Mock } from "vitest";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
// We'll test the wrapped proxy as it would be used in production
|
|
import proxy, { checkPostMethod, POST_METHODS_ALLOWED_API_ROUTES } from "./proxy";
|
|
import { config } from "./proxy";
|
|
|
|
// Mock dependencies at module level
|
|
vi.mock("@vercel/edge-config", () => ({
|
|
get: vi.fn(),
|
|
}));
|
|
|
|
// Mock NextResponse.json since it's not available in test environment
|
|
vi.mock("next/server", async () => {
|
|
const actual = await vi.importActual<typeof import("next/server")>("next/server");
|
|
|
|
// Create a NextResponse constructor that returns Response objects
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const NextResponse = function (body: any, init?: ResponseInit) {
|
|
return new Response(body, init);
|
|
};
|
|
|
|
// Add static methods
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
NextResponse.json = (body: any, init?: ResponseInit) => {
|
|
return new Response(JSON.stringify(body), {
|
|
...init,
|
|
headers: {
|
|
...init?.headers,
|
|
"content-type": "application/json",
|
|
},
|
|
});
|
|
};
|
|
|
|
NextResponse.next = (init?: ResponseInit) => {
|
|
const response = new Response(null, {
|
|
status: 200,
|
|
...init,
|
|
headers: {
|
|
...init?.headers,
|
|
"x-middleware-next": "1",
|
|
},
|
|
});
|
|
|
|
// Add cookies property
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(response as any).cookies = {
|
|
delete: vi.fn(),
|
|
set: vi.fn(),
|
|
get: vi.fn(),
|
|
};
|
|
|
|
return response;
|
|
};
|
|
|
|
NextResponse.rewrite = (url: URL | string, init?: ResponseInit) => {
|
|
const rewriteUrl = typeof url === "string" ? new URL(url) : url;
|
|
return new Response(null, {
|
|
status: 200,
|
|
...init,
|
|
headers: {
|
|
...init?.headers,
|
|
"x-middleware-rewrite": rewriteUrl.toString(),
|
|
},
|
|
});
|
|
};
|
|
|
|
NextResponse.redirect = (url: URL | string, statusOrInit?: number | ResponseInit) => {
|
|
const redirectUrl = typeof url === "string" ? new URL(url) : url;
|
|
const status = typeof statusOrInit === "number" ? statusOrInit : statusOrInit?.status || 307;
|
|
const init = typeof statusOrInit === "object" ? statusOrInit : {};
|
|
|
|
const response = new Response(null, {
|
|
...init,
|
|
status,
|
|
headers: {
|
|
...init.headers,
|
|
location: redirectUrl.toString(),
|
|
},
|
|
});
|
|
|
|
// Add cookies property
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(response as any).cookies = {
|
|
delete: vi.fn(),
|
|
set: vi.fn(),
|
|
get: vi.fn(),
|
|
};
|
|
|
|
return response;
|
|
};
|
|
|
|
return {
|
|
...actual,
|
|
NextResponse,
|
|
};
|
|
});
|
|
|
|
const createTestRequest = (overrides?: {
|
|
url?: string;
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
cookies?: Record<string, string>;
|
|
}) => {
|
|
const url = overrides?.url || `${WEBAPP_URL}/test-path`;
|
|
const method = overrides?.method || "GET";
|
|
const headers = new Headers(overrides?.headers || {});
|
|
|
|
const req = new NextRequest(new Request(url, { method, headers }));
|
|
|
|
// Add cookies if provided
|
|
if (overrides?.cookies) {
|
|
Object.entries(overrides.cookies).forEach(([name, value]) => {
|
|
req.cookies.set(name, value);
|
|
});
|
|
}
|
|
|
|
return req;
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const createEdgeConfigMock = (config: Record<string, any>) => {
|
|
return (key: string) => {
|
|
if (key in config) return Promise.resolve(config[key]);
|
|
return Promise.resolve(undefined);
|
|
};
|
|
};
|
|
|
|
// Helper to safely get header value
|
|
const getHeader = (res: Response, name: string) => {
|
|
return res.headers.get(name);
|
|
};
|
|
|
|
// Helper to check response status
|
|
const expectStatus = (res: Response, status: number) => {
|
|
expect(res.status).toBe(status);
|
|
};
|
|
|
|
// Wrapper for proxy calls to handle type casting
|
|
const callProxy = async (req: NextRequest): Promise<Response> => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return (await (proxy as any)(req)) as Response;
|
|
};
|
|
|
|
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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("Middleware Integration Tests", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Set test environment
|
|
vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", WEBAPP_URL);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("Static File Handling", () => {
|
|
it("should skip processing for static files", async () => {
|
|
const staticPaths = ["/images/logo.png", "/_next/static/main.js", "/favicon.ico"];
|
|
|
|
for (const path of staticPaths) {
|
|
const req = createTestRequest({ url: `${WEBAPP_URL}${path}` });
|
|
const res = await callProxy(req);
|
|
|
|
expect(res).toBeDefined();
|
|
expectStatus(res, 200);
|
|
expect(getHeader(res, "x-middleware-next")).toBe("1");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Maintenance Mode", () => {
|
|
it("should redirect to maintenance when enabled", async () => {
|
|
(edgeConfigGet as Mock).mockImplementation(
|
|
createEdgeConfigMock({
|
|
isInMaintenanceMode: true,
|
|
})
|
|
);
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(res).toBeDefined();
|
|
expectStatus(res, 200);
|
|
});
|
|
|
|
it("should not affect API routes in maintenance mode", async () => {
|
|
(edgeConfigGet as Mock).mockImplementation(
|
|
createEdgeConfigMock({
|
|
isInMaintenanceMode: true,
|
|
})
|
|
);
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/api/bookings`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(res).toBeDefined();
|
|
expectStatus(res, 200);
|
|
});
|
|
});
|
|
|
|
describe("Signup Control", () => {
|
|
it("should block signup when disabled", async () => {
|
|
(edgeConfigGet as Mock).mockImplementation(
|
|
createEdgeConfigMock({
|
|
isSignupDisabled: true,
|
|
})
|
|
);
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/api/auth/signup`,
|
|
method: "POST",
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expectStatus(res, 503);
|
|
|
|
const body = await res.text();
|
|
expect(body).toContain("Signup is disabled");
|
|
});
|
|
|
|
it("should allow signup when enabled", async () => {
|
|
(edgeConfigGet as Mock).mockImplementation(
|
|
createEdgeConfigMock({
|
|
isSignupDisabled: false,
|
|
})
|
|
);
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/api/auth/signup`,
|
|
method: "POST",
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expectStatus(res, 200);
|
|
});
|
|
});
|
|
|
|
describe("Cookie Management", () => {
|
|
it("should handle return-to cookie redirect", async () => {
|
|
const returnUrl = "/dashboard";
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/apps/installed`,
|
|
cookies: { "return-to": returnUrl },
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expectStatus(res, 307);
|
|
expect(getHeader(res, "location")).toContain(returnUrl);
|
|
});
|
|
|
|
it("should not redirect without return-to cookie", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/apps/installed`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expectStatus(res, 200);
|
|
});
|
|
|
|
it("should delete session cookie on logout", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/auth/logout`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
const setCookie = getHeader(res, "set-cookie");
|
|
|
|
if (setCookie) {
|
|
expect(setCookie).toContain("next-auth.session-token");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Embed Routes", () => {
|
|
it("should add embed headers", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test/embed`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(getHeader(res, "x-isEmbed")).toBe("true");
|
|
});
|
|
|
|
it("should add COEP header when requested", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test/embed?flag.coep=true`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(getHeader(res, "Cross-Origin-Embedder-Policy")).toBe("require-corp");
|
|
});
|
|
|
|
it("should add color scheme header", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test/embed?ui.color-scheme=dark`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(getHeader(res, "x-embedColorScheme")).toBe("dark");
|
|
});
|
|
});
|
|
|
|
describe("CSP Headers", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("CSP_POLICY", "strict");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("should add CSP headers to login pages", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/auth/login`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
const cspHeader = getHeader(res, "content-security-policy");
|
|
|
|
expect(cspHeader).toBeTruthy();
|
|
expect(cspHeader).toContain("default-src");
|
|
expect(cspHeader).toContain("script-src");
|
|
});
|
|
|
|
it("should not add CSP headers to non-login pages", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
const cspHeader = getHeader(res, "content-security-policy");
|
|
|
|
expect(cspHeader).toBeNull();
|
|
});
|
|
|
|
it("should add x-csp-status when CSP_POLICY not set", async () => {
|
|
vi.unstubAllEnvs();
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/auth/login`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(getHeader(res, "x-csp-status")).toBe("not-opted-in");
|
|
});
|
|
});
|
|
|
|
describe("Routing Forms", () => {
|
|
it("should rewrite legacy routing_forms URLs", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/apps/routing_forms/form-id`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(res).toBeDefined();
|
|
expectStatus(res, 200);
|
|
});
|
|
});
|
|
|
|
describe("Header sanitization", () => {
|
|
it("sanitizes non-ASCII request header values to prevent Vercel Runtime Malformed Response Header", async () => {
|
|
const spy = vi.spyOn(NextResponse, "next");
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test`,
|
|
// Next.js will translate request overrides into x-middleware-request-* headers internally
|
|
headers: {
|
|
"cf-region": "São Paulo", // contains non-ASCII "ã"
|
|
},
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expectStatus(res, 200);
|
|
|
|
// Assert that middleware forwarded sanitized ASCII-only value
|
|
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
|
|
request?: { headers?: Headers };
|
|
};
|
|
expect(initArg?.request?.headers).toBeDefined();
|
|
|
|
const forwarded = initArg?.request?.headers as Headers;
|
|
expect(forwarded.get("cf-region")).toBe("Sao Paulo");
|
|
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("strips non-ASCII bytes in mojibake values (e.g., 'São Paulo' -> 'So Paulo')", async () => {
|
|
const spy = vi.spyOn(NextResponse, "next");
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test`,
|
|
headers: {
|
|
"cf-region": "São Paulo", // mojibake for "São Paulo"; includes non-ASCII bytes
|
|
},
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expectStatus(res, 200);
|
|
|
|
const initArg = (spy as unknown as Mock).mock.calls.at(-1)?.[0] as {
|
|
request?: { headers?: Headers };
|
|
};
|
|
const forwarded = initArg?.request?.headers as Headers;
|
|
expect(forwarded.get("cf-region")).toBe("So Paulo");
|
|
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("Multiple Features", () => {
|
|
it("should handle embed route with routing forms rewrite", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(getHeader(res, "x-isEmbed")).toBe("true");
|
|
expect(getHeader(res, "x-embedColorScheme")).toBe("light");
|
|
});
|
|
});
|
|
|
|
describe("Error Handling", () => {
|
|
it("should handle Edge Config errors gracefully", async () => {
|
|
(edgeConfigGet as Mock).mockImplementation(() => {
|
|
throw new Error("Config error");
|
|
});
|
|
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}/team/test`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(res).toBeDefined();
|
|
expectStatus(res, 200);
|
|
});
|
|
|
|
it("should handle malformed URLs", async () => {
|
|
const req = createTestRequest({
|
|
url: `${WEBAPP_URL}//double-slash`,
|
|
});
|
|
|
|
const res = await callProxy(req);
|
|
expect(res).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Middleware Matcher Configuration", () => {
|
|
const matcher: string[] = config.matcher;
|
|
|
|
it("should include all core middleware routes", () => {
|
|
expect(matcher).toContain("/auth/login");
|
|
expect(matcher).toContain("/login");
|
|
expect(matcher).toContain("/apps/installed");
|
|
expect(matcher).toContain("/auth/logout");
|
|
expect(matcher).toContain("/:path*/embed");
|
|
});
|
|
|
|
it("should have no duplicate entries", () => {
|
|
const uniqueEntries = new Set(matcher);
|
|
expect(uniqueEntries.size).toBe(matcher.length);
|
|
});
|
|
|
|
it("should cover every POST_METHODS_ALLOWED_API_ROUTES route via exact match or wildcard", () => {
|
|
const wildcardPrefixes = matcher
|
|
.filter((entry) => entry.endsWith(":path*"))
|
|
.map((entry) => entry.replace(":path*", ""));
|
|
|
|
for (const route of POST_METHODS_ALLOWED_API_ROUTES) {
|
|
const matcherEntry = route.endsWith("/") ? `${route}:path*` : route;
|
|
const coveredByWildcard = wildcardPrefixes.some((prefix) => route.startsWith(prefix));
|
|
const coveredByExact = matcher.includes(matcherEntry);
|
|
expect(
|
|
coveredByWildcard || coveredByExact,
|
|
`POST route "${route}" is not covered by any matcher entry`
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("should not contain API matcher entries without corresponding POST_METHODS_ALLOWED_API_ROUTES routes", () => {
|
|
const NON_API_ROUTES = ["/auth/login", "/login", "/apps/installed", "/auth/logout", "/:path*/embed"];
|
|
const apiMatcherEntries = matcher.filter((entry) => !NON_API_ROUTES.includes(entry));
|
|
|
|
for (const entry of apiMatcherEntries) {
|
|
if (entry.endsWith(":path*")) {
|
|
const prefix = entry.replace(":path*", "");
|
|
const hasCoveredRoutes = POST_METHODS_ALLOWED_API_ROUTES.some((route) => route.startsWith(prefix));
|
|
expect(hasCoveredRoutes, `Matcher wildcard "${entry}" doesn't cover any POST route`).toBe(true);
|
|
} else {
|
|
expect(
|
|
POST_METHODS_ALLOWED_API_ROUTES,
|
|
`Matcher has "${entry}" but it's missing from POST_METHODS_ALLOWED_API_ROUTES`
|
|
).toContain(entry);
|
|
}
|
|
}
|
|
});
|
|
});
|