d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
1644 lines
45 KiB
TypeScript
1644 lines
45 KiB
TypeScript
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<T extends string>(
|
|
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 }
|
|
}
|