diff --git a/packages/features/ee/api-keys/lib/autoLock.ts b/packages/features/ee/api-keys/lib/autoLock.ts
index 3334c61654..990b25fb87 100644
--- a/packages/features/ee/api-keys/lib/autoLock.ts
+++ b/packages/features/ee/api-keys/lib/autoLock.ts
@@ -23,6 +23,7 @@ interface HandleAutoLockInput {
export enum LockReason {
RATE_LIMIT = "Auto-locking user due to rate limit exceeded",
SPAM_WORKFLOW_BODY = "Auto-locking user due to spam detected in workflow body",
+ MALICIOUS_URL_IN_WORKFLOW = "Auto-locking user due to malicious URL detected in workflow",
}
const log = logger.getSubLogger({ prefix: ["[autoLock]"] });
@@ -131,7 +132,7 @@ export async function lockUser(identifierType: string, identifier: string, lockR
},
});
break;
- case "apiKey":
+ case "apiKey": {
const hashedApiKey = hashAPIKey(identifier);
const apiKey = await prisma.apiKey.findUnique({
where: { hashedKey: hashedApiKey },
@@ -160,6 +161,7 @@ export async function lockUser(identifierType: string, identifier: string, lockR
},
});
break;
+ }
// Leaving SMS here but it is handled differently via checkRateLimitForSMS that auto locks
case "SMS":
break;
diff --git a/packages/features/ee/workflows/lib/test/urlScanner.test.ts b/packages/features/ee/workflows/lib/test/urlScanner.test.ts
new file mode 100644
index 0000000000..a0b052a972
--- /dev/null
+++ b/packages/features/ee/workflows/lib/test/urlScanner.test.ts
@@ -0,0 +1,178 @@
+import { describe, expect, test, vi } from "vitest";
+
+import { extractUrlsFromHtml, isUrlScanningEnabled } from "../urlScanner";
+
+// Mock the constants module
+vi.mock("@calcom/lib/constants", async () => {
+ const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
+ return {
+ ...actual,
+ URL_SCANNING_ENABLED: true,
+ };
+});
+
+describe("urlScanner", () => {
+ describe("extractUrlsFromHtml", () => {
+ describe("happy paths", () => {
+ test("should extract URLs from href attributes", () => {
+ const html = 'Click here';
+ const result = extractUrlsFromHtml(html);
+ // URLs are normalized - root URLs get trailing slash removed but become normalized form
+ expect(result).toEqual(["https://example.com/"]);
+ });
+
+ test("should extract multiple URLs from href attributes", () => {
+ const html = `
+ Link 1
+ Link 2
+ `;
+ const result = extractUrlsFromHtml(html);
+ expect(result).toContain("https://example.com/");
+ expect(result).toContain("https://another.com/page");
+ expect(result.length).toBe(2);
+ });
+
+ test("should extract bare URLs from text content", () => {
+ const html = "
Visit https://example.com for more info
";
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/"]);
+ });
+
+ test("should extract both href and bare URLs", () => {
+ const html = `
+ Click
+ Also visit https://bare.com
+ `;
+ const result = extractUrlsFromHtml(html);
+ expect(result).toContain("https://link.com/");
+ expect(result).toContain("https://bare.com/");
+ });
+
+ test("should deduplicate URLs", () => {
+ const html = `
+ Link 1
+ Link 2
+ https://example.com
+ `;
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/"]);
+ });
+
+ test("should handle URLs with paths and query strings", () => {
+ const html = 'Link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/path?query=value&foo=bar"]);
+ });
+
+ test("should handle http URLs", () => {
+ const html = 'Link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["http://example.com/"]);
+ });
+
+ test("should handle single-quoted href attributes", () => {
+ const html = "Link";
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/"]);
+ });
+
+ test("should clean trailing punctuation from bare URLs", () => {
+ const html = "Visit https://example.com. More text here.
";
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/"]);
+ });
+
+ test("should handle URLs with ports", () => {
+ const html = 'Link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com:8080/path"]);
+ });
+
+ test("should handle URLs with fragments", () => {
+ const html = 'Link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/page#section"]);
+ });
+ });
+
+ describe("unhappy paths", () => {
+ test("should return empty array for empty string", () => {
+ const result = extractUrlsFromHtml("");
+ expect(result).toEqual([]);
+ });
+
+ test("should return empty array for HTML without URLs", () => {
+ const html = "No links here
";
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual([]);
+ });
+
+ test("should ignore mailto links", () => {
+ const html = 'Email us';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual([]);
+ });
+
+ test("should ignore tel links", () => {
+ const html = 'Call us';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual([]);
+ });
+
+ test("should ignore javascript links", () => {
+ const html = 'Click';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual([]);
+ });
+
+ test("should ignore relative URLs", () => {
+ const html = 'Link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual([]);
+ });
+
+ test("should ignore anchor-only links", () => {
+ const html = 'Jump to section';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual([]);
+ });
+
+ test("should handle malformed HTML gracefully", () => {
+ const html = 'Unclosed link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/"]);
+ });
+
+ test("should handle empty href attributes", () => {
+ const html = 'Empty link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe("URL normalization", () => {
+ test("should normalize URLs with trailing slash", () => {
+ const html = `
+ Link 1
+ Link 2
+ `;
+ const result = extractUrlsFromHtml(html);
+ // Both should normalize to the same URL
+ expect(result.length).toBe(1);
+ });
+
+ test("should preserve path when normalizing", () => {
+ const html = 'Link';
+ const result = extractUrlsFromHtml(html);
+ expect(result).toEqual(["https://example.com/path/"]);
+ });
+ });
+ });
+
+ describe("isUrlScanningEnabled", () => {
+ test("should return true when URL_SCANNING_ENABLED is true", () => {
+ const result = isUrlScanningEnabled();
+ expect(result).toBe(true);
+ });
+ });
+});
diff --git a/packages/features/ee/workflows/lib/urlScanner.ts b/packages/features/ee/workflows/lib/urlScanner.ts
new file mode 100644
index 0000000000..98208156b0
--- /dev/null
+++ b/packages/features/ee/workflows/lib/urlScanner.ts
@@ -0,0 +1,441 @@
+import { LockReason, lockUser } from "@calcom/features/ee/api-keys/lib/autoLock";
+import { URL_SCANNING_ENABLED } from "@calcom/lib/constants";
+import logger from "@calcom/lib/logger";
+
+// biome-ignore lint/nursery/useExplicitType: Logger type is inferred from getSubLogger
+const log = logger.getSubLogger({ prefix: ["[urlScanner]"] });
+
+// Cloudflare URL Scanner API configuration
+const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4";
+const MAX_POLL_ATTEMPTS = 10;
+const POLL_INTERVAL_MS = 15000; // 15 seconds
+
+interface CloudflareScanSubmitResponse {
+ success: boolean;
+ errors: Array<{ code: number; message: string }>;
+ result?: {
+ uuid: string;
+ url: string;
+ visibility: string;
+ };
+}
+
+interface CloudflareBulkScanItem {
+ url: string;
+ uuid?: string;
+ api?: string;
+ result?: string;
+ visibility?: string;
+}
+
+type CloudflareBulkScanResponse = CloudflareBulkScanItem[];
+
+interface CloudflareScanResultResponse {
+ success: boolean;
+ errors: Array<{ code: number; message: string }>;
+ result?: {
+ scan: {
+ task: {
+ uuid: string;
+ url: string;
+ status: string;
+ success: boolean;
+ };
+ verdicts: {
+ overall: {
+ malicious: boolean;
+ categories: string[];
+ };
+ };
+ };
+ };
+}
+
+interface UrlScanResult {
+ url: string;
+ scanId: string;
+ status: "pending" | "completed" | "error";
+ malicious?: boolean;
+ categories?: string[];
+ error?: string;
+}
+
+/**
+ * Gets the error message from an unknown error.
+ */
+function getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return "Unknown error";
+}
+
+/**
+ * Creates an error result for a scan.
+ */
+function createErrorResult(scanId: string, errorMessage: string): UrlScanResult {
+ return {
+ url: "",
+ scanId,
+ status: "error",
+ error: errorMessage,
+ };
+}
+
+/**
+ * Validates that a URL is a valid HTTP/HTTPS URL.
+ */
+function isValidHttpUrl(urlString: string): boolean {
+ try {
+ const url = new URL(urlString);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Normalizes a URL for deduplication.
+ */
+function normalizeUrl(urlString: string): string {
+ try {
+ const url = new URL(urlString);
+ // Remove trailing slash from pathname if it's just "/"
+ if (url.pathname === "/") {
+ url.pathname = "";
+ }
+ return url.toString();
+ } catch {
+ return urlString;
+ }
+}
+
+/**
+ * Extracts URLs from HTML content.
+ * Extracts both href attributes from anchor tags and bare URLs from text.
+ */
+function extractUrlsFromHtml(html: string): string[] {
+ const urls = new Set();
+
+ // Extract href attributes from anchor tags
+ const hrefRegex = /href=["']([^"']+)["']/gi;
+ for (const match of Array.from(html.matchAll(hrefRegex))) {
+ const url = match[1];
+ if (isValidHttpUrl(url)) {
+ urls.add(normalizeUrl(url));
+ }
+ }
+
+ // Extract bare URLs from text content
+ const bareUrlRegex = /https?:\/\/[^\s<>"']+/gi;
+ for (const match of Array.from(html.matchAll(bareUrlRegex))) {
+ const url = match[0];
+ // Clean up trailing punctuation that might have been captured
+ const cleanUrl = url.replace(/[.,;:!?)]+$/, "");
+ if (isValidHttpUrl(cleanUrl)) {
+ urls.add(normalizeUrl(cleanUrl));
+ }
+ }
+
+ return Array.from(urls);
+}
+
+/**
+ * Submits a URL to Cloudflare URL Scanner for scanning.
+ */
+async function submitUrlForScanning(url: string): Promise<{ scanId: string } | { error: string }> {
+ // biome-ignore lint/style/noProcessEnv lint/correctness/noProcessGlobal: Server-side only, credentials from env
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
+ // biome-ignore lint/style/noProcessEnv lint/correctness/noProcessGlobal: Server-side only, credentials from env
+ const apiToken = process.env.CLOUDFLARE_URL_SCANNER_API_TOKEN;
+
+ if (!accountId || !apiToken) {
+ return { error: "Cloudflare URL Scanner credentials not configured" };
+ }
+
+ try {
+ const response = await fetch(`${CLOUDFLARE_API_BASE}/accounts/${accountId}/urlscanner/v2/scan`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ url,
+ visibility: "Unlisted", // Don't make scans public
+ }),
+ });
+
+ const data = (await response.json()) as CloudflareScanSubmitResponse;
+
+ if (!data.success || !data.result?.uuid) {
+ const errorMessage = data.errors?.[0]?.message || "Unknown error submitting URL for scanning";
+ log.error(`Failed to submit URL for scanning: ${errorMessage}`, { url });
+ return { error: errorMessage };
+ }
+
+ log.info(`Submitted URL for scanning`, { url, scanId: data.result.uuid });
+ return { scanId: data.result.uuid };
+ } catch (error) {
+ const errorMessage = getErrorMessage(error);
+ log.error(`Error submitting URL for scanning: ${errorMessage}`, { url });
+ return { error: errorMessage };
+ }
+}
+
+/**
+ * Processes the bulk scan response and populates the results map.
+ */
+function processBulkScanResponse(
+ data: CloudflareBulkScanResponse,
+ results: Map
+): void {
+ for (const item of data) {
+ if (item.uuid) {
+ results.set(item.url, { scanId: item.uuid });
+ log.info(`Submitted URL for bulk scanning`, { url: item.url, scanId: item.uuid });
+ } else {
+ const errorMessage = item.result || "Unknown error submitting URL for bulk scanning";
+ results.set(item.url, { error: errorMessage });
+ log.error(`Failed to submit URL for bulk scanning: ${errorMessage}`, { url: item.url });
+ }
+ }
+}
+
+/**
+ * Submits multiple URLs to Cloudflare URL Scanner for bulk scanning.
+ * Uses the bulk endpoint to reduce API quota usage.
+ * @param urls - Array of URLs to scan (max 100 per request)
+ * @returns Map of URL to scanId, or error for each URL
+ */
+async function submitUrlsForBulkScanning(
+ urls: string[]
+): Promise