* 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>
442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
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,
|
|
};
|