feat: add Cloudflare URL Scanner for malicious URL detection (#26387)

* feat: add Cloudflare URL Scanner integration for malicious URL detection

- Add MALICIOUS_URL_IN_WORKFLOW to LockReason enum
- Add URL_SCANNING_ENABLED constant for feature flag
- Create urlScanner.ts utility for Cloudflare Radar URL Scanner API
- Create scanWorkflowUrls task for async URL scanning with polling
- Integrate URL scanning into scanWorkflowBody task
- Add URL scanning for event type redirect URLs
- Lock user accounts when malicious URLs are detected
- Fix pre-existing lint issues (parseInt radix, optional chaining)

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: address biome lint warnings and TypeScript iterator errors

- Wrap iterators with Array.from() to fix TS2802 errors
- Add biome-ignore comments for process.env usage
- Extract helper functions to reduce function length
- Move exports to end of file per useExportsLast rule
- Remove problematic imports that cause TypeScript errors

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: address cubic-dev-ai review comments for URL scanning

- Fix P0: Re-fetch workflow steps before scheduling notifications to use actual verifiedAt values from database instead of overriding with new Date()
- Fix P1: Mark workflow step as verified in submitWorkflowStepForUrlScanning when URL scanning is disabled or no URLs found
- Fix P1: Add whitelistWorkflows parameter to submitUrlForUrlScanning for consistency
- Fix P2: Preserve URL context in error results in urlScanner.ts scanUrls function

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: add select clause to Prisma query for workflow steps

Address cubic-dev-ai P2 comment: Use select to fetch only the required
fields (id, action, sendTo, emailSubject, reminderBody, template, sender,
verifiedAt) instead of fetching all columns from workflowStep table.

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: add select clause to Prisma query in scanWorkflowBody.ts

Address cubic-dev-ai P2 review comment: Use select to fetch only the
required fields (id, action, sendTo, emailSubject, reminderBody,
template, sender, verifiedAt) instead of fetching all columns.

Co-Authored-By: peer@cal.com <peer@cal.com>

* test: add unit tests for URL scanning functionality

- Add tests for urlScanner.ts (extractUrlsFromHtml, isUrlScanningEnabled)
- Add tests for scanWorkflowUrls.ts (happy/unhappy paths for URL scanning task)
- Add tests for scanWorkflowBody.ts (happy/unhappy paths for workflow body scanning)

Tests cover:
- URL extraction from HTML content
- URL normalization and deduplication
- Handling of malicious URLs and user locking
- Fail-open behavior for API errors
- Whitelisted user handling
- Iffy spam detection integration

Co-Authored-By: peer@cal.com <peer@cal.com>

* test: remove incomplete test that provides no value

Removed the 'should mark all steps as verified when neither Iffy nor URL scanning is enabled' test as it used vi.doMock() which doesn't work after module import, had no assertions, and gave false confidence in test coverage.

Co-Authored-By: peer@cal.com <peer@cal.com>

* refactor: use Cloudflare bulk scanning endpoint to reduce API quota usage

- Added submitUrlsForBulkScanning function that uses /urlscanner/v2/bulk endpoint
- Updated scanUrls to use bulk submission instead of individual URL submissions
- Bulk endpoint accepts up to 100 URLs per request, batching is handled automatically
- Reduces API quota usage as suggested by keithwillcode

Co-Authored-By: peer@cal.com <peer@cal.com>

* Update packages/features/tasker/tasks/scanWorkflowUrls.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: address cubic-dev-ai review comments

- P1: Sanitize URLs before logging to prevent exposing sensitive query parameters
- P2: Extract handleUrlScanningForStep helper function to reduce code duplication
- P2: Use vi.stubGlobal for fetch mock in tests for proper cleanup

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: address volnei review comments on PR #26387

- Move vi.unstubAllGlobals() to afterEach hook in iffyScanBody tests
- Restore updateMany optimization when URL scanning is disabled

Co-Authored-By: peer@cal.com <peer@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Peer Richelsen
2026-01-21 12:28:57 -03:00
committed by GitHub
co-authored by peer@cal.com <peer@cal.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Volnei Munhoz cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent a03722fd7f
commit cfb1db45b2
11 changed files with 1959 additions and 23 deletions
@@ -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;
@@ -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 = '<a href="https://example.com">Click here</a>';
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 = `
<a href="https://example.com">Link 1</a>
<a href="https://another.com/page">Link 2</a>
`;
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 = "<p>Visit https://example.com for more info</p>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should extract both href and bare URLs", () => {
const html = `
<a href="https://link.com">Click</a>
<p>Also visit https://bare.com</p>
`;
const result = extractUrlsFromHtml(html);
expect(result).toContain("https://link.com/");
expect(result).toContain("https://bare.com/");
});
test("should deduplicate URLs", () => {
const html = `
<a href="https://example.com">Link 1</a>
<a href="https://example.com">Link 2</a>
<p>https://example.com</p>
`;
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should handle URLs with paths and query strings", () => {
const html = '<a href="https://example.com/path?query=value&foo=bar">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/path?query=value&foo=bar"]);
});
test("should handle http URLs", () => {
const html = '<a href="http://example.com">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["http://example.com/"]);
});
test("should handle single-quoted href attributes", () => {
const html = "<a href='https://example.com'>Link</a>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should clean trailing punctuation from bare URLs", () => {
const html = "<p>Visit https://example.com. More text here.</p>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should handle URLs with ports", () => {
const html = '<a href="https://example.com:8080/path">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com:8080/path"]);
});
test("should handle URLs with fragments", () => {
const html = '<a href="https://example.com/page#section">Link</a>';
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 = "<p>No links here</p>";
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore mailto links", () => {
const html = '<a href="mailto:test@example.com">Email us</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore tel links", () => {
const html = '<a href="tel:+1234567890">Call us</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore javascript links", () => {
const html = '<a href="javascript:void(0)">Click</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore relative URLs", () => {
const html = '<a href="/relative/path">Link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should ignore anchor-only links", () => {
const html = '<a href="#section">Jump to section</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
test("should handle malformed HTML gracefully", () => {
const html = '<a href="https://example.com">Unclosed link';
const result = extractUrlsFromHtml(html);
expect(result).toEqual(["https://example.com/"]);
});
test("should handle empty href attributes", () => {
const html = '<a href="">Empty link</a>';
const result = extractUrlsFromHtml(html);
expect(result).toEqual([]);
});
});
describe("URL normalization", () => {
test("should normalize URLs with trailing slash", () => {
const html = `
<a href="https://example.com/">Link 1</a>
<a href="https://example.com">Link 2</a>
`;
const result = extractUrlsFromHtml(html);
// Both should normalize to the same URL
expect(result.length).toBe(1);
});
test("should preserve path when normalizing", () => {
const html = '<a href="https://example.com/path/">Link</a>';
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);
});
});
});
@@ -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<string>();
// 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<string, { scanId: string } | { error: string }>
): 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<Map<string, { scanId: string } | { error: string }>> {
const results = new Map<string, { scanId: string } | { error: string }>();
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<UrlScanResult | null> {
// 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<UrlScanResult[]> {
if (!URL_SCANNING_ENABLED || urls.length === 0) {
return [];
}
const results: UrlScanResult[] = [];
const pendingScans: Map<string, { url: string; attempts: number }> = 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,
};
+1
View File
@@ -25,6 +25,7 @@ type TaskPayloads = {
createCRMEvent: z.infer<typeof import("./tasks/crm/schema").createCRMEventSchema>;
sendWorkflowEmails: z.infer<typeof import("./tasks/sendWorkflowEmails").ZSendWorkflowEmailsSchema>;
scanWorkflowBody: z.infer<typeof import("./tasks/scanWorkflowBody").scanWorkflowBodySchema>;
scanWorkflowUrls: z.infer<typeof import("./tasks/scanWorkflowUrls").scanWorkflowUrlsSchema>;
sendAnalyticsEvent: z.infer<typeof import("./tasks/analytics/schema").sendAnalyticsEventSchema>;
executeAIPhoneCall: {
workflowReminderId: number;
+1
View File
@@ -27,6 +27,7 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
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),
@@ -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<void> {
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,
@@ -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<void> {
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<void> {
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,
});
}
@@ -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: '<p>Hello <a href="https://example.com">click here</a></p>',
},
});
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,
'<p>Hello <a href="https://example.com">click here</a></p>',
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: '<p>Hello <a href="https://test.com">link</a></p>',
},
});
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: '<p>Hello <a href="https://example.com">link</a></p>',
},
});
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();
});
});
});
@@ -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,
'<a href="https://example.com">Link</a>',
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,
'<a href="https://example.com">Link</a>',
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,
'<a href="https://example.com">Link</a>',
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, "<p>No links here</p>", 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();
});
});
});
});
+5 -1
View File
@@ -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 !== "";
@@ -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 {