import { createLogger } from '@sim/logger' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('InputValidation') export interface ValidationResult { isValid: boolean error?: string sanitized?: string } export interface PathSegmentOptions { /** Name of the parameter for error messages */ paramName?: string /** Maximum length allowed (default: 255) */ maxLength?: number /** Allow hyphens (default: true) */ allowHyphens?: boolean /** Allow underscores (default: true) */ allowUnderscores?: boolean /** Allow dots (default: false, to prevent directory traversal) */ allowDots?: boolean /** Custom regex pattern to match */ customPattern?: RegExp } /** * Validates a path segment to prevent path traversal and SSRF attacks * * This function ensures that user-provided input used in URL paths or file paths * cannot be used for directory traversal attacks or SSRF. * * Default behavior: * - Allows: letters (a-z, A-Z), numbers (0-9), hyphens (-), underscores (_) * - Blocks: dots (.), slashes (/, \), null bytes, URL encoding, and special characters * * @param value - The path segment to validate * @param options - Validation options * @returns ValidationResult with isValid flag and optional error message * * @example * ```typescript * const result = validatePathSegment(itemId, { paramName: 'itemId' }) * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validatePathSegment( value: string | null | undefined, options: PathSegmentOptions = {} ): ValidationResult { const { paramName = 'path segment', maxLength = 255, allowHyphens = true, allowUnderscores = true, allowDots = false, customPattern, } = options if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (value.length > maxLength) { logger.warn('Path segment exceeds maximum length', { paramName, length: value.length, maxLength, }) return { isValid: false, error: `${paramName} exceeds maximum length of ${maxLength} characters`, } } if (value.includes('\0') || value.includes('%00')) { logger.warn('Path segment contains null bytes', { paramName }) return { isValid: false, error: `${paramName} contains invalid characters`, } } const pathTraversalPatterns = [ '..', './', '.\\.', '%2e%2e', '%252e%252e', '..%2f', '..%5c', '%2e%2e%2f', '%2e%2e/', '..%252f', ] const lowerValue = value.toLowerCase() for (const pattern of pathTraversalPatterns) { if (lowerValue.includes(pattern.toLowerCase())) { logger.warn('Path traversal attempt detected', { paramName, pattern, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} contains invalid path traversal sequences`, } } } if (value.includes('/') || value.includes('\\')) { logger.warn('Path segment contains directory separators', { paramName }) return { isValid: false, error: `${paramName} cannot contain directory separators`, } } if (customPattern) { if (!customPattern.test(value)) { logger.warn('Path segment failed custom pattern validation', { paramName, pattern: customPattern.toString(), }) return { isValid: false, error: `${paramName} format is invalid`, } } return { isValid: true, sanitized: value } } let pattern = '^[a-zA-Z0-9' if (allowHyphens) pattern += '\\-' if (allowUnderscores) pattern += '_' if (allowDots) pattern += '\\.' pattern += ']+$' const regex = new RegExp(pattern) if (!regex.test(value)) { logger.warn('Path segment contains disallowed characters', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} can only contain alphanumeric characters${allowHyphens ? ', hyphens' : ''}${allowUnderscores ? ', underscores' : ''}${allowDots ? ', dots' : ''}`, } } return { isValid: true, sanitized: value } } /** * Validates an alphanumeric ID (letters, numbers, hyphens, underscores only) * * @param value - The ID to validate * @param paramName - Name of the parameter for error messages * @param maxLength - Maximum length (default: 100) * @returns ValidationResult * * @example * ```typescript * const result = validateAlphanumericId(userId, 'userId') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateAlphanumericId( value: string | null | undefined, paramName = 'ID', maxLength = 100 ): ValidationResult { return validatePathSegment(value, { paramName, maxLength, allowHyphens: true, allowUnderscores: true, allowDots: false, }) } /** * Validates a numeric ID * * @param value - The ID to validate * @param paramName - Name of the parameter for error messages * @param options - Additional options (min, max) * @returns ValidationResult with sanitized number as string * * @example * ```typescript * const result = validateNumericId(pageNumber, 'pageNumber', { min: 1, max: 1000 }) * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateNumericId( value: string | number | null | undefined, paramName = 'ID', options: { min?: number; max?: number } = {} ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } const num = typeof value === 'number' ? value : Number(value) if (Number.isNaN(num) || !Number.isFinite(num)) { logger.warn('Invalid numeric ID', { paramName, value }) return { isValid: false, error: `${paramName} must be a valid number`, } } if (options.min !== undefined && num < options.min) { return { isValid: false, error: `${paramName} must be at least ${options.min}`, } } if (options.max !== undefined && num > options.max) { return { isValid: false, error: `${paramName} must be at most ${options.max}`, } } return { isValid: true, sanitized: num.toString() } } /** * Validates an integer value (from JSON body or other sources) * * This is stricter than validateNumericId - it requires: * - Value must already be a number type (not string) * - Must be an integer (no decimals) * - Must be finite (not NaN or Infinity) * * @param value - The value to validate * @param paramName - Name of the parameter for error messages * @param options - Additional options (min, max) * @returns ValidationResult * * @example * ```typescript * const result = validateInteger(failedCount, 'failedCount', { min: 0 }) * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateInteger( value: unknown, paramName = 'value', options: { min?: number; max?: number } = {} ): ValidationResult { if (value === null || value === undefined) { return { isValid: false, error: `${paramName} is required`, } } if (typeof value !== 'number') { logger.warn('Value is not a number', { paramName, valueType: typeof value }) return { isValid: false, error: `${paramName} must be a number`, } } if (Number.isNaN(value) || !Number.isFinite(value)) { logger.warn('Invalid number value', { paramName, value }) return { isValid: false, error: `${paramName} must be a valid number`, } } if (!Number.isInteger(value)) { logger.warn('Value is not an integer', { paramName, value }) return { isValid: false, error: `${paramName} must be an integer`, } } if (options.min !== undefined && value < options.min) { return { isValid: false, error: `${paramName} must be at least ${options.min}`, } } if (options.max !== undefined && value > options.max) { return { isValid: false, error: `${paramName} must be at most ${options.max}`, } } return { isValid: true } } /** * Validates that a value is in an allowed list (enum validation) * * @param value - The value to validate * @param allowedValues - Array of allowed values * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateEnum(type, ['note', 'contact', 'task'], 'type') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateEnum( value: string | null | undefined, allowedValues: readonly T[], paramName = 'value' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (!allowedValues.includes(value as T)) { logger.warn('Value not in allowed list', { paramName, value, allowedValues, }) return { isValid: false, error: `${paramName} must be one of: ${allowedValues.join(', ')}`, } } return { isValid: true, sanitized: value } } /** * Validates a hostname to prevent SSRF attacks * * This function checks that a hostname is not a private IP, localhost, or other reserved address. * It complements the validateProxyUrl function by providing hostname-specific validation. * * @param hostname - The hostname to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateHostname(webhookDomain, 'webhook domain') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateHostname( hostname: string | null | undefined, paramName = 'hostname' ): ValidationResult { if (hostname === null || hostname === undefined || hostname === '') { return { isValid: false, error: `${paramName} is required`, } } const lowerHostname = hostname.toLowerCase() if (lowerHostname === 'localhost') { logger.warn('Hostname is localhost', { paramName }) return { isValid: false, error: `${paramName} cannot be a private IP address or localhost`, } } if (ipaddr.isValid(lowerHostname)) { if (isPrivateOrReservedIP(lowerHostname)) { logger.warn('Hostname matches blocked IP range', { paramName, hostname: hostname.substring(0, 100), }) return { isValid: false, error: `${paramName} cannot be a private IP address or localhost`, } } } const hostnamePattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i if (!hostnamePattern.test(hostname)) { logger.warn('Invalid hostname format', { paramName, hostname: hostname.substring(0, 100), }) return { isValid: false, error: `${paramName} is not a valid hostname`, } } return { isValid: true, sanitized: hostname } } /** * Validates a file extension * * @param extension - The file extension (with or without leading dot) * @param allowedExtensions - Array of allowed extensions (without dots) * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateFileExtension(ext, ['jpg', 'png', 'gif'], 'file extension') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateFileExtension( extension: string | null | undefined, allowedExtensions: readonly string[], paramName = 'file extension' ): ValidationResult { if (extension === null || extension === undefined || extension === '') { return { isValid: false, error: `${paramName} is required`, } } const ext = extension.startsWith('.') ? extension.slice(1) : extension const normalizedExt = ext.toLowerCase() if (!allowedExtensions.map((e) => e.toLowerCase()).includes(normalizedExt)) { logger.warn('File extension not in allowed list', { paramName, extension: ext, allowedExtensions, }) return { isValid: false, error: `${paramName} must be one of: ${allowedExtensions.join(', ')}`, } } return { isValid: true, sanitized: normalizedExt } } /** * Validates Microsoft Graph API resource IDs * * Microsoft Graph IDs can be complex - for example, SharePoint site IDs can include: * - "root" (literal string) * - GUIDs * - Hostnames with colons and slashes (e.g., "hostname:/sites/sitename") * - Group paths (e.g., "groups/{guid}/sites/root") * * This function allows these legitimate patterns while blocking path traversal. * * @param value - The Microsoft Graph ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateMicrosoftGraphId(siteId, 'siteId') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateMicrosoftGraphId( value: string | null | undefined, paramName = 'ID' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } const pathTraversalPatterns = [ '../', '..\\', '%2e%2e%2f', '%2e%2e/', '..%2f', '%2e%2e%5c', '%2e%2e\\', '..%5c', '%252e%252e%252f', ] const lowerValue = value.toLowerCase() for (const pattern of pathTraversalPatterns) { if (lowerValue.includes(pattern)) { logger.warn('Path traversal attempt in Microsoft Graph ID', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} contains invalid path traversal sequence`, } } } if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) { logger.warn('Control characters in Microsoft Graph ID', { paramName }) return { isValid: false, error: `${paramName} contains invalid control characters`, } } if (value.includes('\n') || value.includes('\r')) { return { isValid: false, error: `${paramName} contains invalid newline characters`, } } return { isValid: true, sanitized: value } } /** * Validates SharePoint site IDs used in Microsoft Graph API. * * Site IDs are compound identifiers: `hostname,spsite-guid,spweb-guid` * (e.g. `contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE`). * The API also accepts partial forms like a single GUID or just a hostname. * * Allowed characters: alphanumeric, periods, hyphens, and commas. * * @param value - The SharePoint site ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult */ export function validateSharePointSiteId( value: string | null | undefined, paramName = 'siteId' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (value.length > 512) { return { isValid: false, error: `${paramName} exceeds maximum length`, } } if (!/^[a-zA-Z0-9.\-,]+$/.test(value)) { logger.warn('Invalid characters in SharePoint site ID', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} contains invalid characters`, } } return { isValid: true, sanitized: value } } /** * Validates Jira Cloud IDs (typically UUID format) * * @param value - The Jira Cloud ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateJiraCloudId(cloudId, 'cloudId') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateJiraCloudId( value: string | null | undefined, paramName = 'cloudId' ): ValidationResult { return validatePathSegment(value, { paramName, allowHyphens: true, allowUnderscores: false, allowDots: false, maxLength: 100, }) } /** * Validates an Atlassian Assets workspace ID (a UUID-shaped, hyphenated * alphanumeric identifier) before it is interpolated into an API path. * * @param value - The Assets workspace ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult */ export function validateAssetsWorkspaceId( value: string | null | undefined, paramName = 'workspaceId' ): ValidationResult { return validatePathSegment(value, { paramName, allowHyphens: true, allowUnderscores: false, allowDots: false, maxLength: 100, }) } /** * Validates Jira issue keys (format: PROJECT-123 or PROJECT-KEY-123) * * @param value - The Jira issue key to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateJiraIssueKey(issueKey, 'issueKey') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateJiraIssueKey( value: string | null | undefined, paramName = 'issueKey' ): ValidationResult { return validatePathSegment(value, { paramName, allowHyphens: true, allowUnderscores: false, allowDots: false, maxLength: 255, }) } /** * Validates a URL to prevent SSRF attacks * * This function checks that URLs: * - Use https:// protocol only * - Do not point to private IP ranges or localhost * - Do not use suspicious ports * * @param url - The URL to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateExternalUrl(url, 'fileUrl') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateExternalUrl( url: string | null | undefined, paramName = 'url', options: { allowHttp?: boolean } = {} ): ValidationResult { if (!url || typeof url !== 'string') { return { isValid: false, error: `${paramName} is required and must be a string`, } } let parsedUrl: URL try { parsedUrl = new URL(url) } catch { return { isValid: false, error: `${paramName} must be a valid URL`, } } const protocol = parsedUrl.protocol const hostname = parsedUrl.hostname.toLowerCase() const cleanHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname let isLocalhost = cleanHostname === 'localhost' if (ipaddr.isValid(cleanHostname)) { const processedIP = ipaddr.process(cleanHostname).toString() if (processedIP === '127.0.0.1' || processedIP === '::1') { isLocalhost = true } } if (isLocalhost && isHosted) { return { isValid: false, error: `${paramName} cannot point to localhost`, } } if (options.allowHttp) { if (protocol !== 'https:' && protocol !== 'http:') { return { isValid: false, error: `${paramName} must use http:// or https:// protocol`, } } } else if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost && !isHosted)) { return { isValid: false, error: `${paramName} must use https:// protocol`, } } if (!isLocalhost && ipaddr.isValid(cleanHostname)) { if (isPrivateOrReservedIP(cleanHostname)) { return { isValid: false, error: `${paramName} cannot point to private IP addresses`, } } } const port = parsedUrl.port const blockedPorts = ['22', '23', '25', '3306', '5432', '6379', '27017', '9200'] if (port && blockedPorts.includes(port)) { return { isValid: false, error: `${paramName} uses a blocked port`, } } return { isValid: true } } /** * Validates an image URL to prevent SSRF attacks * Alias for validateExternalUrl for backward compatibility */ export function validateImageUrl( url: string | null | undefined, paramName = 'imageUrl' ): ValidationResult { return validateExternalUrl(url, paramName) } /** * Validates a proxy URL to prevent SSRF attacks * Alias for validateExternalUrl for backward compatibility */ export function validateProxyUrl( url: string | null | undefined, paramName = 'proxyUrl' ): ValidationResult { return validateExternalUrl(url, paramName) } /** * Checks if an IP address is private or reserved (not routable on the public internet) * Uses ipaddr.js for robust handling of all IP formats including: * - Octal notation (0177.0.0.1) * - Hex notation (0x7f000001) * - IPv4-mapped IPv6 (::ffff:127.0.0.1) * - Various edge cases that regex patterns miss */ function isPrivateOrReservedIP(ip: string): boolean { try { if (!ipaddr.isValid(ip)) { return true } const addr = ipaddr.process(ip) const range = addr.range() return range !== 'unicast' } catch { return true } } /** * Validates an Airtable ID (base, table, or webhook ID) * * Airtable IDs have specific prefixes: * - Base IDs: "app" + 14 alphanumeric characters (e.g., appXXXXXXXXXXXXXX) * - Table IDs: "tbl" + 14 alphanumeric characters * - Webhook IDs: "ach" + 14 alphanumeric characters * * @param value - The ID to validate * @param expectedPrefix - The expected prefix ('app', 'tbl', or 'ach') * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateAirtableId(baseId, 'app', 'baseId') * if (!result.isValid) { * throw new Error(result.error) * } * ``` */ export function validateAirtableId( value: string | null | undefined, expectedPrefix: 'app' | 'tbl' | 'ach', paramName = 'ID' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } const airtableIdPattern = new RegExp(`^${expectedPrefix}[a-zA-Z0-9]{14}$`) if (!airtableIdPattern.test(value)) { logger.warn('Invalid Airtable ID format', { paramName, expectedPrefix, value: value.substring(0, 20), }) return { isValid: false, error: `${paramName} must be a valid Airtable ID starting with "${expectedPrefix}"`, } } return { isValid: true, sanitized: value } } /** * Validates an AWS region identifier * * Supported region formats: * - Standard: us-east-1, eu-west-2, ap-southeast-1, sa-east-1, af-south-1 * - GovCloud: us-gov-east-1, us-gov-west-1 * - China: cn-north-1, cn-northwest-1 * - Israel: il-central-1 * - ISO partitions: us-iso-east-1, us-iso-west-1, us-isob-east-1 * - Mexico: mx-central-1 * - EU Sovereign Cloud: eu-isoe-west-1 * * @param value - The AWS region to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateAwsRegion(region, 'region') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateAwsRegion( value: string | null | undefined, paramName = 'region' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } const awsRegionPattern = /^(eu-isoe|us-isob|us-iso|us-gov|af|ap|ca|cn|eu|il|me|mx|sa|us)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/ if (!awsRegionPattern.test(value)) { logger.warn('Invalid AWS region format', { paramName, value: value.substring(0, 50), }) return { isValid: false, error: `${paramName} must be a valid AWS region (e.g., us-east-1, eu-west-2, us-gov-west-1)`, } } return { isValid: true, sanitized: value } } /** * Validates an S3 bucket name according to AWS naming rules * * S3 bucket names must: * - Be 3-63 characters long * - Start and end with a letter or number * - Contain only lowercase letters, numbers, and hyphens * - Not contain consecutive periods * - Not be formatted as an IP address * * @param value - The S3 bucket name to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateS3BucketName(bucket, 'bucket') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateS3BucketName( value: string | null | undefined, paramName = 'bucket' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (value.length < 3 || value.length > 63) { logger.warn('S3 bucket name length invalid', { paramName, length: value.length, }) return { isValid: false, error: `${paramName} must be between 3 and 63 characters`, } } const bucketNamePattern = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$|^[a-z0-9]$/ if (!bucketNamePattern.test(value)) { logger.warn('Invalid S3 bucket name format', { paramName, value: value.substring(0, 63), }) return { isValid: false, error: `${paramName} must start and end with a letter or number, and contain only lowercase letters, numbers, hyphens, and periods`, } } if (value.includes('..')) { logger.warn('S3 bucket name contains consecutive periods', { paramName }) return { isValid: false, error: `${paramName} cannot contain consecutive periods`, } } const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/ if (ipPattern.test(value)) { logger.warn('S3 bucket name formatted as IP address', { paramName }) return { isValid: false, error: `${paramName} cannot be formatted as an IP address`, } } return { isValid: true, sanitized: value } } /** * Validates a Google Calendar ID * * Google Calendar IDs can be: * - "primary" (literal string for the user's primary calendar) * - Email addresses (for user calendars) * - Alphanumeric strings with hyphens, underscores, and dots (for other calendars) * * This validator allows these legitimate formats while blocking path traversal and injection attempts. * * @param value - The calendar ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateGoogleCalendarId(calendarId, 'calendarId') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateGoogleCalendarId( value: string | null | undefined, paramName = 'calendarId' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (value === 'primary') { return { isValid: true, sanitized: value } } const pathTraversalPatterns = [ '../', '..\\', '%2e%2e%2f', '%2e%2e/', '..%2f', '%2e%2e%5c', '%2e%2e\\', '..%5c', '%252e%252e%252f', ] const lowerValue = value.toLowerCase() for (const pattern of pathTraversalPatterns) { if (lowerValue.includes(pattern)) { logger.warn('Path traversal attempt in Google Calendar ID', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} contains invalid path traversal sequence`, } } } if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) { logger.warn('Control characters in Google Calendar ID', { paramName }) return { isValid: false, error: `${paramName} contains invalid control characters`, } } if (value.includes('\n') || value.includes('\r')) { return { isValid: false, error: `${paramName} contains invalid newline characters`, } } const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ if (emailPattern.test(value)) { return { isValid: true, sanitized: value } } const calendarIdPattern = /^[a-zA-Z0-9._@%#+-]+$/ if (!calendarIdPattern.test(value)) { logger.warn('Invalid Google Calendar ID format', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} format is invalid. Must be "primary", an email address, or an alphanumeric ID`, } } if (value.length > 255) { logger.warn('Google Calendar ID exceeds maximum length', { paramName, length: value.length, }) return { isValid: false, error: `${paramName} exceeds maximum length of 255 characters`, } } return { isValid: true, sanitized: value } } /** * Validates a pagination cursor token * * Pagination cursors are opaque tokens returned by APIs (e.g., Confluence, Jira) * and passed back to get the next page. They are typically base64-encoded or * URL-safe strings. This validator ensures the cursor cannot contain characters * that could alter URL structure. * * @param value - The cursor token to validate * @param paramName - Name of the parameter for error messages * @param maxLength - Maximum length (default: 1024) * @returns ValidationResult * * @example * ```typescript * if (cursor) { * const result = validatePaginationCursor(cursor, 'cursor') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * } * ``` */ export function validatePaginationCursor( value: string | null | undefined, paramName = 'cursor', maxLength = 1024 ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (value.length > maxLength) { logger.warn('Pagination cursor exceeds maximum length', { paramName, length: value.length, maxLength, }) return { isValid: false, error: `${paramName} exceeds maximum length of ${maxLength} characters`, } } if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) { logger.warn('Pagination cursor contains control characters', { paramName }) return { isValid: false, error: `${paramName} contains invalid characters`, } } const cursorPattern = /^[A-Za-z0-9+/=\-_.~%]+$/ if (!cursorPattern.test(value)) { logger.warn('Pagination cursor contains disallowed characters', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} contains invalid characters`, } } return { isValid: true, sanitized: value } } const CALLBACK_URL_SERVER_BASE = 'https://callback-url-validator.invalid' /** * Validates a callback URL to prevent open redirect attacks. * * Accepts: * - Same-origin relative references (e.g. `/workspace`, `/invite/abc?foo=bar`, `?q=1`) * - Absolute URLs whose origin matches the current origin * * Rejects: * - Cross-origin absolute URLs * - Protocol-relative URLs (`//evil.com`) and backslash variants (`/\evil.com`, * `\\evil.com`), which browsers resolve to an external origin * - Whitespace/control-character bypasses (`/\t/evil.com`, `/\n/evil.com`) — the * WHATWG URL parser strips these everywhere in the input, collapsing the value * to a protocol-relative reference * - Userinfo smuggling (`https://trusted.com@evil.com`) * - Opaque-origin schemes (`javascript:`, `data:`, `vbscript:`, etc.) * * Delegates parsing to `new URL()` so validation matches the browser's resolution * when the value is later assigned to `window.location.href`. This mirrors the * reference pattern from next-auth and follows the OWASP Unvalidated Redirects * cheat sheet guidance to never validate URLs with string operations. * * @param url - The callback URL to validate * @returns true if the URL is safe to redirect to */ export function validateCallbackUrl(url: string): boolean { try { if (typeof url !== 'string' || url.length === 0) return false const base = typeof window === 'undefined' ? CALLBACK_URL_SERVER_BASE : window.location.origin const parsed = new URL(url, base) return parsed.origin === base } catch (error) { logger.error('Error validating callback URL:', { error, url }) return false } } const OKTA_DOMAIN_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.(okta|okta-gov|okta-emea|oktapreview|trexcloud)\.com$/ /** * Validates and sanitizes an Okta domain to prevent SSRF. * Ensures the domain matches a known Okta domain suffix. * * @param rawDomain - The raw domain string (may include protocol, trailing slash, or whitespace) * @returns The cleaned, validated domain string * @throws Error if the domain does not match a known Okta domain suffix * * @example * ```typescript * const domain = validateOktaDomain(params.domain) * // Returns: "dev-123456.okta.com" * ``` */ export function validateOktaDomain(rawDomain: string): string { const domain = rawDomain .trim() .replace(/^https?:\/\//, '') .replace(/\/$/, '') if (!OKTA_DOMAIN_PATTERN.test(domain)) { throw new Error( `Invalid Okta domain: "${domain}". Must be a valid Okta domain (e.g., dev-123456.okta.com)` ) } return domain } const MICROSOFT_CONTENT_SUFFIXES = [ 'sharepoint.com', 'sharepoint.us', 'sharepoint.de', 'sharepoint.cn', 'sharepointonline.com', 'onedrive.com', 'onedrive.live.com', '1drv.ms', '1drv.com', 'microsoftpersonalcontent.com', ] as const /** * Returns true if the given URL is hosted on a trusted Microsoft SharePoint or * OneDrive domain. Validates the parsed hostname against an allowlist using exact * match or subdomain suffix, preventing incomplete-substring bypasses. * * Covers SharePoint Online (commercial, GCC/GCC High/DoD, Germany, China), * OneDrive business and consumer, OneDrive short-link and CDN domains, * and Microsoft personal content CDN. * * @see https://learn.microsoft.com/en-us/sharepoint/required-urls-and-ports * @see https://learn.microsoft.com/en-us/microsoft-365/enterprise/microsoft-365-u-s-government-gcc-high-endpoints * * @param url - The URL to check * @returns Whether the URL belongs to a trusted Microsoft content host */ /** * Validates a Monday.com numeric ID (board, item, webhook, workspace, user IDs). * * Monday.com uses numeric integer IDs for boards, items, webhooks, workspaces, and users. * These are always positive integers, represented as strings in GraphQL `ID!` scalars. * * @param value - The ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateMondayNumericId(boardId, 'boardId') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateMondayNumericId( value: string | number | null | undefined, paramName = 'ID' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } const str = String(value).trim() if (!/^\d+$/.test(str)) { logger.warn('Monday.com ID is not a valid numeric integer', { paramName, value: str.substring(0, 50), }) return { isValid: false, error: `${paramName} must be a numeric integer`, } } return { isValid: true, sanitized: str } } /** * Validates a Monday.com group ID. * * Monday.com group IDs are strings that can contain lowercase/uppercase letters, * digits, underscores, and spaces. They are user-visible identifiers like * "topics", "new_group", or "test group id". Auto-generated IDs may also * include "group_title" patterns. * * @param value - The group ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateMondayGroupId(groupId, 'groupId') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateMondayGroupId( value: string | null | undefined, paramName = 'groupId' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (value.length > 255) { logger.warn('Monday.com group ID exceeds maximum length', { paramName, length: value.length, }) return { isValid: false, error: `${paramName} exceeds maximum length of 255 characters`, } } if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) { logger.warn('Monday.com group ID contains control characters', { paramName }) return { isValid: false, error: `${paramName} contains invalid control characters`, } } if (!/^[a-zA-Z0-9_ ]+$/.test(value)) { logger.warn('Monday.com group ID contains disallowed characters', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} can only contain letters, digits, underscores, and spaces`, } } return { isValid: true, sanitized: value } } /** * Validates a Monday.com column ID. * * Column IDs are strings containing lowercase letters (a-z), digits (0-9), * and underscores. User-specified IDs are 1-20 characters of [a-z_]. * Auto-generated IDs follow patterns like "status", "date4", "email_mksr9hcd". * * @param value - The column ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateMondayColumnId(columnId, 'columnId') * if (!result.isValid) { * return NextResponse.json({ error: result.error }, { status: 400 }) * } * ``` */ export function validateMondayColumnId( value: string | null | undefined, paramName = 'columnId' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (value.length > 255) { logger.warn('Monday.com column ID exceeds maximum length', { paramName, length: value.length, }) return { isValid: false, error: `${paramName} exceeds maximum length of 255 characters`, } } if (!/^[a-z0-9_]+$/.test(value)) { logger.warn('Monday.com column ID contains disallowed characters', { paramName, value: value.substring(0, 100), }) return { isValid: false, error: `${paramName} can only contain lowercase letters, digits, and underscores`, } } return { isValid: true, sanitized: value } } /** * Validates a Supabase project ID. * * Supabase project IDs are 20-character lowercase alphanumeric strings * (e.g. "jdrkgepadsdopsntdlom"). This validator ensures the value cannot * contain URL-fragment (`#`), path-separator, or other characters that * would let an attacker break out of the `*.supabase.co` domain when the * ID is interpolated into a URL template. * * @param value - The project ID to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult */ export function validateSupabaseProjectId( value: string | null | undefined, paramName = 'projectId' ): ValidationResult { if (value === null || value === undefined || value === '') { return { isValid: false, error: `${paramName} is required`, } } if (!/^[a-z0-9]+$/.test(value)) { logger.warn('Invalid Supabase project ID format', { paramName, value: value.substring(0, 50), }) return { isValid: false, error: `${paramName} must contain only lowercase alphanumeric characters`, } } if (value.length < 10 || value.length > 40) { logger.warn('Supabase project ID length invalid', { paramName, length: value.length, }) return { isValid: false, error: `${paramName} must be between 10 and 40 characters`, } } return { isValid: true, sanitized: value } } export function isMicrosoftContentUrl(url: string): boolean { let hostname: string try { hostname = new URL(url).hostname.toLowerCase() } catch { return false } return MICROSOFT_CONTENT_SUFFIXES.some( (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`) ) } const SERVICENOW_ALLOWED_HOST_SUFFIXES = [ '.service-now.com', '.servicenow.com', '.servicenowservices.com', ] as const /** * Validates a ServiceNow instance URL to prevent SSRF attacks. * * ServiceNow instances are SaaS endpoints hosted on ServiceNow-owned domains. * Example valid formats: * - https://acme.service-now.com (standard commercial instances) * - https://acme.servicenow.com (newer commercial domain) * - https://acme.servicenowservices.com (GovCloud/FedRAMP) * * This validator ensures the URL: * - Is a valid HTTPS URL (reuses validateExternalUrl for IP/localhost/port checks) * - Has a hostname ending in a trusted ServiceNow-owned domain suffix * * Note: Customers using the Custom URLs plugin to front their instance with a * vanity CNAME (e.g. support.acme.com) will be rejected. Point the connector at * the underlying `*.service-now.com` host instead. * * @param url - The instance URL to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateServiceNowInstanceUrl(instanceUrl) * if (!result.isValid) { * throw new Error(result.error) * } * ``` */ export function validateServiceNowInstanceUrl( url: string | null | undefined, paramName = 'instanceUrl' ): ValidationResult { const urlResult = validateExternalUrl(url, paramName) if (!urlResult.isValid) return urlResult const hostname = new URL(url as string).hostname.toLowerCase() const isAllowedHost = SERVICENOW_ALLOWED_HOST_SUFFIXES.some( (suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix) ) if (!isAllowedHost) { logger.warn('ServiceNow instance URL hostname not on allowlist', { paramName, hostname: hostname.substring(0, 100), }) return { isValid: false, error: `${paramName} must be a ServiceNow-hosted domain (e.g., *.service-now.com, *.servicenow.com, or *.servicenowservices.com)`, } } return { isValid: true, sanitized: url as string } } const WORKDAY_ALLOWED_HOST_SUFFIXES = ['.workday.com', '.myworkday.com'] as const /** * Validates a Workday tenant URL to prevent SSRF attacks. * * Workday tenant URLs are SaaS endpoints hosted on Workday-owned domains. * Example valid formats: * - https://wd2-impl-services1.workday.com (implementation/sandbox tenants) * - https://wd5-services1.workday.com (production) * - https://wd5-services1.myworkday.com (production, customer-facing endpoint) * * This validator ensures the URL: * - Is a valid HTTPS URL (reuses validateExternalUrl for IP/localhost/port checks) * - Has a hostname ending in a trusted Workday-owned domain suffix * * @param url - The tenant URL to validate * @param paramName - Name of the parameter for error messages * @returns ValidationResult * * @example * ```typescript * const result = validateWorkdayTenantUrl(tenantUrl) * if (!result.isValid) { * throw new Error(result.error) * } * ``` */ export function validateWorkdayTenantUrl( url: string | null | undefined, paramName = 'tenantUrl' ): ValidationResult { const urlResult = validateExternalUrl(url, paramName) if (!urlResult.isValid) return urlResult const hostname = new URL(url as string).hostname.toLowerCase() const isAllowedHost = WORKDAY_ALLOWED_HOST_SUFFIXES.some( (suffix) => hostname === suffix.slice(1) || hostname.endsWith(suffix) ) if (!isAllowedHost) { logger.warn('Workday tenant URL hostname not on allowlist', { paramName, hostname: hostname.substring(0, 100), }) return { isValid: false, error: `${paramName} must be a Workday-hosted domain (e.g., *.workday.com or *.myworkday.com)`, } } return { isValid: true, sanitized: url as string } } /** * Validates a database identifier (table or column name) to prevent SQL injection. * * Accepts only identifiers that start with a letter or underscore and contain * only letters, digits, and underscores — the safe subset of SQL identifiers. * * @param value - The identifier to validate * @param paramName - Name of the parameter for error messages (e.g. 'table', 'column') * @returns ValidationResult with isValid flag and optional error message */ export function validateDatabaseIdentifier( value: unknown, paramName = 'identifier' ): ValidationResult { if (typeof value !== 'string' || value.length === 0) { return { isValid: false, error: `${paramName} is required` } } if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { logger.warn('Invalid database identifier', { paramName, value: value.substring(0, 100) }) return { isValid: false, error: `Invalid ${paramName}: must start with a letter or underscore and contain only letters, digits, and underscores`, } } return { isValid: true, sanitized: value } }