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> { + const results = new Map(); + + if (urls.length === 0) { + return results; + } + + // 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) { + for (const url of urls) { + results.set(url, { error: "Cloudflare URL Scanner credentials not configured" }); + } + return results; + } + + try { + // Cloudflare bulk endpoint accepts up to 100 URLs per request + const MAX_BULK_SIZE = 100; + const batches: string[][] = []; + for (let i = 0; i < urls.length; i += MAX_BULK_SIZE) { + batches.push(urls.slice(i, i + MAX_BULK_SIZE)); + } + + for (const batch of batches) { + const requestBody = batch.map((url) => ({ url, visibility: "Unlisted" })); + const response = await fetch(`${CLOUDFLARE_API_BASE}/accounts/${accountId}/urlscanner/v2/bulk`, { + method: "POST", + headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + const data = (await response.json()) as CloudflareBulkScanResponse; + processBulkScanResponse(data, results); + } + } catch (error) { + const errorMessage = getErrorMessage(error); + log.error(`Error submitting URLs for bulk scanning: ${errorMessage}`); + for (const url of urls) { + if (!results.has(url)) { + results.set(url, { error: errorMessage }); + } + } + } + + return results; +} + +/** + * Parses the scan result response from Cloudflare API. + */ +function parseScanResultResponse(scanId: string, data: CloudflareScanResultResponse): UrlScanResult { + if (!data.success || !data.result?.scan) { + const errorMessage = data.errors?.[0]?.message || "Unknown error getting scan result"; + return createErrorResult(scanId, errorMessage); + } + + const { task, verdicts } = data.result.scan; + + return { + url: task.url, + scanId, + status: "completed", + malicious: verdicts.overall.malicious, + categories: verdicts.overall.categories, + }; +} + +/** + * Gets the result of a URL scan from Cloudflare. + * Returns null if the scan is still in progress. + */ +async function getScanResult(scanId: string): Promise { + // 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 createErrorResult(scanId, "Cloudflare URL Scanner credentials not configured"); + } + + try { + const response = await fetch(`${CLOUDFLARE_API_BASE}/accounts/${accountId}/urlscanner/v2/result/${scanId}`, { + method: "GET", + headers: { + Authorization: `Bearer ${apiToken}`, + }, + }); + + // 404 means scan is still in progress + if (response.status === 404) { + return null; + } + + if (!response.ok) { + const errorText = await response.text(); + log.error(`Error getting scan result: ${response.status} ${errorText}`, { scanId }); + return createErrorResult(scanId, `HTTP ${response.status}: ${errorText}`); + } + + const data = (await response.json()) as CloudflareScanResultResponse; + return parseScanResultResponse(scanId, data); + } catch (error) { + const errorMessage = getErrorMessage(error); + log.error(`Error getting scan result: ${errorMessage}`, { scanId }); + return createErrorResult(scanId, errorMessage); + } +} + +/** + * Scans multiple URLs and returns results. + * This is a synchronous scan that polls for results. + * Uses bulk scanning endpoint to reduce API quota usage. + * For async scanning, use submitUrlForScanning and getScanResult separately. + */ +async function scanUrls(urls: string[]): Promise { + if (!URL_SCANNING_ENABLED || urls.length === 0) { + return []; + } + + const results: UrlScanResult[] = []; + const pendingScans: Map = new Map(); + + // Submit all URLs for bulk scanning + const bulkResults = await submitUrlsForBulkScanning(urls); + + for (const [url, submitResult] of Array.from(bulkResults.entries())) { + if ("error" in submitResult) { + results.push({ + url, + scanId: "", + status: "error", + error: submitResult.error, + }); + } else { + pendingScans.set(submitResult.scanId, { url, attempts: 0 }); + } + } + + // Poll for results + while (pendingScans.size > 0) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + + for (const [scanId, { url, attempts }] of Array.from(pendingScans.entries())) { + if (attempts >= MAX_POLL_ATTEMPTS) { + results.push({ + url, + scanId, + status: "error", + error: "Max poll attempts reached", + }); + pendingScans.delete(scanId); + continue; + } + + const result = await getScanResult(scanId); + if (result === null) { + // Still pending + pendingScans.set(scanId, { url, attempts: attempts + 1 }); + } else { + // Preserve URL context in case getScanResult returned an error with empty URL + results.push({ ...result, url: result.url || url }); + pendingScans.delete(scanId); + } + } + } + + return results; +} + +/** + * Checks if any URLs are malicious and locks the user if so. + * Returns true if malicious URLs were found and user was locked. + */ +async function checkUrlsAndLockIfMalicious( + urls: string[], + userId: number, + context: { workflowStepId?: number; eventTypeId?: number; whitelistWorkflows?: boolean } +): Promise<{ maliciousUrls: string[]; locked: boolean }> { + if (!URL_SCANNING_ENABLED || urls.length === 0) { + return { maliciousUrls: [], locked: false }; + } + + const results = await scanUrls(urls); + const maliciousUrls = results.filter((r) => r.malicious).map((r) => r.url); + + if (maliciousUrls.length > 0) { + log.warn(`Malicious URLs detected`, { + userId, + maliciousUrls, + workflowStepId: context.workflowStepId, + eventTypeId: context.eventTypeId, + }); + + // Don't lock whitelisted users + if (context.whitelistWorkflows) { + log.warn(`Skipping lock for whitelisted user`, { userId }); + return { maliciousUrls, locked: false }; + } + + // Lock the user + await lockUser("userId", String(userId), LockReason.MALICIOUS_URL_IN_WORKFLOW); + return { maliciousUrls, locked: true }; + } + + return { maliciousUrls: [], locked: false }; +} + +/** + * Checks if URL scanning is enabled. + */ +function isUrlScanningEnabled(): boolean { + return URL_SCANNING_ENABLED; +} + +// Export all public functions and types at the end +export type { UrlScanResult }; +export { + extractUrlsFromHtml, + submitUrlForScanning, + submitUrlsForBulkScanning, + getScanResult, + scanUrls, + checkUrlsAndLockIfMalicious, + isUrlScanningEnabled, +}; diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index c550230a02..244ef3e7a7 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -25,6 +25,7 @@ type TaskPayloads = { createCRMEvent: z.infer; sendWorkflowEmails: z.infer; scanWorkflowBody: z.infer; + scanWorkflowUrls: z.infer; sendAnalyticsEvent: z.infer; executeAIPhoneCall: { workflowReminderId: number; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index 89b4ac7a80..18215a26f5 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -27,6 +27,7 @@ const tasks: Record Promise> = { createCRMEvent: () => import("./crm/createCRMEvent").then((module) => module.createCRMEvent), sendWorkflowEmails: () => import("./sendWorkflowEmails").then((module) => module.sendWorkflowEmails), scanWorkflowBody: () => import("./scanWorkflowBody").then((module) => module.scanWorkflowBody), + scanWorkflowUrls: () => import("./scanWorkflowUrls").then((module) => module.scanWorkflowUrls), sendAnalyticsEvent: () => import("./analytics/sendAnalyticsEvent").then((module) => module.sendAnalyticsEvent), executeAIPhoneCall: () => import("./executeAIPhoneCall").then((module) => module.executeAIPhoneCall), diff --git a/packages/features/tasker/tasks/scanWorkflowBody.ts b/packages/features/tasker/tasks/scanWorkflowBody.ts index 1f9ff24d2e..56c323584a 100644 --- a/packages/features/tasker/tasks/scanWorkflowBody.ts +++ b/packages/features/tasker/tasks/scanWorkflowBody.ts @@ -4,11 +4,14 @@ import { getTemplateBodyForAction } from "@calcom/features/ee/workflows/lib/acti import compareReminderBodyToTemplate from "@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate"; import { scheduleWorkflowNotifications } from "@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications"; import { Task } from "@calcom/features/tasker/repository"; +import { URL_SCANNING_ENABLED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; +import { submitWorkflowStepForUrlScanning } from "./scanWorkflowUrls"; + export const scanWorkflowBodySchema = z.object({ userId: z.number(), // deprecated: use workflowStepId instead @@ -19,6 +22,29 @@ export const scanWorkflowBodySchema = z.object({ const log = logger.getSubLogger({ prefix: ["[tasker] scanWorkflowBody"] }); +/** + * Helper function to handle URL scanning or verification for a workflow step. + * Extracts the duplicated logic from both Iffy-enabled and Iffy-disabled paths. + */ +async function handleUrlScanningForStep( + workflowStep: { id: number; reminderBody: string | null; workflow: { user: { whitelistWorkflows: boolean } | null } }, + userId: number +): Promise { + if (URL_SCANNING_ENABLED && workflowStep.reminderBody) { + await submitWorkflowStepForUrlScanning( + workflowStep.id, + workflowStep.reminderBody, + userId, + workflowStep.workflow.user?.whitelistWorkflows ?? false + ); + } else { + await prisma.workflowStep.update({ + where: { id: workflowStep.id }, + data: { verifiedAt: new Date() }, + }); + } +} + export async function scanWorkflowBody(payload: string) { const { workflowStepIds, userId, createdAt, workflowStepId } = scanWorkflowBodySchema.parse( JSON.parse(payload) @@ -118,31 +144,32 @@ export async function scanWorkflowBody(payload: string) { log.warn(`For whitelisted user, workflow step ${workflowStep.id} marked as spam`); } - await prisma.workflowStep.update({ + // Handle URL scanning or mark as verified + await handleUrlScanningForStep(workflowStep, userId); + } + } + + if (!process.env.IFFY_API_KEY) { + log.info("IFFY_API_KEY not set, skipping Iffy spam scan"); + + if (!URL_SCANNING_ENABLED) { + await prisma.workflowStep.updateMany({ where: { - id: workflowStep.id, + id: { + in: stepIdsToScan, + }, }, data: { verifiedAt: new Date(), }, }); + } else { + for (const workflowStep of workflowSteps) { + await handleUrlScanningForStep(workflowStep, userId); + } } } - if (!process.env.IFFY_API_KEY) { - log.info("IFFY_API_KEY not set, skipping scan"); - await prisma.workflowStep.updateMany({ - where: { - id: { - in: stepIdsToScan, - }, - }, - data: { - verifiedAt: new Date(), - }, - }); - } - const workflow = await prisma.workflow.findFirst({ where: { steps: { @@ -166,13 +193,28 @@ export async function scanWorkflowBody(payload: string) { const isOrg = !!workflow?.team?.isOrganization; + const updatedWorkflowSteps = await prisma.workflowStep.findMany({ + where: { + id: { + in: stepIdsToScan, + }, + }, + select: { + id: true, + action: true, + sendTo: true, + emailSubject: true, + reminderBody: true, + template: true, + sender: true, + verifiedAt: true, + }, + }); + await scheduleWorkflowNotifications({ activeOn: workflow.activeOn.map((activeOn) => activeOn.eventTypeId) ?? [], isOrg, - workflowSteps: workflowSteps.map((step) => ({ - ...step, - verifiedAt: new Date(), - })), + workflowSteps: updatedWorkflowSteps, time: workflow.time, timeUnit: workflow.timeUnit, trigger: workflow.trigger, diff --git a/packages/features/tasker/tasks/scanWorkflowUrls.ts b/packages/features/tasker/tasks/scanWorkflowUrls.ts new file mode 100644 index 0000000000..823d88fc58 --- /dev/null +++ b/packages/features/tasker/tasks/scanWorkflowUrls.ts @@ -0,0 +1,277 @@ +import z from "zod"; + +import { LockReason, lockUser } from "@calcom/features/ee/api-keys/lib/autoLock"; +import { + extractUrlsFromHtml, + getScanResult, + isUrlScanningEnabled, + submitUrlForScanning, +} from "@calcom/features/ee/workflows/lib/urlScanner"; +import tasker from "@calcom/features/tasker"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +export const scanWorkflowUrlsSchema = z.object({ + userId: z.number(), + workflowStepId: z.number().optional(), + eventTypeId: z.number().optional(), + // URLs to scan (extracted from workflow body or event type redirect URL) + urls: z.array(z.string()).optional(), + // Scan IDs from Cloudflare (for polling results) + pendingScans: z + .array( + z.object({ + url: z.string(), + scanId: z.string(), + }) + ) + .optional(), + // Number of poll attempts made + pollAttempts: z.number().optional(), + createdAt: z.string().optional(), + whitelistWorkflows: z.boolean().optional(), +}); + +const log = logger.getSubLogger({ prefix: ["[tasker] scanWorkflowUrls"] }); + +/** + * Sanitizes a URL for logging by removing query parameters that may contain sensitive data. + */ +function sanitizeUrlForLogging(url: string): string { + try { + const parsed = new URL(url); + // biome-ignore lint/nursery/noTernary: Simple ternary for conditional suffix + return `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search ? "[query_params_redacted]" : ""}`; + } catch { + return "[invalid_url]"; + } +} + +const MAX_POLL_ATTEMPTS = 10; +const POLL_DELAY_MS = 15000; // 15 seconds + +export async function scanWorkflowUrls(payload: string) { + const parsed = scanWorkflowUrlsSchema.parse(JSON.parse(payload)); + const { + userId, + workflowStepId, + eventTypeId, + urls, + pendingScans, + pollAttempts = 0, + whitelistWorkflows, + } = parsed; + + if (!isUrlScanningEnabled()) { + log.info("URL scanning is not enabled, skipping"); + // Mark workflow step as verified if this was for a workflow + if (workflowStepId) { + await markWorkflowStepVerified(workflowStepId); + } + return; + } + + // Phase 1: Submit URLs for scanning + if (urls && urls.length > 0 && !pendingScans) { + log.info(`Submitting ${urls.length} URLs for scanning`, { userId, workflowStepId, eventTypeId }); + + const newPendingScans: Array<{ url: string; scanId: string }> = []; + const failedUrls: string[] = []; + + for (const url of urls) { + const result = await submitUrlForScanning(url); + if ("error" in result) { + log.error(`Failed to submit URL for scanning: ${result.error}`, { url: sanitizeUrlForLogging(url) }); + failedUrls.push(url); + } else { + newPendingScans.push({ url, scanId: result.scanId }); + } + } + + if (newPendingScans.length === 0) { + // All submissions failed, mark as verified (fail-open for submission errors) + log.warn("All URL submissions failed, marking as verified", { userId, workflowStepId, eventTypeId }); + if (workflowStepId) { + await markWorkflowStepVerified(workflowStepId); + } + return; + } + + // Schedule follow-up task to poll for results + const scheduledAt = new Date(Date.now() + POLL_DELAY_MS); + await tasker.create( + "scanWorkflowUrls", + { + userId, + workflowStepId, + eventTypeId, + pendingScans: newPendingScans, + pollAttempts: 0, + whitelistWorkflows, + }, + { scheduledAt } + ); + + return; + } + + // Phase 2: Poll for scan results + if (pendingScans && pendingScans.length > 0) { + if (pollAttempts >= MAX_POLL_ATTEMPTS) { + log.warn("Max poll attempts reached, marking as verified", { + userId, + workflowStepId, + eventTypeId, + pendingScans, + }); + // Fail-open: mark as verified if we can't get results + if (workflowStepId) { + await markWorkflowStepVerified(workflowStepId); + } + return; + } + + const stillPending: Array<{ url: string; scanId: string }> = []; + const maliciousUrls: string[] = []; + + for (const { url, scanId } of pendingScans) { + const result = await getScanResult(scanId); + + if (result === null) { + // Still pending + stillPending.push({ url, scanId }); + } else if (result.status === "error") { + log.error(`Error getting scan result: ${result.error}`, { url: sanitizeUrlForLogging(url), scanId }); + // Don't add to stillPending, treat as non-malicious (fail-open for errors) + } else if (result.malicious) { + maliciousUrls.push(url); + log.warn(`Malicious URL detected`, { + url: sanitizeUrlForLogging(url), + scanId, + categories: result.categories, + userId, + workflowStepId, + eventTypeId, + }); + } + } + + // If malicious URLs found, lock the user (unless whitelisted) + if (maliciousUrls.length > 0) { + if (whitelistWorkflows) { + log.warn(`Skipping lock for whitelisted user with malicious URLs`, { + userId, + maliciousUrlCount: maliciousUrls.length, + workflowStepId, + eventTypeId, + }); + } else { + log.warn(`Locking user due to malicious URLs`, { + userId, + maliciousUrlCount: maliciousUrls.length, + workflowStepId, + eventTypeId, + }); + await lockUser("userId", String(userId), LockReason.MALICIOUS_URL_IN_WORKFLOW); + } + // Don't mark as verified - the workflow step should not be sent + return; + } + + // If still pending, schedule another poll + if (stillPending.length > 0) { + const scheduledAt = new Date(Date.now() + POLL_DELAY_MS); + await tasker.create( + "scanWorkflowUrls", + { + userId, + workflowStepId, + eventTypeId, + pendingScans: stillPending, + pollAttempts: pollAttempts + 1, + whitelistWorkflows, + }, + { scheduledAt } + ); + return; + } + + // All scans completed and no malicious URLs found + log.info("All URL scans completed, no malicious URLs found", { userId, workflowStepId, eventTypeId }); + if (workflowStepId) { + await markWorkflowStepVerified(workflowStepId); + } + } +} + +async function markWorkflowStepVerified(workflowStepId: number) { + await prisma.workflowStep.update({ + where: { id: workflowStepId }, + data: { verifiedAt: new Date() }, + }); + log.info(`Marked workflow step as verified`, { workflowStepId }); +} + +/** + * Helper function to extract URLs from workflow step body and submit for scanning. + * Called from scanWorkflowBody task or directly from workflow update handler. + */ +export async function submitWorkflowStepForUrlScanning( + workflowStepId: number, + reminderBody: string, + userId: number, + whitelistWorkflows?: boolean +): Promise { + if (!isUrlScanningEnabled()) { + // URL scanning is disabled, mark as verified since there's nothing to scan + await markWorkflowStepVerified(workflowStepId); + return; + } + + const urls = extractUrlsFromHtml(reminderBody); + if (urls.length === 0) { + // No URLs found, mark as verified since there's nothing to scan + log.info("No URLs found in workflow step body, marking as verified", { workflowStepId }); + await markWorkflowStepVerified(workflowStepId); + return; + } + + log.info(`Found ${urls.length} URLs in workflow step body, submitting for scanning`, { + workflowStepId, + urlCount: urls.length, + }); + + await tasker.create("scanWorkflowUrls", { + userId, + workflowStepId, + urls, + whitelistWorkflows, + }); +} + +/** + * Helper function to scan a single URL (e.g., event type redirect URL). + */ +export async function submitUrlForUrlScanning( + url: string, + userId: number, + eventTypeId: number, + whitelistWorkflows?: boolean +): Promise { + if (!isUrlScanningEnabled()) { + return; + } + + log.info(`Submitting event type redirect URL for scanning`, { + url: sanitizeUrlForLogging(url), + userId, + eventTypeId, + }); + + await tasker.create("scanWorkflowUrls", { + userId, + eventTypeId, + urls: [url], + whitelistWorkflows, + }); +} diff --git a/packages/features/tasker/tasks/test/scanWorkflowBody.test.ts b/packages/features/tasker/tasks/test/scanWorkflowBody.test.ts new file mode 100644 index 0000000000..3e14432624 --- /dev/null +++ b/packages/features/tasker/tasks/test/scanWorkflowBody.test.ts @@ -0,0 +1,505 @@ +import process from "node:process"; + +import prismock from "@calcom/testing/lib/__mocks__/prisma"; + +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; + +import { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents, TimeUnit } from "@calcom/prisma/enums"; + +import { scanWorkflowBody, iffyScanBody } from "../scanWorkflowBody"; + +// Mock the submitWorkflowStepForUrlScanning function +vi.mock("../scanWorkflowUrls", () => ({ + submitWorkflowStepForUrlScanning: vi.fn().mockResolvedValue(undefined), +})); + +// Mock the scheduleWorkflowNotifications function +vi.mock("@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications", () => ({ + scheduleWorkflowNotifications: vi.fn().mockResolvedValue(undefined), +})); + +// Mock the Task repository +vi.mock("@calcom/features/tasker/repository", () => ({ + Task: { + hasNewerScanTaskForStepId: vi.fn().mockResolvedValue(false), + }, +})); + +// Mock the actionHelperFunctions +vi.mock("@calcom/features/ee/workflows/lib/actionHelperFunctions", () => ({ + getTemplateBodyForAction: vi.fn().mockReturnValue("Default template body"), +})); + +// Mock the compareReminderBodyToTemplate +vi.mock("@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate", () => ({ + default: vi.fn().mockReturnValue(false), +})); + +// Mock the i18n +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: vi.fn().mockResolvedValue((key: string) => key), +})); + +// Mock the timeFormat +vi.mock("@calcom/lib/timeFormat", () => ({ + getTimeFormatStringFromUserTimeFormat: vi.fn().mockReturnValue("h:mma"), +})); + +// Mock the constants +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, + }; +}); + +// Import mocked modules for assertions +import { submitWorkflowStepForUrlScanning } from "../scanWorkflowUrls"; +import { scheduleWorkflowNotifications } from "@calcom/features/ee/workflows/lib/scheduleWorkflowNotifications"; +import { Task } from "@calcom/features/tasker/repository"; +import compareReminderBodyToTemplate from "@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate"; + +describe("scanWorkflowBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset environment variables + delete process.env.IFFY_API_KEY; + }); + + describe("happy paths", () => { + test("should process workflow step and submit for URL scanning when IFFY is not configured", async () => { + // Create test data in prismock + const user = await prismock.user.create({ + data: { + id: 1, + email: "test@example.com", + locale: "en", + timeFormat: 12, + whitelistWorkflows: false, + }, + }); + + const workflow = await prismock.workflow.create({ + data: { + id: 1, + name: "Test Workflow", + userId: user.id, + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + }, + }); + + await prismock.workflowStep.create({ + data: { + id: 200, + stepNumber: 1, + action: WorkflowActions.EMAIL_HOST, + template: WorkflowTemplates.REMINDER, + workflowId: workflow.id, + reminderBody: '

Hello click here

', + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + id: 1, + title: "Test Event", + slug: "test-event", + length: 30, + userId: user.id, + }, + }); + + await prismock.workflowsOnEventTypes.create({ + data: { + workflowId: workflow.id, + eventTypeId: eventType.id, + }, + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 200, + }); + + await scanWorkflowBody(payload); + + // Should submit for URL scanning + expect(submitWorkflowStepForUrlScanning).toHaveBeenCalledWith( + 200, + '

Hello click here

', + 1, + false + ); + + // Should schedule workflow notifications + expect(scheduleWorkflowNotifications).toHaveBeenCalled(); + }); + + test("should mark workflow step as verified when no reminder body", async () => { + // Set IFFY_API_KEY to test the Iffy path + process.env.IFFY_API_KEY = "test-api-key"; + + const user = await prismock.user.create({ + data: { + id: 2, + email: "test2@example.com", + locale: "en", + timeFormat: 12, + whitelistWorkflows: false, + }, + }); + + const workflow = await prismock.workflow.create({ + data: { + id: 2, + name: "Test Workflow 2", + userId: user.id, + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + }, + }); + + await prismock.workflowStep.create({ + data: { + id: 201, + stepNumber: 1, + action: WorkflowActions.EMAIL_HOST, + template: WorkflowTemplates.REMINDER, + workflowId: workflow.id, + reminderBody: null, // No reminder body + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + id: 2, + title: "Test Event 2", + slug: "test-event-2", + length: 30, + userId: user.id, + }, + }); + + await prismock.workflowsOnEventTypes.create({ + data: { + workflowId: workflow.id, + eventTypeId: eventType.id, + }, + }); + + const payload = JSON.stringify({ + userId: 2, + workflowStepId: 201, + }); + + await scanWorkflowBody(payload); + + // Should mark as verified + const updatedStep = await prismock.workflowStep.findUnique({ + where: { id: 201 }, + }); + expect(updatedStep?.verifiedAt).toBeTruthy(); + }); + + test("should mark workflow step as verified when body matches template", async () => { + process.env.IFFY_API_KEY = "test-api-key"; + vi.mocked(compareReminderBodyToTemplate).mockReturnValueOnce(true); + + const user = await prismock.user.create({ + data: { + id: 3, + email: "test3@example.com", + locale: "en", + timeFormat: 12, + whitelistWorkflows: false, + }, + }); + + const workflow = await prismock.workflow.create({ + data: { + id: 3, + name: "Test Workflow 3", + userId: user.id, + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + }, + }); + + await prismock.workflowStep.create({ + data: { + id: 202, + stepNumber: 1, + action: WorkflowActions.EMAIL_HOST, + template: WorkflowTemplates.REMINDER, + workflowId: workflow.id, + reminderBody: "Default template body", + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + id: 3, + title: "Test Event 3", + slug: "test-event-3", + length: 30, + userId: user.id, + }, + }); + + await prismock.workflowsOnEventTypes.create({ + data: { + workflowId: workflow.id, + eventTypeId: eventType.id, + }, + }); + + const payload = JSON.stringify({ + userId: 3, + workflowStepId: 202, + }); + + await scanWorkflowBody(payload); + + // Should mark as verified since body matches template + const updatedStep = await prismock.workflowStep.findUnique({ + where: { id: 202 }, + }); + expect(updatedStep?.verifiedAt).toBeTruthy(); + }); + + test("should skip processing when newer task exists", async () => { + vi.mocked(Task.hasNewerScanTaskForStepId).mockResolvedValueOnce(true); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 999, + createdAt: new Date().toISOString(), + }); + + await scanWorkflowBody(payload); + + // Should not submit for URL scanning + expect(submitWorkflowStepForUrlScanning).not.toHaveBeenCalled(); + }); + + test("should handle deprecated workflowStepIds array", async () => { + const user = await prismock.user.create({ + data: { + id: 4, + email: "test4@example.com", + locale: "en", + timeFormat: 12, + whitelistWorkflows: false, + }, + }); + + const workflow = await prismock.workflow.create({ + data: { + id: 4, + name: "Test Workflow 4", + userId: user.id, + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + }, + }); + + await prismock.workflowStep.create({ + data: { + id: 203, + stepNumber: 1, + action: WorkflowActions.EMAIL_HOST, + template: WorkflowTemplates.REMINDER, + workflowId: workflow.id, + reminderBody: '

Hello link

', + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + id: 4, + title: "Test Event 4", + slug: "test-event-4", + length: 30, + userId: user.id, + }, + }); + + await prismock.workflowsOnEventTypes.create({ + data: { + workflowId: workflow.id, + eventTypeId: eventType.id, + }, + }); + + const payload = JSON.stringify({ + userId: 4, + workflowStepIds: [203], // Using deprecated array format + }); + + await scanWorkflowBody(payload); + + // Should submit for URL scanning + expect(submitWorkflowStepForUrlScanning).toHaveBeenCalled(); + }); + }); + + describe("unhappy paths", () => { + test("should return early when no step IDs provided", async () => { + const payload = JSON.stringify({ + userId: 1, + }); + + await scanWorkflowBody(payload); + + // Should not submit for URL scanning + expect(submitWorkflowStepForUrlScanning).not.toHaveBeenCalled(); + expect(scheduleWorkflowNotifications).not.toHaveBeenCalled(); + }); + + test("should return early when workflow not found after processing steps", async () => { + // This test verifies that the function returns early when the workflow + // associated with the step IDs is not found (after the initial step processing) + // Note: In practice, this scenario is rare since workflow steps are always + // associated with a workflow, but the code handles it gracefully by logging + // a warning and returning early. + + const payload = JSON.stringify({ + userId: 5, + workflowStepIds: [999], // Non-existent step IDs + }); + + // Should not throw - the function returns early when no steps are found + await expect(scanWorkflowBody(payload)).resolves.not.toThrow(); + + // Should not schedule notifications since no workflow was found + expect(scheduleWorkflowNotifications).not.toHaveBeenCalled(); + }); + + test("should pass whitelistWorkflows flag when user is whitelisted", async () => { + const user = await prismock.user.create({ + data: { + id: 6, + email: "test6@example.com", + locale: "en", + timeFormat: 12, + whitelistWorkflows: true, // Whitelisted user + }, + }); + + const workflow = await prismock.workflow.create({ + data: { + id: 6, + name: "Test Workflow 6", + userId: user.id, + trigger: WorkflowTriggerEvents.BEFORE_EVENT, + time: 1, + timeUnit: TimeUnit.HOUR, + }, + }); + + await prismock.workflowStep.create({ + data: { + id: 205, + stepNumber: 1, + action: WorkflowActions.EMAIL_HOST, + template: WorkflowTemplates.REMINDER, + workflowId: workflow.id, + reminderBody: '

Hello link

', + }, + }); + + const eventType = await prismock.eventType.create({ + data: { + id: 6, + title: "Test Event 6", + slug: "test-event-6", + length: 30, + userId: user.id, + }, + }); + + await prismock.workflowsOnEventTypes.create({ + data: { + workflowId: workflow.id, + eventTypeId: eventType.id, + }, + }); + + const payload = JSON.stringify({ + userId: 6, + workflowStepId: 205, + }); + + await scanWorkflowBody(payload); + + // Should pass whitelistWorkflows=true + expect(submitWorkflowStepForUrlScanning).toHaveBeenCalledWith( + 205, + expect.any(String), + 6, + true + ); + }); + }); + + describe("iffyScanBody", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("should return flagged status from Iffy API", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ flagged: true }), + }); + vi.stubGlobal("fetch", mockFetch); + + process.env.IFFY_API_KEY = "test-api-key"; + + const result = await iffyScanBody("spam content", 100); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.iffy.com/api/v1/moderate", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer test-api-key", + }), + body: expect.stringContaining("spam content"), + }) + ); + }); + + test("should return false when content is not flagged", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ flagged: false }), + }); + vi.stubGlobal("fetch", mockFetch); + + process.env.IFFY_API_KEY = "test-api-key"; + + const result = await iffyScanBody("normal content", 100); + + expect(result).toBe(false); + }); + + test("should handle API errors gracefully", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("API error")); + vi.stubGlobal("fetch", mockFetch); + + process.env.IFFY_API_KEY = "test-api-key"; + + const result = await iffyScanBody("content", 100); + + // Should return undefined on error (fail-open) + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/features/tasker/tasks/test/scanWorkflowUrls.test.ts b/packages/features/tasker/tasks/test/scanWorkflowUrls.test.ts new file mode 100644 index 0000000000..2bdf6d3d31 --- /dev/null +++ b/packages/features/tasker/tasks/test/scanWorkflowUrls.test.ts @@ -0,0 +1,478 @@ +import prismock from "@calcom/testing/lib/__mocks__/prisma"; + +import { describe, expect, test, vi, beforeEach } from "vitest"; + +import tasker from "@calcom/features/tasker"; + +import { + scanWorkflowUrls, + submitWorkflowStepForUrlScanning, + submitUrlForUrlScanning, +} from "../scanWorkflowUrls"; + +// Mock the urlScanner module +vi.mock("@calcom/features/ee/workflows/lib/urlScanner", () => ({ + extractUrlsFromHtml: vi.fn((html: string) => { + // Simple mock implementation + const urls: string[] = []; + const hrefRegex = /href=["']([^"']+)["']/gi; + for (const match of Array.from(html.matchAll(hrefRegex))) { + const url = match[1]; + if (url.startsWith("http://") || url.startsWith("https://")) { + urls.push(url); + } + } + return urls; + }), + submitUrlForScanning: vi.fn().mockResolvedValue({ scanId: "mock-scan-id" }), + getScanResult: vi.fn().mockResolvedValue({ + url: "https://example.com", + scanId: "mock-scan-id", + status: "completed", + malicious: false, + categories: [], + }), + isUrlScanningEnabled: vi.fn().mockReturnValue(true), +})); + +// Mock the tasker +vi.mock("@calcom/features/tasker", () => ({ + default: { + create: vi.fn().mockResolvedValue(undefined), + }, +})); + +// Mock the autoLock module +vi.mock("@calcom/features/ee/api-keys/lib/autoLock", () => ({ + LockReason: { + MALICIOUS_URL_IN_WORKFLOW: "MALICIOUS_URL_IN_WORKFLOW", + }, + lockUser: vi.fn().mockResolvedValue(undefined), +})); + +// Import mocked modules for assertions +import * as urlScanner from "@calcom/features/ee/workflows/lib/urlScanner"; +import { lockUser } from "@calcom/features/ee/api-keys/lib/autoLock"; + +describe("scanWorkflowUrls", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("scanWorkflowUrls task handler", () => { + describe("happy paths", () => { + test("should skip scanning when URL scanning is disabled", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValueOnce(false); + + // Create a workflow step in prismock first + await prismock.workflowStep.create({ + data: { + id: 100, + stepNumber: 1, + action: "EMAIL_HOST", + template: "REMINDER", + workflowId: 1, + }, + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 100, + urls: ["https://example.com"], + }); + + await scanWorkflowUrls(payload); + + // Should mark workflow step as verified + const workflowStep = await prismock.workflowStep.findUnique({ + where: { id: 100 }, + }); + expect(workflowStep?.verifiedAt).toBeTruthy(); + expect(urlScanner.submitUrlForScanning).not.toHaveBeenCalled(); + }); + + test("should submit URLs for scanning in phase 1", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.submitUrlForScanning).mockResolvedValue({ scanId: "scan-123" }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 100, + urls: ["https://example.com", "https://another.com"], + }); + + await scanWorkflowUrls(payload); + + expect(urlScanner.submitUrlForScanning).toHaveBeenCalledTimes(2); + expect(urlScanner.submitUrlForScanning).toHaveBeenCalledWith("https://example.com"); + expect(urlScanner.submitUrlForScanning).toHaveBeenCalledWith("https://another.com"); + + // Should schedule follow-up task + expect(tasker.create).toHaveBeenCalledWith( + "scanWorkflowUrls", + expect.objectContaining({ + userId: 1, + workflowStepId: 100, + pendingScans: expect.arrayContaining([ + expect.objectContaining({ url: "https://example.com", scanId: "scan-123" }), + ]), + pollAttempts: 0, + }), + expect.objectContaining({ scheduledAt: expect.any(Date) }) + ); + }); + + test("should poll for scan results in phase 2 and mark as verified when clean", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.getScanResult).mockResolvedValue({ + url: "https://example.com", + scanId: "scan-123", + status: "completed", + malicious: false, + categories: [], + }); + + // Create a workflow step in prismock + await prismock.workflowStep.create({ + data: { + id: 100, + stepNumber: 1, + action: "EMAIL_HOST", + template: "REMINDER", + workflowId: 1, + }, + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 100, + pendingScans: [{ url: "https://example.com", scanId: "scan-123" }], + pollAttempts: 0, + }); + + await scanWorkflowUrls(payload); + + expect(urlScanner.getScanResult).toHaveBeenCalledWith("scan-123"); + + // Should mark workflow step as verified + const workflowStep = await prismock.workflowStep.findUnique({ + where: { id: 100 }, + }); + expect(workflowStep?.verifiedAt).toBeTruthy(); + }); + }); + + describe("unhappy paths", () => { + test("should lock user when malicious URL is detected", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.getScanResult).mockResolvedValue({ + url: "https://malicious.com", + scanId: "scan-123", + status: "completed", + malicious: true, + categories: ["phishing"], + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 100, + pendingScans: [{ url: "https://malicious.com", scanId: "scan-123" }], + pollAttempts: 0, + }); + + await scanWorkflowUrls(payload); + + expect(lockUser).toHaveBeenCalledWith("userId", "1", "MALICIOUS_URL_IN_WORKFLOW"); + }); + + test("should not lock whitelisted user when malicious URL is detected", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.getScanResult).mockResolvedValue({ + url: "https://malicious.com", + scanId: "scan-123", + status: "completed", + malicious: true, + categories: ["phishing"], + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 100, + pendingScans: [{ url: "https://malicious.com", scanId: "scan-123" }], + pollAttempts: 0, + whitelistWorkflows: true, + }); + + await scanWorkflowUrls(payload); + + expect(lockUser).not.toHaveBeenCalled(); + }); + + test("should mark as verified when max poll attempts reached (fail-open)", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + + // Create a workflow step in prismock + await prismock.workflowStep.create({ + data: { + id: 101, + stepNumber: 1, + action: "EMAIL_HOST", + template: "REMINDER", + workflowId: 1, + }, + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 101, + pendingScans: [{ url: "https://example.com", scanId: "scan-123" }], + pollAttempts: 10, // MAX_POLL_ATTEMPTS + }); + + await scanWorkflowUrls(payload); + + // Should mark workflow step as verified (fail-open) + const workflowStep = await prismock.workflowStep.findUnique({ + where: { id: 101 }, + }); + expect(workflowStep?.verifiedAt).toBeTruthy(); + }); + + test("should mark as verified when all URL submissions fail (fail-open)", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.submitUrlForScanning).mockResolvedValue({ error: "API error" }); + + // Create a workflow step in prismock + await prismock.workflowStep.create({ + data: { + id: 102, + stepNumber: 1, + action: "EMAIL_HOST", + template: "REMINDER", + workflowId: 1, + }, + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 102, + urls: ["https://example.com"], + }); + + await scanWorkflowUrls(payload); + + // Should mark workflow step as verified (fail-open) + const workflowStep = await prismock.workflowStep.findUnique({ + where: { id: 102 }, + }); + expect(workflowStep?.verifiedAt).toBeTruthy(); + }); + + test("should schedule another poll when scan is still pending", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.getScanResult).mockResolvedValue(null); // Still pending + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 100, + pendingScans: [{ url: "https://example.com", scanId: "scan-123" }], + pollAttempts: 0, + }); + + await scanWorkflowUrls(payload); + + // Should schedule another poll + expect(tasker.create).toHaveBeenCalledWith( + "scanWorkflowUrls", + expect.objectContaining({ + userId: 1, + workflowStepId: 100, + pendingScans: [{ url: "https://example.com", scanId: "scan-123" }], + pollAttempts: 1, + }), + expect.objectContaining({ scheduledAt: expect.any(Date) }) + ); + }); + + test("should treat scan errors as non-malicious (fail-open)", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.getScanResult).mockResolvedValue({ + url: "https://example.com", + scanId: "scan-123", + status: "error", + error: "Scan failed", + }); + + // Create a workflow step in prismock + await prismock.workflowStep.create({ + data: { + id: 103, + stepNumber: 1, + action: "EMAIL_HOST", + template: "REMINDER", + workflowId: 1, + }, + }); + + const payload = JSON.stringify({ + userId: 1, + workflowStepId: 103, + pendingScans: [{ url: "https://example.com", scanId: "scan-123" }], + pollAttempts: 0, + }); + + await scanWorkflowUrls(payload); + + // Should mark workflow step as verified (fail-open for errors) + const workflowStep = await prismock.workflowStep.findUnique({ + where: { id: 103 }, + }); + expect(workflowStep?.verifiedAt).toBeTruthy(); + }); + }); + }); + + describe("submitWorkflowStepForUrlScanning", () => { + describe("happy paths", () => { + test("should create task when URLs are found in reminder body", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.extractUrlsFromHtml).mockReturnValue(["https://example.com"]); + + await submitWorkflowStepForUrlScanning( + 100, + 'Link', + 1, + false + ); + + expect(tasker.create).toHaveBeenCalledWith( + "scanWorkflowUrls", + expect.objectContaining({ + userId: 1, + workflowStepId: 100, + urls: ["https://example.com"], + whitelistWorkflows: false, + }) + ); + }); + + test("should pass whitelistWorkflows parameter", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.extractUrlsFromHtml).mockReturnValue(["https://example.com"]); + + await submitWorkflowStepForUrlScanning( + 100, + 'Link', + 1, + true + ); + + expect(tasker.create).toHaveBeenCalledWith( + "scanWorkflowUrls", + expect.objectContaining({ + whitelistWorkflows: true, + }) + ); + }); + }); + + describe("unhappy paths", () => { + test("should mark as verified when URL scanning is disabled", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(false); + + // Create a workflow step in prismock + await prismock.workflowStep.create({ + data: { + id: 104, + stepNumber: 1, + action: "EMAIL_HOST", + template: "REMINDER", + workflowId: 1, + }, + }); + + await submitWorkflowStepForUrlScanning( + 104, + 'Link', + 1, + false + ); + + // Should mark workflow step as verified + const workflowStep = await prismock.workflowStep.findUnique({ + where: { id: 104 }, + }); + expect(workflowStep?.verifiedAt).toBeTruthy(); + expect(tasker.create).not.toHaveBeenCalled(); + }); + + test("should mark as verified when no URLs are found", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + vi.mocked(urlScanner.extractUrlsFromHtml).mockReturnValue([]); + + // Create a workflow step in prismock + await prismock.workflowStep.create({ + data: { + id: 105, + stepNumber: 1, + action: "EMAIL_HOST", + template: "REMINDER", + workflowId: 1, + }, + }); + + await submitWorkflowStepForUrlScanning(105, "

No links here

", 1, false); + + // Should mark workflow step as verified + const workflowStep = await prismock.workflowStep.findUnique({ + where: { id: 105 }, + }); + expect(workflowStep?.verifiedAt).toBeTruthy(); + expect(tasker.create).not.toHaveBeenCalled(); + }); + }); + }); + + describe("submitUrlForUrlScanning", () => { + describe("happy paths", () => { + test("should create task for event type redirect URL", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + + await submitUrlForUrlScanning("https://redirect.com", 1, 50, false); + + expect(tasker.create).toHaveBeenCalledWith( + "scanWorkflowUrls", + expect.objectContaining({ + userId: 1, + eventTypeId: 50, + urls: ["https://redirect.com"], + whitelistWorkflows: false, + }) + ); + }); + + test("should pass whitelistWorkflows parameter", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(true); + + await submitUrlForUrlScanning("https://redirect.com", 1, 50, true); + + expect(tasker.create).toHaveBeenCalledWith( + "scanWorkflowUrls", + expect.objectContaining({ + whitelistWorkflows: true, + }) + ); + }); + }); + + describe("unhappy paths", () => { + test("should skip when URL scanning is disabled", async () => { + vi.mocked(urlScanner.isUrlScanningEnabled).mockReturnValue(false); + + await submitUrlForUrlScanning("https://redirect.com", 1, 50, false); + + expect(tasker.create).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 20bf534c81..f4c9b56e68 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -176,7 +176,7 @@ export const IS_VISUAL_REGRESSION_TESTING = Boolean(globalThis.window?.Meticulou export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt( process.env.NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD ?? "0", - 0 + 10 ); export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY; @@ -223,6 +223,10 @@ export const GOOGLE_CALENDAR_SCOPES = [ export const DIRECTORY_IDS_TO_LOG = process.env.DIRECTORY_IDS_TO_LOG?.split(",") || []; export const SCANNING_WORKFLOW_STEPS = !!(!IS_SELF_HOSTED && process.env.IFFY_API_KEY); +// Cloudflare URL Scanner - checks URLs for malicious content in workflows and event types +export const URL_SCANNING_ENABLED = + !!process.env.CLOUDFLARE_URL_SCANNER_API_TOKEN && !!process.env.CLOUDFLARE_ACCOUNT_ID; + export const IS_DUB_REFERRALS_ENABLED = !!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID && process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== ""; diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 4fbaf38a8e..4c07039985 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -9,11 +9,13 @@ import { allowDisablingAttendeeConfirmationEmails, allowDisablingHostConfirmationEmails, } from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; +import { isUrlScanningEnabled } from "@calcom/features/ee/workflows/lib/urlScanner"; import { HashedLinkRepository } from "@calcom/features/hashedLink/lib/repository/HashedLinkRepository"; import { HashedLinkService } from "@calcom/features/hashedLink/lib/service/HashedLinkService"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { ScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; import tasker from "@calcom/features/tasker"; +import { submitUrlForUrlScanning } from "@calcom/features/tasker/tasks/scanWorkflowUrls"; import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder"; import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; @@ -601,7 +603,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }, }); // Make sure the secondary email id belongs to the current user and its a verified one - if (secondaryEmail && secondaryEmail.emailVerified) { + if (secondaryEmail?.emailVerified) { data.secondaryEmail = { connect: { id: secondaryEmailId, @@ -752,6 +754,11 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); } + // Scan redirect URL for malicious content if URL scanning is enabled + if (isUrlScanningEnabled() && rest.successRedirectUrl) { + await submitUrlForUrlScanning(rest.successRedirectUrl, ctx.user.id, id); + } + const res = ctx.res as NextApiResponse; if (typeof res?.revalidate !== "undefined") { try {