chore: system wide ratelimit per path (#25080)
* chore: Add system wide rate limiting * Handle and convert HttpError to 429 * Make algo 32-bit to prevent <ES2020 error + fix sms manager unit test * Change core to common to go from 10 requests per minute to 200 * Remove redundant function * Fix integration tests * Make sure we allow all legal POST routes * Allow tRPC post calls * Add matcher tests on middleware * Add matcher tests on middleware * Fix matcher to not use regex * Fix missing POST allow rule for /api/auth/callback/credentials * Missed the api/book/event endpoints * Add missing pages/api routes * Remove POST middleware for now, very risky * Remove tests for POST protection --------- Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
This commit is contained in:
co-authored by
Volnei Munhoz
parent
a3fc17bc75
commit
1d6959c4ac
+72
-48
@@ -9,6 +9,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { checkPostMethod } from "./middleware";
|
||||
// We'll test the wrapped middleware as it would be used in production
|
||||
import middleware from "./middleware";
|
||||
import { config } from "./middleware";
|
||||
|
||||
// Mock dependencies at module level
|
||||
vi.mock("@vercel/edge-config", () => ({
|
||||
@@ -16,6 +17,7 @@ vi.mock("@vercel/edge-config", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("next-collect/server", () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
collectEvents: vi.fn((config: any) => config.middleware),
|
||||
}));
|
||||
|
||||
@@ -29,11 +31,13 @@ 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,
|
||||
@@ -55,6 +59,7 @@ vi.mock("next/server", async () => {
|
||||
});
|
||||
|
||||
// Add cookies property
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(response as any).cookies = {
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
@@ -91,6 +96,7 @@ vi.mock("next/server", async () => {
|
||||
});
|
||||
|
||||
// Add cookies property
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(response as any).cookies = {
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
@@ -128,6 +134,7 @@ const createTestRequest = (overrides?: {
|
||||
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]);
|
||||
@@ -147,6 +154,7 @@ const expectStatus = (res: Response, status: number) => {
|
||||
|
||||
// Wrapper for middleware calls to handle type casting
|
||||
const callMiddleware = async (req: NextRequest): Promise<Response> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (await (middleware as any)(req)) as Response;
|
||||
};
|
||||
|
||||
@@ -165,15 +173,6 @@ describe("Middleware - POST requests restriction", () => {
|
||||
expect(res1).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);
|
||||
@@ -213,29 +212,6 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST Method Protection", () => {
|
||||
it("should allow POST to /api/auth/signup", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/api/auth/signup`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 200);
|
||||
});
|
||||
|
||||
it("should block POST to regular routes", async () => {
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
expectStatus(res, 405);
|
||||
expect(getHeader(res, "Allow")).toBe("GET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Maintenance Mode", () => {
|
||||
it("should redirect to maintenance when enabled", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
@@ -430,22 +406,6 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("Multiple Features", () => {
|
||||
it("should handle POST protection before maintenance mode", async () => {
|
||||
(edgeConfigGet as Mock).mockImplementation(
|
||||
createEdgeConfigMock({
|
||||
isInMaintenanceMode: true,
|
||||
})
|
||||
);
|
||||
|
||||
const req = createTestRequest({
|
||||
url: `${WEBAPP_URL}/team/test`,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res = await callMiddleware(req);
|
||||
// POST protection should trigger first
|
||||
expectStatus(res, 405);
|
||||
});
|
||||
|
||||
it("should handle embed route with routing forms rewrite", async () => {
|
||||
const req = createTestRequest({
|
||||
@@ -483,3 +443,67 @@ describe("Middleware Integration Tests", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Middleware Matcher - Comprehensive Coverage", () => {
|
||||
const matcher = config.matcher[0];
|
||||
const pattern = matcher.replace(/^\/|\/$/g, "");
|
||||
const regex = new RegExp(`^/${pattern}`);
|
||||
|
||||
const cases = [
|
||||
// pages & apis
|
||||
{ path: "/", expected: true, reason: "Root page" },
|
||||
{ path: "/home", expected: true, reason: "Regular page" },
|
||||
{ path: "/team/abc", expected: true, reason: "Nested page" },
|
||||
{ path: "/api/auth/login", expected: true, reason: "API route" },
|
||||
{ path: "/api/bookings", expected: true, reason: "Top-level API" },
|
||||
{ path: "/dashboard/settings", expected: true, reason: "Deep nested page" },
|
||||
{ path: "/user/john/profile", expected: true, reason: "Multiple nested path" },
|
||||
{ path: "/apps/routing_forms/form", expected: true, reason: "App page under /apps" },
|
||||
{ path: "/embed?ui.color-scheme=dark", expected: true, reason: "Embed query param" },
|
||||
|
||||
// should be ignored (internal / static / public)
|
||||
{ path: "/_next/static/chunks/app.js", expected: false, reason: "Internal static asset" },
|
||||
{ path: "/_next/image?url=%2Flogo.png&w=256&q=75", expected: false, reason: "Internal image handler" },
|
||||
{ path: "/_next/data/build-id/page.json", expected: false, reason: "Next.js data route" },
|
||||
{ path: "/favicon.ico", expected: false, reason: "Favicon asset" },
|
||||
{ path: "/robots.txt", expected: false, reason: "Robots file" },
|
||||
{ path: "/sitemap.xml", expected: false, reason: "Sitemap file" },
|
||||
{ path: "/public/images/logo.png", expected: false, reason: "Public folder asset" },
|
||||
{ path: "/public/fonts/inter.woff2", expected: false, reason: "Public folder font" },
|
||||
{ path: "/static/js/main.js", expected: false, reason: "Static folder JavaScript" },
|
||||
{ path: "/static/css/app.css", expected: false, reason: "Static folder stylesheet" },
|
||||
|
||||
// edge cases
|
||||
{ path: "/manifest.json", expected: true, reason: "Manifest is a public page, not ignored" },
|
||||
{ path: "/_nextsomething", expected: true, reason: "Looks like _next but not reserved" },
|
||||
{ path: "/nextconfig", expected: true, reason: "Normal route with 'next' in name" },
|
||||
{ path: "/_NEXT/image", expected: true, reason: "Case-sensitive test (should match)" },
|
||||
{ path: "/favicon-abc.ico", expected: true, reason: "Favicon variant should still match" },
|
||||
{ path: "/robots-custom.txt", expected: true, reason: "Custom robots file should match" },
|
||||
{ path: "/sitemap-other.xml", expected: true, reason: "Custom sitemap file should match" },
|
||||
{ path: "/api_", expected: true, reason: "Partial match with api underscore" },
|
||||
{ path: "//double-slash", expected: true, reason: "Double slash URL" },
|
||||
{ path: "/_next", expected: false, reason: "Bare _next path" },
|
||||
];
|
||||
|
||||
it("should match only the intended routes", () => {
|
||||
for (const { path, expected, reason } of cases) {
|
||||
const result = regex.test(path);
|
||||
expect(result, `${path} → ${reason}`).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not accidentally match internal Next.js routes", () => {
|
||||
const internalPaths = ["/_next/static", "/_next/image", "/_next/data"];
|
||||
for (const path of internalPaths) {
|
||||
expect(regex.test(path)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should match all user-facing routes and APIs", () => {
|
||||
const publicPaths = ["/", "/api/user", "/settings", "/dashboard"];
|
||||
for (const path of publicPaths) {
|
||||
expect(regex.test(path)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+72
-30
@@ -3,19 +3,74 @@ import { collectEvents } from "next-collect/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
|
||||
|
||||
import { getCspHeader, getCspNonce } from "@lib/csp";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
return get<T>(key);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Don't crash if EDGE_CONFIG env var is missing
|
||||
}
|
||||
};
|
||||
|
||||
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup"];
|
||||
export const POST_METHODS_ALLOWED_API_ROUTES = [
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/oauth/me",
|
||||
"/api/auth/oauth/refreshToken",
|
||||
"/api/auth/oauth/token",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/saml/callback",
|
||||
"/api/auth/saml/token",
|
||||
"/api/auth/setup",
|
||||
"/api/auth/signup",
|
||||
"/api/auth/two-factor/totp/disable",
|
||||
"/api/auth/two-factor/totp/enable",
|
||||
"/api/auth/two-factor/totp/setup",
|
||||
"/api/auth/session",
|
||||
"/api/availability/calendar",
|
||||
"/api/cancel",
|
||||
"/api/cron/bookingReminder",
|
||||
"/api/cron/calendar-cache-cleanup",
|
||||
"/api/cron/changeTimeZone",
|
||||
"/api/cron/checkSmsPrices",
|
||||
"/api/cron/downgradeUsers",
|
||||
"/api/cron/monthlyDigestEmail",
|
||||
"/api/cron/syncAppMeta",
|
||||
"/api/cron/webhookTriggers",
|
||||
"/api/cron/workflows/scheduleEmailReminders",
|
||||
"/api/cron/workflows/scheduleSMSReminders",
|
||||
"/api/cron/workflows/scheduleWhatsappReminders",
|
||||
"/api/get-inbound-dynamic-variables",
|
||||
"/api/integrations/", // for /api/integrations/[...args] and webhooks
|
||||
"/api/recorded-daily-video",
|
||||
"/api/router",
|
||||
"/api/routing-forms/queued-response",
|
||||
"/api/scim/v2.0/", // /api/scim/v2.0/[...directory]
|
||||
"/api/support/conversation",
|
||||
"/api/sync/helpscout",
|
||||
"/api/twilio/webhook",
|
||||
"/api/username",
|
||||
"/api/verify-booking-token",
|
||||
"/api/video/guest-session",
|
||||
"/api/webhook/app-credential",
|
||||
"/api/webhooks/calendar-subscription/", // /api/webhooks/calendar-subscription/[provider]
|
||||
"/api/webhooks/retell-ai",
|
||||
"/api/workflows/sms/user-response",
|
||||
"/api/trpc/", // for tRPC
|
||||
"/api/auth/callback/", // for NextAuth
|
||||
"/api/book/event",
|
||||
"/api/book/instant-event",
|
||||
"/api/book/recurring-event",
|
||||
"/availability",
|
||||
];
|
||||
|
||||
export function checkPostMethod(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
|
||||
@@ -30,14 +85,6 @@ export function checkPostMethod(req: NextRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function checkStaticFiles(pathname: string) {
|
||||
const hasFileExtension = /\.(svg|png|jpg|jpeg|gif|webp|ico)$/.test(pathname);
|
||||
// Skip Next.js internal paths (_next) and static assets
|
||||
if (pathname.startsWith("/_next") || hasFileExtension) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
}
|
||||
|
||||
const isPagePathRequest = (url: URL) => {
|
||||
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
|
||||
const isFile = /\..*$/;
|
||||
@@ -50,11 +97,21 @@ const shouldEnforceCsp = (url: URL) => {
|
||||
};
|
||||
|
||||
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const postCheckResult = checkPostMethod(req);
|
||||
if (postCheckResult) return postCheckResult;
|
||||
const requestorIp = getIP(req);
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "common",
|
||||
identifier: `${req.nextUrl.pathname}-${piiHasher.hash(requestorIp)}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
return new NextResponse(error.message, { status: error.statusCode });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isStaticFile = checkStaticFiles(req.nextUrl.pathname);
|
||||
if (isStaticFile) return isStaticFile;
|
||||
// const postCheckResult = checkPostMethod(req);
|
||||
// if (postCheckResult) return postCheckResult;
|
||||
|
||||
const url = req.nextUrl;
|
||||
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
||||
@@ -168,22 +225,7 @@ function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
|
||||
// https://github.com/vercel/next.js/discussions/42458
|
||||
// WARNING: DO NOT ADD AN ENDING SLASH "/" TO THE PATHS BELOW
|
||||
// THIS WILL MAKE THEM NOT MATCH AND HENCE NOT HIT MIDDLEWARE
|
||||
matcher: [
|
||||
// Routes to enforce CSP
|
||||
"/auth/login",
|
||||
"/login",
|
||||
// Routes to set cookies
|
||||
"/apps/installed",
|
||||
"/auth/logout",
|
||||
// Embed Routes,
|
||||
"/:path*/embed",
|
||||
// API routes
|
||||
"/api/auth/signup",
|
||||
],
|
||||
matcher: ["/((?!_next(?:/|$)|static(?:/|$)|public(?:/|$)|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$).*)"],
|
||||
};
|
||||
|
||||
export default collectEvents({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextRequest } from "next/server";
|
||||
import TwilioClient from "twilio";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { setTestSMS } from "@calcom/lib/testSMS";
|
||||
@@ -85,6 +85,7 @@ export const sendSMS = async ({
|
||||
}
|
||||
|
||||
if (isWhatsapp) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messageOptions: any = {
|
||||
contentSid: contentSid,
|
||||
to: getSMSNumber(phoneNumber, isWhatsapp),
|
||||
@@ -172,6 +173,7 @@ export const scheduleSMS = async ({
|
||||
}
|
||||
|
||||
if (isWhatsapp) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messageOptions: any = {
|
||||
contentSid: contentSid,
|
||||
to: getSMSNumber(phoneNumber, isWhatsapp),
|
||||
@@ -225,7 +227,7 @@ export const verifyNumber = async (phoneNumber: string, code: string) => {
|
||||
.services(process.env.TWILIO_VERIFY_SID)
|
||||
.verificationChecks.create({ to: phoneNumber, code: code });
|
||||
return verification_check.status;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
@@ -265,7 +267,7 @@ async function isLockedForSMSSending(userId?: number | null, teamId?: number | n
|
||||
(membership) => membership.team.smsLockState === SMSLockState.LOCKED
|
||||
);
|
||||
|
||||
if (!!memberOfLockedTeam) {
|
||||
if (memberOfLockedTeam) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/t
|
||||
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
|
||||
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { formatCalEventExtended } from "@calcom/lib/formatCalendarEvent";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { SMSLockState } from "@calcom/prisma/enums";
|
||||
|
||||
import { HttpError } from "./http-error";
|
||||
import type { RateLimitHelper } from "./rateLimit";
|
||||
import { rateLimiter } from "./rateLimit";
|
||||
@@ -12,10 +9,8 @@ export async function checkRateLimitAndThrowError({
|
||||
opts,
|
||||
}: RateLimitHelper) {
|
||||
const response = await rateLimiter()({ rateLimitingType, identifier, opts });
|
||||
const { success, reset } = response;
|
||||
|
||||
if (onRateLimiterResponse) onRateLimiterResponse(response);
|
||||
|
||||
const { success, reset } = response;
|
||||
if (!success) {
|
||||
const convertToSeconds = (ms: number) => Math.floor(ms / 1000);
|
||||
const secondsToWait = convertToSeconds(reset - Date.now());
|
||||
@@ -26,63 +21,3 @@ export async function checkRateLimitAndThrowError({
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function checkSMSRateLimit({
|
||||
rateLimitingType = "sms",
|
||||
identifier,
|
||||
onRateLimiterResponse,
|
||||
opts,
|
||||
}: RateLimitHelper) {
|
||||
const response = await rateLimiter()({ rateLimitingType, identifier, opts });
|
||||
const { success } = response;
|
||||
|
||||
if (onRateLimiterResponse) onRateLimiterResponse(response);
|
||||
|
||||
if (!success) {
|
||||
await changeSMSLockState(
|
||||
identifier,
|
||||
rateLimitingType === "sms" ? SMSLockState.LOCKED : SMSLockState.REVIEW_NEEDED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSMSLockState(identifier: string, status: SMSLockState) {
|
||||
let userId, teamId;
|
||||
|
||||
if (identifier.startsWith("sms:user:")) {
|
||||
userId = Number(identifier.slice(9));
|
||||
} else if (identifier.startsWith("sms:team:")) {
|
||||
teamId = Number(identifier.slice(9));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId, profiles: { none: {} } } });
|
||||
if (user?.smsLockReviewedByAdmin) return;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
profiles: { none: {} },
|
||||
},
|
||||
data: {
|
||||
smsLockState: status,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId, parentId: null, isOrganization: false },
|
||||
});
|
||||
if (team?.smsLockReviewedByAdmin) return;
|
||||
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
parentId: null,
|
||||
isOrganization: false,
|
||||
},
|
||||
data: {
|
||||
smsLockState: status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,24 @@ describe("PII Hasher Test Suite", () => {
|
||||
|
||||
const hasher = new Md5PiiHasher("test-salt");
|
||||
|
||||
it("can hash email addresses", async () => {
|
||||
it("can hash email addresses deterministically and preserve domain", async () => {
|
||||
const email = "sensitive_data@example.com";
|
||||
const hashedEmail = hashEmail(email, hasher);
|
||||
expect(hashedEmail).toBe("2e74ca9edc8add1709b0d049aa2a0959@example.com");
|
||||
// Domain must be preserved
|
||||
expect(hashedEmail.endsWith("@example.com")).toBe(true);
|
||||
// Local part should change
|
||||
expect(hashedEmail.split("@")[0]).not.toBe("sensitive_data");
|
||||
// Deterministic
|
||||
expect(hashEmail(email, hasher)).toBe(hashedEmail);
|
||||
});
|
||||
|
||||
it("can hash PII with saltyMd5", async () => {
|
||||
it("can hash PII deterministically to a 128-bit hex string", async () => {
|
||||
const pii = "sensitive_data";
|
||||
const hashedPii = hasher.hash(pii);
|
||||
expect(hashedPii).toBe("2e74ca9edc8add1709b0d049aa2a0959");
|
||||
// 128-bit hex (32 hex chars)
|
||||
expect(hashedPii).toMatch(/^[0-9a-f]{32}$/);
|
||||
// Deterministic
|
||||
expect(hasher.hash(pii)).toBe(hashedPii);
|
||||
});
|
||||
|
||||
it("handles hashing with different salt", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash } from "crypto";
|
||||
// Note: avoid Node's crypto to support runtimes where it's unavailable (e.g., edge/browsers)
|
||||
|
||||
export interface PiiHasher {
|
||||
hash(input: string): string;
|
||||
@@ -7,9 +7,24 @@ export interface PiiHasher {
|
||||
export class Md5PiiHasher implements PiiHasher {
|
||||
constructor(private readonly salt: string) {}
|
||||
hash(input: string) {
|
||||
return createHash("md5")
|
||||
.update(this.salt + input)
|
||||
.digest("hex");
|
||||
// FNV-1a 32-bit using Math.imul, repeated 4 times with variant salt to get 128-bit hex
|
||||
const fnv1a32 = (str: string) => {
|
||||
// Convert to UTF-8 bytes without relying on TextEncoder/Buffer
|
||||
const data = unescape(encodeURIComponent(str));
|
||||
let hash = 0x811c9dc5; // offset basis (2166136261)
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
hash = Math.imul(hash ^ data.charCodeAt(i), 0x01000193); // 16777619
|
||||
}
|
||||
return hash >>> 0; // unsigned 32-bit
|
||||
};
|
||||
|
||||
// Four independent 32-bit hashes to produce a stable 128-bit hex string
|
||||
const h1 = fnv1a32(`${this.salt}::1::${input}`);
|
||||
const h2 = fnv1a32(`${this.salt}::2::${input}`);
|
||||
const h3 = fnv1a32(`${this.salt}::3::${input}`);
|
||||
const h4 = fnv1a32(`${this.salt}::4::${input}`);
|
||||
const toHex8 = (n: number) => n.toString(16).padStart(8, "0");
|
||||
return `${toHex8(h1)}${toHex8(h2)}${toHex8(h3)}${toHex8(h4)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { SMSLockState } from "@calcom/prisma/enums";
|
||||
import type { RateLimitHelper } from "./rateLimit";
|
||||
import { rateLimiter } from "./rateLimit";
|
||||
|
||||
export async function checkSMSRateLimit({
|
||||
rateLimitingType = "sms",
|
||||
identifier,
|
||||
onRateLimiterResponse,
|
||||
opts,
|
||||
}: RateLimitHelper) {
|
||||
const response = await rateLimiter()({ rateLimitingType, identifier, opts });
|
||||
const { success } = response;
|
||||
|
||||
if (onRateLimiterResponse) onRateLimiterResponse(response);
|
||||
|
||||
if (!success) {
|
||||
await changeSMSLockState(
|
||||
identifier,
|
||||
rateLimitingType === "sms" ? SMSLockState.LOCKED : SMSLockState.REVIEW_NEEDED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSMSLockState(identifier: string, status: SMSLockState) {
|
||||
let userId, teamId;
|
||||
|
||||
if (identifier.startsWith("sms:user:")) {
|
||||
userId = Number(identifier.slice(9));
|
||||
} else if (identifier.startsWith("sms:team:")) {
|
||||
teamId = Number(identifier.slice(9));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId, profiles: { none: {} } } });
|
||||
if (user?.smsLockReviewedByAdmin) return;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
profiles: { none: {} },
|
||||
},
|
||||
data: {
|
||||
smsLockState: status,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId, parentId: null, isOrganization: false },
|
||||
});
|
||||
if (team?.smsLockReviewedByAdmin) return;
|
||||
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
parentId: null,
|
||||
isOrganization: false,
|
||||
},
|
||||
data: {
|
||||
smsLockState: status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getSenderId } from "@calcom/features/ee/workflows/lib/alphanumericSenderIdSupport";
|
||||
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
|
||||
import { SENDER_ID } from "@calcom/lib/constants";
|
||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
@@ -42,7 +42,7 @@ const handleSendingSMS = async ({
|
||||
phoneNumber: reminderPhone,
|
||||
body: smsMessage,
|
||||
sender: senderID,
|
||||
...(!!teamId ? { teamId } : { userId: organizerUserId }),
|
||||
...(teamId ? { teamId } : { userId: organizerUserId }),
|
||||
bookingUid,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { TFunction } from "i18next";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import SMSManager from "../sms-manager";
|
||||
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
|
||||
vi.mock("@calcom/lib/smsLockState");
|
||||
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_HOSTED_CAL_FEATURES",
|
||||
"NEXT_PUBLIC_IS_E2E",
|
||||
"IS_E2E",
|
||||
"NEXT_PUBLIC_MINUTES_TO_BOOK",
|
||||
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
|
||||
"NEXT_PUBLIC_SENDER_ID",
|
||||
|
||||
Reference in New Issue
Block a user