fix(confluence): add input validation for SSRF-flagged parameters (#3351)

This commit is contained in:
Waleed
2026-02-25 23:35:45 -08:00
committed by GitHub
parent ba7d6ff298
commit fbafe204e5
7 changed files with 162 additions and 16 deletions
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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()}`
@@ -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()}`
@@ -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 })
@@ -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 }
}