diff --git a/apps/sim/app/api/tools/confluence/page-descendants/route.ts b/apps/sim/app/api/tools/confluence/page-descendants/route.ts index ecdc500591..e437b73750 100644 --- a/apps/sim/app/api/tools/confluence/page-descendants/route.ts +++ b/apps/sim/app/api/tools/confluence/page-descendants/route.ts @@ -1,7 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAlphanumericId, + validateJiraCloudId, + validatePaginationCursor, +} from '@/lib/core/security/input-validation' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageDescendantsAPI') @@ -50,6 +54,10 @@ export async function POST(request: NextRequest) { queryParams.append('limit', String(Math.min(limit, 250))) if (cursor) { + const cursorValidation = validatePaginationCursor(cursor, 'cursor') + if (!cursorValidation.isValid) { + return NextResponse.json({ error: cursorValidation.error }, { status: 400 }) + } queryParams.append('cursor', cursor) } diff --git a/apps/sim/app/api/tools/confluence/page-versions/route.ts b/apps/sim/app/api/tools/confluence/page-versions/route.ts index cdd77f4e76..799fc28b0a 100644 --- a/apps/sim/app/api/tools/confluence/page-versions/route.ts +++ b/apps/sim/app/api/tools/confluence/page-versions/route.ts @@ -1,7 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAlphanumericId, + validateJiraCloudId, + validateNumericId, + validatePaginationCursor, +} from '@/lib/core/security/input-validation' import { cleanHtmlContent, getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageVersionsAPI') @@ -57,8 +62,14 @@ export async function POST(request: NextRequest) { // If versionNumber is provided, get specific version with page content if (versionNumber !== undefined && versionNumber !== null) { - const versionUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}` - const pageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?version=${versionNumber}&body-format=storage` + const versionValidation = validateNumericId(versionNumber, 'versionNumber', { min: 1 }) + if (!versionValidation.isValid) { + return NextResponse.json({ error: versionValidation.error }, { status: 400 }) + } + const safeVersion = versionValidation.sanitized + + const versionUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${safeVersion}` + const pageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?version=${safeVersion}&body-format=storage` logger.info(`Fetching version ${versionNumber} for page ${pageId}`) @@ -135,6 +146,10 @@ export async function POST(request: NextRequest) { queryParams.append('limit', String(Math.min(limit, 250))) if (cursor) { + const cursorValidation = validatePaginationCursor(cursor, 'cursor') + if (!cursorValidation.isValid) { + return NextResponse.json({ error: cursorValidation.error }, { status: 400 }) + } queryParams.append('cursor', cursor) } diff --git a/apps/sim/app/api/tools/confluence/space-permissions/route.ts b/apps/sim/app/api/tools/confluence/space-permissions/route.ts index c1a063e2cf..8a046a0f6c 100644 --- a/apps/sim/app/api/tools/confluence/space-permissions/route.ts +++ b/apps/sim/app/api/tools/confluence/space-permissions/route.ts @@ -1,7 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAlphanumericId, + validateJiraCloudId, + validatePaginationCursor, +} from '@/lib/core/security/input-validation' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpacePermissionsAPI') @@ -50,6 +54,10 @@ export async function POST(request: NextRequest) { queryParams.append('limit', String(Math.min(limit, 250))) if (cursor) { + const cursorValidation = validatePaginationCursor(cursor, 'cursor') + if (!cursorValidation.isValid) { + return NextResponse.json({ error: cursorValidation.error }, { status: 400 }) + } queryParams.append('cursor', cursor) } diff --git a/apps/sim/app/api/tools/confluence/space-properties/route.ts b/apps/sim/app/api/tools/confluence/space-properties/route.ts index dce10a74cb..5bdd176aff 100644 --- a/apps/sim/app/api/tools/confluence/space-properties/route.ts +++ b/apps/sim/app/api/tools/confluence/space-properties/route.ts @@ -1,7 +1,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAlphanumericId, + validateJiraCloudId, + validatePaginationCursor, +} from '@/lib/core/security/input-validation' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpacePropertiesAPI') @@ -82,7 +86,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 }) } - const url = `${baseUrl}/${propertyId}` + const url = `${baseUrl}/${encodeURIComponent(propertyId)}` logger.info(`Deleting space property ${propertyId} from space ${spaceId}`) @@ -148,7 +152,13 @@ export async function POST(request: NextRequest) { const queryParams = new URLSearchParams() queryParams.append('limit', String(Math.min(limit, 250))) - if (cursor) queryParams.append('cursor', cursor) + if (cursor) { + const cursorValidation = validatePaginationCursor(cursor, 'cursor') + if (!cursorValidation.isValid) { + return NextResponse.json({ error: cursorValidation.error }, { status: 400 }) + } + queryParams.append('cursor', cursor) + } const url = `${baseUrl}?${queryParams.toString()}` diff --git a/apps/sim/app/api/tools/confluence/tasks/route.ts b/apps/sim/app/api/tools/confluence/tasks/route.ts index 46031dcc4f..e74ca3b540 100644 --- a/apps/sim/app/api/tools/confluence/tasks/route.ts +++ b/apps/sim/app/api/tools/confluence/tasks/route.ts @@ -1,7 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { + validateAlphanumericId, + validateJiraCloudId, + validatePaginationCursor, + validatePathSegment, +} from '@/lib/core/security/input-validation' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceTasksAPI') @@ -180,11 +185,40 @@ export async function POST(request: NextRequest) { const queryParams = new URLSearchParams() queryParams.append('limit', String(Math.min(limit, 250))) - if (cursor) queryParams.append('cursor', cursor) + if (cursor) { + const cursorValidation = validatePaginationCursor(cursor, 'cursor') + if (!cursorValidation.isValid) { + return NextResponse.json({ error: cursorValidation.error }, { status: 400 }) + } + queryParams.append('cursor', cursor) + } if (taskStatus) queryParams.append('status', taskStatus) - if (pageId) queryParams.append('page-id', pageId) - if (spaceId) queryParams.append('space-id', spaceId) - if (assignedTo) queryParams.append('assigned-to', assignedTo) + if (pageId) { + const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255) + if (!pageIdValidation.isValid) { + return NextResponse.json({ error: pageIdValidation.error }, { status: 400 }) + } + queryParams.append('page-id', pageId) + } + if (spaceId) { + const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255) + if (!spaceIdValidation.isValid) { + return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) + } + queryParams.append('space-id', spaceId) + } + if (assignedTo) { + // Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134 + const assignedToValidation = validatePathSegment(assignedTo, { + paramName: 'assignedTo', + maxLength: 128, + customPattern: /^[a-zA-Z0-9_|:-]+$/, + }) + if (!assignedToValidation.isValid) { + return NextResponse.json({ error: assignedToValidation.error }, { status: 400 }) + } + queryParams.append('assigned-to', assignedTo) + } const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks?${queryParams.toString()}` diff --git a/apps/sim/app/api/tools/confluence/user/route.ts b/apps/sim/app/api/tools/confluence/user/route.ts index 68ea52bd1d..5b81116d80 100644 --- a/apps/sim/app/api/tools/confluence/user/route.ts +++ b/apps/sim/app/api/tools/confluence/user/route.ts @@ -34,11 +34,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Account ID is required' }, { status: 400 }) } - // Atlassian account IDs use format like 557058:6b9c9931-4693-49c1-8b3a-931f1af98134 + // Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134 const accountIdValidation = validatePathSegment(accountId, { paramName: 'accountId', - maxLength: 255, - customPattern: /^[a-zA-Z0-9:-]+$/, + maxLength: 128, + customPattern: /^[a-zA-Z0-9_|:-]+$/, }) if (!accountIdValidation.isValid) { return NextResponse.json({ error: accountIdValidation.error }, { status: 400 }) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 06bc41b069..cd005277ba 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1039,3 +1039,74 @@ export function validateGoogleCalendarId( 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`, + } + } + + // Allow alphanumeric, base64 chars (+, /, =), and URL-safe chars (-, _, ., ~, %) + 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 } +}