chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import { createEnvMock, envFlagsMock } from '@sim/testing'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/env', () =>
|
||||
createEnvMock({
|
||||
NEXT_PUBLIC_APP_URL: 'https://example.com',
|
||||
NEXT_PUBLIC_SOCKET_URL: 'https://socket.example.com',
|
||||
OLLAMA_URL: 'http://localhost:11434',
|
||||
S3_BUCKET_NAME: 'test-bucket',
|
||||
AWS_REGION: 'us-east-1',
|
||||
S3_KB_BUCKET_NAME: 'test-kb-bucket',
|
||||
S3_CHAT_BUCKET_NAME: 'test-chat-bucket',
|
||||
NEXT_PUBLIC_BRAND_LOGO_URL: 'https://brand.example.com/logo.png',
|
||||
NEXT_PUBLIC_BRAND_FAVICON_URL: 'https://brand.example.com/favicon.ico',
|
||||
NEXT_PUBLIC_PRIVACY_URL: 'https://legal.example.com/privacy',
|
||||
NEXT_PUBLIC_TERMS_URL: 'https://legal.example.com/terms',
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
|
||||
|
||||
import {
|
||||
addCSPSource,
|
||||
buildCSPString,
|
||||
buildTimeCSPDirectives,
|
||||
type CSPDirectives,
|
||||
generateRuntimeCSP,
|
||||
getChatEmbedCSPPolicy,
|
||||
getMainCSPPolicy,
|
||||
getWorkflowExecutionCSPPolicy,
|
||||
removeCSPSource,
|
||||
} from './csp'
|
||||
|
||||
describe('buildCSPString', () => {
|
||||
it('should build CSP string from directives', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'", "'unsafe-inline'"],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self'")
|
||||
expect(result).toContain("script-src 'self' 'unsafe-inline'")
|
||||
expect(result).toContain(';')
|
||||
})
|
||||
|
||||
it('should handle empty directives', () => {
|
||||
const directives: CSPDirectives = {}
|
||||
const result = buildCSPString(directives)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should skip empty source arrays', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': [],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self'")
|
||||
expect(result).not.toContain('script-src')
|
||||
})
|
||||
|
||||
it('should filter out empty string sources', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'", '', ' ', 'https://example.com'],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self' https://example.com")
|
||||
expect(result).not.toMatch(/\s{2,}/)
|
||||
})
|
||||
|
||||
it('should handle all directive types', () => {
|
||||
const directives: CSPDirectives = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': ["'self'"],
|
||||
'style-src': ["'self'"],
|
||||
'img-src': ["'self'", 'data:'],
|
||||
'media-src': ["'self'"],
|
||||
'font-src': ["'self'"],
|
||||
'connect-src': ["'self'"],
|
||||
'frame-src': ["'none'"],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'form-action': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'object-src': ["'none'"],
|
||||
}
|
||||
|
||||
const result = buildCSPString(directives)
|
||||
|
||||
expect(result).toContain("default-src 'self'")
|
||||
expect(result).toContain("script-src 'self'")
|
||||
expect(result).toContain("object-src 'none'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMainCSPPolicy', () => {
|
||||
it('should return a valid CSP policy string', () => {
|
||||
const policy = getMainCSPPolicy()
|
||||
|
||||
expect(policy).toContain("default-src 'self'")
|
||||
expect(policy).toContain('script-src')
|
||||
expect(policy).toContain('style-src')
|
||||
expect(policy).toContain('img-src')
|
||||
})
|
||||
|
||||
it('should include security directives', () => {
|
||||
const policy = getMainCSPPolicy()
|
||||
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).toContain("frame-ancestors 'self'")
|
||||
expect(policy).toContain("form-action 'self'")
|
||||
expect(policy).toContain("base-uri 'self'")
|
||||
})
|
||||
|
||||
it('should include necessary external resources', () => {
|
||||
const policy = getMainCSPPolicy()
|
||||
|
||||
expect(policy).toContain('https://fonts.googleapis.com')
|
||||
expect(policy).toContain('https://fonts.gstatic.com')
|
||||
expect(policy).toContain('https://*.google.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkflowExecutionCSPPolicy', () => {
|
||||
it('should return permissive CSP for workflow execution', () => {
|
||||
const policy = getWorkflowExecutionCSPPolicy()
|
||||
|
||||
expect(policy).toContain('default-src *')
|
||||
expect(policy).toContain("'unsafe-inline'")
|
||||
expect(policy).toContain("'unsafe-eval'")
|
||||
expect(policy).toContain('connect-src *')
|
||||
})
|
||||
|
||||
it('should be more permissive than main CSP', () => {
|
||||
const mainPolicy = getMainCSPPolicy()
|
||||
const execPolicy = getWorkflowExecutionCSPPolicy()
|
||||
|
||||
expect(execPolicy.length).toBeLessThan(mainPolicy.length)
|
||||
expect(execPolicy).toContain('*')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateRuntimeCSP', () => {
|
||||
it('should generate CSP with runtime environment variables', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).toContain("default-src 'self'")
|
||||
expect(csp).toContain('https://example.com')
|
||||
})
|
||||
|
||||
it('should include socket URL and WebSocket variant', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).toContain('https://socket.example.com')
|
||||
expect(csp).toContain('wss://socket.example.com')
|
||||
})
|
||||
|
||||
it('should include brand URLs', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).toContain('https://brand.example.com')
|
||||
})
|
||||
|
||||
it('should not have excessive whitespace', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
|
||||
expect(csp).not.toMatch(/\s{3,}/)
|
||||
expect(csp.trim()).toBe(csp)
|
||||
})
|
||||
|
||||
it('should allow blob URLs for iframe-based PDF previews', () => {
|
||||
const csp = generateRuntimeCSP()
|
||||
const frameSrcDirective = csp
|
||||
.split('; ')
|
||||
.find((directive) => directive.startsWith('frame-src '))
|
||||
|
||||
expect(frameSrcDirective).toBeDefined()
|
||||
expect(frameSrcDirective).toContain('blob:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addCSPSource', () => {
|
||||
const originalDirectives = structuredClone(buildTimeCSPDirectives)
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(buildTimeCSPDirectives).forEach((key) => {
|
||||
const k = key as keyof CSPDirectives
|
||||
buildTimeCSPDirectives[k] = originalDirectives[k]
|
||||
})
|
||||
})
|
||||
|
||||
it('should add a source to an existing directive', () => {
|
||||
const originalLength = buildTimeCSPDirectives['img-src']?.length || 0
|
||||
|
||||
addCSPSource('img-src', 'https://new-source.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('https://new-source.com')
|
||||
expect(buildTimeCSPDirectives['img-src']?.length).toBe(originalLength + 1)
|
||||
})
|
||||
|
||||
it('should not add duplicate sources', () => {
|
||||
addCSPSource('img-src', 'https://duplicate.com')
|
||||
const lengthAfterFirst = buildTimeCSPDirectives['img-src']?.length || 0
|
||||
|
||||
addCSPSource('img-src', 'https://duplicate.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']?.length).toBe(lengthAfterFirst)
|
||||
})
|
||||
|
||||
it('should create directive array if it does not exist', () => {
|
||||
;(buildTimeCSPDirectives as any)['worker-src'] = undefined
|
||||
|
||||
addCSPSource('script-src', 'https://worker.example.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['script-src']).toContain('https://worker.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeCSPSource', () => {
|
||||
const originalDirectives = structuredClone(buildTimeCSPDirectives)
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(buildTimeCSPDirectives).forEach((key) => {
|
||||
const k = key as keyof CSPDirectives
|
||||
buildTimeCSPDirectives[k] = originalDirectives[k]
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove a source from an existing directive', () => {
|
||||
addCSPSource('img-src', 'https://to-remove.com')
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('https://to-remove.com')
|
||||
|
||||
removeCSPSource('img-src', 'https://to-remove.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']).not.toContain('https://to-remove.com')
|
||||
})
|
||||
|
||||
it('should handle removing non-existent source gracefully', () => {
|
||||
const originalLength = buildTimeCSPDirectives['img-src']?.length || 0
|
||||
|
||||
removeCSPSource('img-src', 'https://non-existent.com')
|
||||
|
||||
expect(buildTimeCSPDirectives['img-src']?.length).toBe(originalLength)
|
||||
})
|
||||
|
||||
it('should handle removing from non-existent directive gracefully', () => {
|
||||
;(buildTimeCSPDirectives as any)['worker-src'] = undefined
|
||||
|
||||
expect(() => {
|
||||
removeCSPSource('script-src', 'https://anything.com')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTimeCSPDirectives', () => {
|
||||
it('should have all required security directives', () => {
|
||||
expect(buildTimeCSPDirectives['default-src']).toBeDefined()
|
||||
expect(buildTimeCSPDirectives['object-src']).toContain("'none'")
|
||||
expect(buildTimeCSPDirectives['frame-ancestors']).toContain("'self'")
|
||||
expect(buildTimeCSPDirectives['base-uri']).toContain("'self'")
|
||||
})
|
||||
|
||||
it('should have self as default source', () => {
|
||||
expect(buildTimeCSPDirectives['default-src']).toContain("'self'")
|
||||
})
|
||||
|
||||
it('should allow Google fonts', () => {
|
||||
expect(buildTimeCSPDirectives['style-src']).toContain('https://fonts.googleapis.com')
|
||||
expect(buildTimeCSPDirectives['font-src']).toContain('https://fonts.gstatic.com')
|
||||
})
|
||||
|
||||
it('should allow data: and blob: for images', () => {
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('data:')
|
||||
expect(buildTimeCSPDirectives['img-src']).toContain('blob:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getChatEmbedCSPPolicy', () => {
|
||||
it('allows iframe embedding from any origin', () => {
|
||||
expect(getChatEmbedCSPPolicy()).toContain('frame-ancestors *')
|
||||
})
|
||||
|
||||
it('allows Office.js to load from Microsoft for Excel/Word add-in embedding', () => {
|
||||
const policy = getChatEmbedCSPPolicy()
|
||||
expect(policy).toMatch(/script-src[^;]*https:\/\/appsforoffice\.microsoft\.com/)
|
||||
expect(policy).toMatch(/connect-src[^;]*https:\/\/appsforoffice\.microsoft\.com/)
|
||||
})
|
||||
|
||||
it('does not regress object-src or base-uri restrictions', () => {
|
||||
const policy = getChatEmbedCSPPolicy()
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).toContain("base-uri 'self'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
import { env, getEnv } from '../config/env'
|
||||
import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags'
|
||||
|
||||
/**
|
||||
* Content Security Policy (CSP) configuration builder
|
||||
*
|
||||
* NOTE: This file is loaded by next.config.ts at build time, before @/ path
|
||||
* aliases are resolved. Do NOT import from ../utils/urls (which uses @/ imports).
|
||||
* Keep all URL constants local to this file.
|
||||
*/
|
||||
|
||||
const DEFAULT_SOCKET_URL = 'http://localhost:3002'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434'
|
||||
|
||||
function toWebSocketUrl(httpUrl: string): string {
|
||||
return httpUrl.replace('http://', 'ws://').replace('https://', 'wss://')
|
||||
}
|
||||
|
||||
function getHostnameFromUrl(url: string | undefined): string[] {
|
||||
if (!url) return []
|
||||
try {
|
||||
return [`https://${new URL(url).hostname}`]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export interface CSPDirectives {
|
||||
'default-src'?: string[]
|
||||
'script-src'?: string[]
|
||||
'style-src'?: string[]
|
||||
'img-src'?: string[]
|
||||
'media-src'?: string[]
|
||||
'font-src'?: string[]
|
||||
'connect-src'?: string[]
|
||||
'worker-src'?: string[]
|
||||
'frame-src'?: string[]
|
||||
'frame-ancestors'?: string[]
|
||||
'form-action'?: string[]
|
||||
'base-uri'?: string[]
|
||||
'object-src'?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Static CSP sources shared between build-time and runtime.
|
||||
* Add new domains here — both paths pick them up automatically.
|
||||
*/
|
||||
const STATIC_SCRIPT_SRC = [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
...(isDev ? ["'unsafe-eval'"] : []),
|
||||
'https://*.google.com',
|
||||
'https://apis.google.com',
|
||||
'https://challenges.cloudflare.com',
|
||||
// Cal.com booking embed (landing /demo) — embed.js is served from app.cal.com
|
||||
'https://app.cal.com',
|
||||
...(isReactGrabEnabled ? ['https://unpkg.com'] : []),
|
||||
...(isHosted
|
||||
? [
|
||||
'https://www.googletagmanager.com',
|
||||
'https://www.google-analytics.com',
|
||||
'https://analytics.ahrefs.com',
|
||||
// HubSpot tracking (landing pages) — loader plus the
|
||||
// analytics/form-tracking/banner scripts it injects as <script> tags
|
||||
'https://*.hs-scripts.com',
|
||||
'https://*.hs-analytics.net',
|
||||
'https://*.hscollectedforms.net',
|
||||
'https://*.hs-banner.com',
|
||||
]
|
||||
: []),
|
||||
] as const
|
||||
|
||||
const STATIC_IMG_SRC = ["'self'", 'data:', 'blob:', 'https:'] as const
|
||||
|
||||
const STATIC_CONNECT_SRC = [
|
||||
"'self'",
|
||||
'https://api.browser-use.com',
|
||||
'https://api.elevenlabs.io',
|
||||
'wss://api.elevenlabs.io',
|
||||
'https://api.exa.ai',
|
||||
'https://api.firecrawl.dev',
|
||||
'https://*.googleapis.com',
|
||||
'https://*.amazonaws.com',
|
||||
'https://*.s3.amazonaws.com',
|
||||
'https://*.blob.core.windows.net',
|
||||
'https://*.atlassian.com',
|
||||
'https://*.supabase.co',
|
||||
'https://api.github.com',
|
||||
'https://github.com/*',
|
||||
'https://challenges.cloudflare.com',
|
||||
// Cal.com booking embed (landing /demo) — embed XHR/availability calls
|
||||
'https://app.cal.com',
|
||||
'https://cal.com',
|
||||
...(isReactGrabEnabled ? ['https://www.react-grab.com'] : []),
|
||||
...(isDev ? ['ws://localhost:4722'] : []),
|
||||
...(isHosted
|
||||
? [
|
||||
'https://www.googletagmanager.com',
|
||||
'https://*.google-analytics.com',
|
||||
'https://*.analytics.google.com',
|
||||
'https://analytics.google.com',
|
||||
'https://www.google.com',
|
||||
'https://analytics.ahrefs.com',
|
||||
'https://*.g.doubleclick.net',
|
||||
// HubSpot tracking — form-tracking API (hscollectedforms.js).
|
||||
// The visitor beacon itself is an image pixel (img-src, already
|
||||
// permitted below), not a connect-src request.
|
||||
'https://*.hscollectedforms.net',
|
||||
]
|
||||
: []),
|
||||
] as const
|
||||
|
||||
const STATIC_FRAME_SRC = [
|
||||
"'self'",
|
||||
'blob:',
|
||||
'https://challenges.cloudflare.com',
|
||||
// Cal.com booking embed (landing /demo) — the booking iframe
|
||||
'https://app.cal.com',
|
||||
'https://cal.com',
|
||||
'https://drive.google.com',
|
||||
'https://docs.google.com',
|
||||
'https://*.google.com',
|
||||
'https://www.youtube.com',
|
||||
'https://player.vimeo.com',
|
||||
'https://www.dailymotion.com',
|
||||
'https://player.twitch.tv',
|
||||
'https://clips.twitch.tv',
|
||||
'https://streamable.com',
|
||||
'https://fast.wistia.net',
|
||||
'https://www.tiktok.com',
|
||||
'https://w.soundcloud.com',
|
||||
'https://open.spotify.com',
|
||||
'https://embed.music.apple.com',
|
||||
'https://www.loom.com',
|
||||
'https://www.facebook.com',
|
||||
'https://www.instagram.com',
|
||||
'https://platform.twitter.com',
|
||||
'https://rumble.com',
|
||||
'https://play.vidyard.com',
|
||||
'https://iframe.cloudflarestream.com',
|
||||
'https://www.mixcloud.com',
|
||||
'https://tenor.com',
|
||||
'https://giphy.com',
|
||||
...(isHosted ? ['https://www.googletagmanager.com'] : []),
|
||||
] as const
|
||||
|
||||
// Build-time CSP directives (for next.config.ts)
|
||||
export const buildTimeCSPDirectives: CSPDirectives = {
|
||||
'default-src': ["'self'"],
|
||||
'script-src': [...STATIC_SCRIPT_SRC],
|
||||
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
|
||||
'img-src': [...STATIC_IMG_SRC],
|
||||
|
||||
'media-src': ["'self'", 'blob:'],
|
||||
'worker-src': ["'self'", 'blob:'],
|
||||
'font-src': ["'self'", 'https://fonts.gstatic.com'],
|
||||
|
||||
'connect-src': [
|
||||
...STATIC_CONNECT_SRC,
|
||||
env.NEXT_PUBLIC_APP_URL || '',
|
||||
...(env.OLLAMA_URL ? [env.OLLAMA_URL] : isDev ? [DEFAULT_OLLAMA_URL] : []),
|
||||
...(env.NEXT_PUBLIC_SOCKET_URL
|
||||
? [env.NEXT_PUBLIC_SOCKET_URL, toWebSocketUrl(env.NEXT_PUBLIC_SOCKET_URL)]
|
||||
: isDev
|
||||
? [DEFAULT_SOCKET_URL, toWebSocketUrl(DEFAULT_SOCKET_URL)]
|
||||
: []),
|
||||
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL),
|
||||
...getHostnameFromUrl(env.NEXT_PUBLIC_PRIVACY_URL),
|
||||
...getHostnameFromUrl(env.NEXT_PUBLIC_TERMS_URL),
|
||||
],
|
||||
|
||||
'frame-src': [...STATIC_FRAME_SRC],
|
||||
'frame-ancestors': ["'self'"],
|
||||
'form-action': ["'self'"],
|
||||
'base-uri': ["'self'"],
|
||||
'object-src': ["'none'"],
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSP string from directives object
|
||||
*/
|
||||
export function buildCSPString(directives: CSPDirectives): string {
|
||||
return Object.entries(directives)
|
||||
.map(([directive, sources]) => {
|
||||
if (!sources || sources.length === 0) return ''
|
||||
const validSources = sources.filter((source: string) => source && source.trim() !== '')
|
||||
if (validSources.length === 0) return ''
|
||||
return `${directive} ${validSources.join(' ')}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate runtime CSP header with dynamic environment variables.
|
||||
* Composes from the same STATIC_* constants as buildTimeCSPDirectives,
|
||||
* but resolves env vars at request time via getEnv() to fix Docker
|
||||
* deployments where build-time values may be stale placeholders.
|
||||
*/
|
||||
export function generateRuntimeCSP(): string {
|
||||
const appUrl = getEnv('NEXT_PUBLIC_APP_URL') || ''
|
||||
|
||||
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev ? DEFAULT_SOCKET_URL : '')
|
||||
const socketWsUrl = socketUrl ? toWebSocketUrl(socketUrl) : ''
|
||||
const ollamaUrl = getEnv('OLLAMA_URL') || (isDev ? DEFAULT_OLLAMA_URL : '')
|
||||
|
||||
const brandLogoDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_BRAND_LOGO_URL'))
|
||||
const privacyDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_PRIVACY_URL'))
|
||||
const termsDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_TERMS_URL'))
|
||||
|
||||
const runtimeDirectives: CSPDirectives = {
|
||||
...buildTimeCSPDirectives,
|
||||
|
||||
'img-src': [...STATIC_IMG_SRC],
|
||||
|
||||
'connect-src': [
|
||||
...STATIC_CONNECT_SRC,
|
||||
appUrl,
|
||||
ollamaUrl,
|
||||
socketUrl,
|
||||
socketWsUrl,
|
||||
...brandLogoDomains,
|
||||
...privacyDomains,
|
||||
...termsDomains,
|
||||
],
|
||||
}
|
||||
|
||||
return buildCSPString(runtimeDirectives)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main CSP policy string (build-time)
|
||||
*/
|
||||
export function getMainCSPPolicy(): string {
|
||||
return buildCSPString(buildTimeCSPDirectives)
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive CSP for workflow execution endpoints
|
||||
*/
|
||||
export function getWorkflowExecutionCSPPolicy(): string {
|
||||
return "default-src * 'unsafe-inline' 'unsafe-eval'; connect-src *;"
|
||||
}
|
||||
|
||||
/**
|
||||
* CSP for embeddable chat pages.
|
||||
* Extends the shared embed policy with Microsoft Office.js sources so the
|
||||
* chat page can serve as an Office (Excel/Word/Outlook) add-in surface
|
||||
* when loaded with `?embed=office`.
|
||||
*/
|
||||
export function getChatEmbedCSPPolicy(): string {
|
||||
return buildCSPString({
|
||||
...buildTimeCSPDirectives,
|
||||
'script-src': [...STATIC_SCRIPT_SRC, 'https://appsforoffice.microsoft.com'],
|
||||
'connect-src': [
|
||||
...(buildTimeCSPDirectives['connect-src'] ?? []),
|
||||
'https://appsforoffice.microsoft.com',
|
||||
],
|
||||
'frame-ancestors': ['*'],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a source to a specific directive (modifies build-time directives)
|
||||
*/
|
||||
export function addCSPSource(directive: keyof CSPDirectives, source: string): void {
|
||||
if (!buildTimeCSPDirectives[directive]) {
|
||||
buildTimeCSPDirectives[directive] = []
|
||||
}
|
||||
if (!buildTimeCSPDirectives[directive]!.includes(source)) {
|
||||
buildTimeCSPDirectives[directive]!.push(source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a source from a specific directive (modifies build-time directives)
|
||||
*/
|
||||
export function removeCSPSource(directive: keyof CSPDirectives, source: string): void {
|
||||
if (buildTimeCSPDirectives[directive]) {
|
||||
buildTimeCSPDirectives[directive] = buildTimeCSPDirectives[directive]!.filter(
|
||||
(s: string) => s !== source
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import {
|
||||
type DeploymentAuthKind,
|
||||
deploymentAuthCookieName,
|
||||
isEmailAllowed,
|
||||
validateAuthToken,
|
||||
} from '@/lib/core/security/deployment'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getClientIp } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('DeploymentAuth')
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
/**
|
||||
* Throttles unauthenticated password guesses per client IP against a single
|
||||
* deployment, mirroring the OTP/SSO IP limits.
|
||||
*/
|
||||
const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 10,
|
||||
refillRate: 10,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* A password/email-gated resource (a deployed chat or a public file share). Only
|
||||
* the fields the auth check needs — the `password` is the encrypted secret.
|
||||
*/
|
||||
export interface DeploymentAuthResource {
|
||||
id: string
|
||||
authType: string | null
|
||||
password?: string | null
|
||||
allowedEmails?: unknown
|
||||
}
|
||||
|
||||
interface DeploymentAuthBody {
|
||||
password?: string
|
||||
email?: string
|
||||
input?: unknown
|
||||
}
|
||||
|
||||
export interface DeploymentAuthResult {
|
||||
authorized: boolean
|
||||
error?: string
|
||||
status?: number
|
||||
retryAfterMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared password/email/SSO gate for deployed resources. The `cookiePrefix`
|
||||
* selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit
|
||||
* namespace so chat deployments and public file shares share one code path. Both
|
||||
* support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`.
|
||||
*/
|
||||
export async function validateDeploymentAuth(
|
||||
requestId: string,
|
||||
resource: DeploymentAuthResource,
|
||||
request: NextRequest,
|
||||
parsedBody: DeploymentAuthBody | null | undefined,
|
||||
cookiePrefix: DeploymentAuthKind
|
||||
): Promise<DeploymentAuthResult> {
|
||||
const authType = resource.authType || 'public'
|
||||
|
||||
if (authType === 'public') {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
if (authType !== 'sso') {
|
||||
const authCookie = request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id))
|
||||
|
||||
if (
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, resource.id, authType, resource.password)
|
||||
) {
|
||||
return { authorized: true }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'password') {
|
||||
if (request.method === 'GET') {
|
||||
return { authorized: false, error: 'auth_required_password' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!parsedBody) {
|
||||
return { authorized: false, error: 'Password is required' }
|
||||
}
|
||||
|
||||
const { password, input } = parsedBody
|
||||
|
||||
if (input && !password) {
|
||||
return { authorized: false, error: 'auth_required_password' }
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return { authorized: false, error: 'Password is required' }
|
||||
}
|
||||
|
||||
if (!resource.password) {
|
||||
logger.error(`[${requestId}] No password set for password-protected ${resource.id}`)
|
||||
return { authorized: false, error: 'Authentication configuration error' }
|
||||
}
|
||||
|
||||
const ip = getClientIp(request)
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`${cookiePrefix}-password:ip:${resource.id}:${ip}`,
|
||||
PASSWORD_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] Password attempt IP rate limit exceeded for ${resource.id} from ${ip}`
|
||||
)
|
||||
return {
|
||||
authorized: false,
|
||||
error: 'Too many attempts. Please try again later.',
|
||||
status: 429,
|
||||
retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs,
|
||||
}
|
||||
}
|
||||
|
||||
const { decrypted } = await decryptSecret(resource.password)
|
||||
if (!safeCompare(password, decrypted)) {
|
||||
return { authorized: false, error: 'Invalid password' }
|
||||
}
|
||||
|
||||
return { authorized: true }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating password:`, error)
|
||||
return { authorized: false, error: 'Authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'email') {
|
||||
if (request.method === 'GET') {
|
||||
return { authorized: false, error: 'auth_required_email' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!parsedBody) {
|
||||
return { authorized: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
const { email, input } = parsedBody
|
||||
|
||||
if (input && !email) {
|
||||
return { authorized: false, error: 'auth_required_email' }
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return { authorized: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
const allowedEmails = (resource.allowedEmails as string[]) || []
|
||||
|
||||
if (isEmailAllowed(email, allowedEmails)) {
|
||||
return { authorized: false, error: 'otp_required' }
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Email not authorized' }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating email:`, error)
|
||||
return { authorized: false, error: 'Authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'sso') {
|
||||
try {
|
||||
if (request.method !== 'GET' && !parsedBody) {
|
||||
return { authorized: false, error: 'SSO authentication is required' }
|
||||
}
|
||||
|
||||
const { getSession } = await import('@/lib/auth')
|
||||
const session = await getSession()
|
||||
|
||||
if (!session || !session.user) {
|
||||
return { authorized: false, error: 'auth_required_sso' }
|
||||
}
|
||||
|
||||
const userEmail = session.user.email
|
||||
if (!userEmail) {
|
||||
return { authorized: false, error: 'SSO session does not contain email' }
|
||||
}
|
||||
|
||||
const allowedEmails = (resource.allowedEmails as string[]) || []
|
||||
|
||||
if (isEmailAllowed(userEmail, allowedEmails)) {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Your email is not authorized to access this resource' }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error validating SSO:`, error)
|
||||
return { authorized: false, error: 'SSO authentication error' }
|
||||
}
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Unsupported authentication type' }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
|
||||
describe('isEmailAllowed', () => {
|
||||
it('matches an exact email regardless of casing on either side', () => {
|
||||
expect(isEmailAllowed('user@acme.com', ['user@acme.com'])).toBe(true)
|
||||
expect(isEmailAllowed('User@Acme.com', ['user@acme.com'])).toBe(true)
|
||||
expect(isEmailAllowed('user@acme.com', ['USER@ACME.COM'])).toBe(true)
|
||||
expect(isEmailAllowed(' User@Acme.com ', ['user@acme.com'])).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a domain pattern regardless of casing (covers IdP/session emails)', () => {
|
||||
expect(isEmailAllowed('User@Acme.com', ['@acme.com'])).toBe(true)
|
||||
expect(isEmailAllowed('user@acme.com', ['@Acme.com'])).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects emails not on the allow-list', () => {
|
||||
expect(isEmailAllowed('user@evil.com', ['user@acme.com', '@acme.com'])).toBe(false)
|
||||
expect(isEmailAllowed('user@acme.com', [])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { hmacSha256Hex } from '@sim/security/hmac'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isDev } from '@/lib/core/config/env-flags'
|
||||
|
||||
/**
|
||||
* Shared authentication utilities for deployed chat endpoints.
|
||||
* Handles token generation, validation, and auth cookies. CORS for these
|
||||
* endpoints lives in proxy.ts as the single source of truth.
|
||||
*/
|
||||
|
||||
function signPayload(payload: string): string {
|
||||
return hmacSha256Hex(payload, env.BETTER_AUTH_SECRET)
|
||||
}
|
||||
|
||||
function passwordSlot(encryptedPassword?: string | null): string {
|
||||
if (!encryptedPassword) return ''
|
||||
return sha256Hex(encryptedPassword).slice(0, 8)
|
||||
}
|
||||
|
||||
function generateAuthToken(
|
||||
deploymentId: string,
|
||||
type: string,
|
||||
encryptedPassword?: string | null
|
||||
): string {
|
||||
const payload = `${deploymentId}:${type}:${Date.now()}:${passwordSlot(encryptedPassword)}`
|
||||
const sig = signPayload(payload)
|
||||
return Buffer.from(`${payload}:${sig}`).toString('base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an HMAC-signed authentication token for a chat deployment.
|
||||
* Includes a password-derived slot so changing the deployment password immediately
|
||||
* invalidates existing sessions.
|
||||
*/
|
||||
export function validateAuthToken(
|
||||
token: string,
|
||||
deploymentId: string,
|
||||
authType: string,
|
||||
encryptedPassword?: string | null
|
||||
): boolean {
|
||||
try {
|
||||
const decoded = Buffer.from(token, 'base64').toString()
|
||||
const lastColon = decoded.lastIndexOf(':')
|
||||
if (lastColon === -1) return false
|
||||
|
||||
const payload = decoded.slice(0, lastColon)
|
||||
const sig = decoded.slice(lastColon + 1)
|
||||
|
||||
const expectedSig = signPayload(payload)
|
||||
if (!safeCompare(sig, expectedSig)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parts = payload.split(':')
|
||||
if (parts.length < 4) return false
|
||||
const [storedId, storedType, timestamp, storedPwSlot] = parts
|
||||
|
||||
if (storedId !== deploymentId) return false
|
||||
|
||||
// Bind the cookie to the auth type so a token minted under one mode (e.g. a
|
||||
// `public` share, which has an empty password slot) can't satisfy another
|
||||
// mode (e.g. `email` OTP) after the share's auth type is changed.
|
||||
if (storedType !== authType) return false
|
||||
|
||||
const expectedPwSlot = passwordSlot(encryptedPassword)
|
||||
if (storedPwSlot !== expectedPwSlot) return false
|
||||
|
||||
const createdAt = Number.parseInt(timestamp)
|
||||
const expireTime = 24 * 60 * 60 * 1000
|
||||
if (Date.now() - createdAt > expireTime) return false
|
||||
|
||||
return true
|
||||
} catch (_e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind of deployed resource an auth cookie/token belongs to. */
|
||||
export type DeploymentAuthKind = 'chat' | 'file'
|
||||
|
||||
/** Canonical auth cookie name for a deployed resource (`{kind}_auth_{id}`). */
|
||||
export function deploymentAuthCookieName(cookiePrefix: DeploymentAuthKind, id: string): string {
|
||||
return `${cookiePrefix}_auth_${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an authentication cookie for a deployment
|
||||
*/
|
||||
export function setDeploymentAuthCookie(
|
||||
response: NextResponse,
|
||||
cookiePrefix: DeploymentAuthKind,
|
||||
deploymentId: string,
|
||||
authType: string,
|
||||
encryptedPassword?: string | null
|
||||
): void {
|
||||
const token = generateAuthToken(deploymentId, authType, encryptedPassword)
|
||||
response.cookies.set({
|
||||
name: deploymentAuthCookieName(cookiePrefix, deploymentId),
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email matches the allowed emails list (exact match or domain
|
||||
* match). Case-insensitive — email addresses are compared lowercased on both
|
||||
* sides, so callers don't need to normalize before calling.
|
||||
*/
|
||||
export function isEmailAllowed(email: string, allowedEmails: string[]): boolean {
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
const normalizedAllowed = allowedEmails.map(normalizeEmail)
|
||||
|
||||
if (normalizedAllowed.includes(normalizedEmail)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const atIndex = normalizedEmail.indexOf('@')
|
||||
if (atIndex > 0) {
|
||||
const domain = normalizedEmail.substring(atIndex + 1)
|
||||
if (domain && normalizedAllowed.some((allowed) => allowed === `@${domain}`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createEnvMock } from '@sim/testing'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/env', () =>
|
||||
createEnvMock({
|
||||
ENCRYPTION_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
})
|
||||
)
|
||||
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { decryptSecret, encryptSecret, generatePassword } from './encryption'
|
||||
|
||||
describe('encryptSecret', () => {
|
||||
it('should encrypt a secret and return encrypted value with IV', async () => {
|
||||
const secret = 'my-secret-value'
|
||||
const result = await encryptSecret(secret)
|
||||
|
||||
expect(result.encrypted).toBeDefined()
|
||||
expect(result.iv).toBeDefined()
|
||||
expect(result.encrypted).toContain(':')
|
||||
expect(result.iv).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('should produce different encrypted values for the same input', async () => {
|
||||
const secret = 'same-secret'
|
||||
const result1 = await encryptSecret(secret)
|
||||
const result2 = await encryptSecret(secret)
|
||||
|
||||
expect(result1.encrypted).not.toBe(result2.encrypted)
|
||||
expect(result1.iv).not.toBe(result2.iv)
|
||||
})
|
||||
|
||||
it('should encrypt empty strings', async () => {
|
||||
const result = await encryptSecret('')
|
||||
expect(result.encrypted).toBeDefined()
|
||||
expect(result.iv).toBeDefined()
|
||||
})
|
||||
|
||||
it('should encrypt long secrets', async () => {
|
||||
const longSecret = 'a'.repeat(10000)
|
||||
const result = await encryptSecret(longSecret)
|
||||
expect(result.encrypted).toBeDefined()
|
||||
})
|
||||
|
||||
it('should encrypt secrets with special characters', async () => {
|
||||
const specialSecret = '!@#$%^&*()_+-=[]{}|;\':",.<>?/`~\n\t\r'
|
||||
const result = await encryptSecret(specialSecret)
|
||||
expect(result.encrypted).toBeDefined()
|
||||
})
|
||||
|
||||
it('should encrypt unicode characters', async () => {
|
||||
const unicodeSecret = 'Hello !"#$%&\'()*+,-./0123456789:;<=>?@'
|
||||
const result = await encryptSecret(unicodeSecret)
|
||||
expect(result.encrypted).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('decryptSecret', () => {
|
||||
it('should decrypt an encrypted secret back to original value', async () => {
|
||||
const originalSecret = 'my-secret-value'
|
||||
const { encrypted } = await encryptSecret(originalSecret)
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
|
||||
expect(decrypted).toBe(originalSecret)
|
||||
})
|
||||
|
||||
it('should decrypt very short secrets', async () => {
|
||||
const { encrypted } = await encryptSecret('a')
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
expect(decrypted).toBe('a')
|
||||
})
|
||||
|
||||
it('should decrypt long secrets', async () => {
|
||||
const longSecret = 'b'.repeat(10000)
|
||||
const { encrypted } = await encryptSecret(longSecret)
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
expect(decrypted).toBe(longSecret)
|
||||
})
|
||||
|
||||
it('should decrypt secrets with special characters', async () => {
|
||||
const specialSecret = '!@#$%^&*()_+-=[]{}|;\':",.<>?/`~\n\t\r'
|
||||
const { encrypted } = await encryptSecret(specialSecret)
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
expect(decrypted).toBe(specialSecret)
|
||||
})
|
||||
|
||||
it('should throw error for invalid encrypted format (missing parts)', async () => {
|
||||
await expect(decryptSecret('invalid')).rejects.toThrow(
|
||||
'Invalid encrypted value format. Expected "iv:encrypted:authTag"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error for invalid encrypted format (only two parts)', async () => {
|
||||
await expect(decryptSecret('part1:part2')).rejects.toThrow(
|
||||
'Invalid encrypted value format. Expected "iv:encrypted:authTag"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error for tampered ciphertext', async () => {
|
||||
const { encrypted } = await encryptSecret('original-secret')
|
||||
const parts = encrypted.split(':')
|
||||
parts[1] = `tampered${parts[1].slice(8)}`
|
||||
const tamperedEncrypted = parts.join(':')
|
||||
|
||||
await expect(decryptSecret(tamperedEncrypted)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for tampered auth tag', async () => {
|
||||
const { encrypted } = await encryptSecret('original-secret')
|
||||
const parts = encrypted.split(':')
|
||||
parts[2] = '00000000000000000000000000000000'
|
||||
const tamperedEncrypted = parts.join(':')
|
||||
|
||||
await expect(decryptSecret(tamperedEncrypted)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for invalid IV', async () => {
|
||||
const { encrypted } = await encryptSecret('original-secret')
|
||||
const parts = encrypted.split(':')
|
||||
parts[0] = '00000000000000000000000000000000'
|
||||
const tamperedEncrypted = parts.join(':')
|
||||
|
||||
await expect(decryptSecret(tamperedEncrypted)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generatePassword', () => {
|
||||
it('should generate password with default length of 24', () => {
|
||||
const password = generatePassword()
|
||||
expect(password).toHaveLength(24)
|
||||
})
|
||||
|
||||
it('should generate password with custom length', () => {
|
||||
const password = generatePassword(32)
|
||||
expect(password).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('should generate password with minimum length', () => {
|
||||
const password = generatePassword(1)
|
||||
expect(password).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should generate different passwords on each call', () => {
|
||||
const passwords = new Set()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
passwords.add(generatePassword())
|
||||
}
|
||||
expect(passwords.size).toBeGreaterThan(90)
|
||||
})
|
||||
|
||||
it('should only contain allowed characters', () => {
|
||||
const allowedChars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+='
|
||||
const password = generatePassword(1000)
|
||||
|
||||
for (const char of password) {
|
||||
expect(allowedChars).toContain(char)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle zero length', () => {
|
||||
const password = generatePassword(0)
|
||||
expect(password).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('encryption key validation', () => {
|
||||
const originalEncryptionKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
|
||||
afterEach(() => {
|
||||
;(env as Record<string, string>).ENCRYPTION_KEY = originalEncryptionKey
|
||||
})
|
||||
|
||||
it('should throw error when ENCRYPTION_KEY is not set', async () => {
|
||||
;(env as Record<string, string>).ENCRYPTION_KEY = ''
|
||||
await expect(encryptSecret('test')).rejects.toThrow(
|
||||
'ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error when ENCRYPTION_KEY is wrong length', async () => {
|
||||
;(env as Record<string, string>).ENCRYPTION_KEY = '0123456789abcdef'
|
||||
await expect(encryptSecret('test')).rejects.toThrow(
|
||||
'ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { decrypt, encrypt } from '@sim/security/encryption'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { randomInt } from '@sim/utils/random'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('Encryption')
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = env.ENCRYPTION_KEY
|
||||
if (!key || key.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes)')
|
||||
}
|
||||
return Buffer.from(key, 'hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a secret using AES-256-GCM with the app's `ENCRYPTION_KEY`.
|
||||
* @param secret - The secret to encrypt
|
||||
* @returns A promise resolving to the encrypted value (`iv:ciphertext:authTag`) and the IV.
|
||||
*/
|
||||
export async function encryptSecret(secret: string): Promise<{ encrypted: string; iv: string }> {
|
||||
return encrypt(secret, getEncryptionKey())
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a secret previously produced by {@link encryptSecret}. Logs and
|
||||
* rethrows on malformed input or tampered ciphertext.
|
||||
*/
|
||||
export async function decryptSecret(encryptedValue: string): Promise<{ decrypted: string }> {
|
||||
try {
|
||||
return await decrypt(encryptedValue, getEncryptionKey())
|
||||
} catch (error) {
|
||||
logger.error('Decryption error:', { error: toError(error).message })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a secure random password
|
||||
* @param length - The length of the password (default: 24)
|
||||
* @returns A new secure password string
|
||||
*/
|
||||
export function generatePassword(length = 24): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+='
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(randomInt(0, chars.length))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import dns from 'dns/promises'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import type { LookupFunction } from 'net'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { omit } from '@sim/utils/object'
|
||||
import * as ipaddr from 'ipaddr.js'
|
||||
import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici'
|
||||
import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
|
||||
import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation'
|
||||
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
|
||||
const logger = createLogger('InputValidation')
|
||||
|
||||
/**
|
||||
* Result type for async URL validation with resolved IP
|
||||
*/
|
||||
export interface AsyncValidationResult extends ValidationResult {
|
||||
resolvedIP?: string
|
||||
originalHostname?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* - IPv4-compatible IPv6 (::a.b.c.d / ::xxxx:xxxx, RFC 4291 §2.5.5.1, deprecated)
|
||||
* - Various edge cases that regex patterns miss
|
||||
*/
|
||||
export function isPrivateOrReservedIP(ip: string): boolean {
|
||||
try {
|
||||
if (!ipaddr.isValid(ip)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const addr = ipaddr.process(ip)
|
||||
const range = addr.range()
|
||||
|
||||
if (range !== 'unicast') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (addr.kind() === 'ipv6') {
|
||||
const v6 = addr as ipaddr.IPv6
|
||||
const parts = v6.parts
|
||||
const firstSixZero = parts.slice(0, 6).every((p) => p === 0)
|
||||
if (firstSixZero) {
|
||||
const embedded = ipaddr.fromByteArray([
|
||||
(parts[6] >> 8) & 0xff,
|
||||
parts[6] & 0xff,
|
||||
(parts[7] >> 8) & 0xff,
|
||||
parts[7] & 0xff,
|
||||
])
|
||||
return embedded.range() !== 'unicast'
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding
|
||||
*
|
||||
* This function:
|
||||
* 1. Performs basic URL validation (protocol, format)
|
||||
* 2. Resolves the hostname to an IP address
|
||||
* 3. Validates the resolved IP is not private/reserved
|
||||
* 4. Returns the resolved IP for use in the actual request
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @param paramName - Name of the parameter for error messages
|
||||
* @returns AsyncValidationResult with resolved IP for DNS pinning
|
||||
*/
|
||||
export async function validateUrlWithDNS(
|
||||
url: string | null | undefined,
|
||||
paramName = 'url',
|
||||
options: { allowHttp?: boolean } = {}
|
||||
): Promise<AsyncValidationResult> {
|
||||
const basicValidation = validateExternalUrl(url, paramName, options)
|
||||
if (!basicValidation.isValid) {
|
||||
return basicValidation
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url!)
|
||||
const hostname = parsedUrl.hostname
|
||||
|
||||
const hostnameLower = hostname.toLowerCase()
|
||||
const cleanHostname =
|
||||
hostnameLower.startsWith('[') && hostnameLower.endsWith(']')
|
||||
? hostnameLower.slice(1, -1)
|
||||
: hostnameLower
|
||||
|
||||
let isLocalhost = cleanHostname === 'localhost'
|
||||
if (ipaddr.isValid(cleanHostname)) {
|
||||
const processedIP = ipaddr.process(cleanHostname).toString()
|
||||
if (processedIP === '127.0.0.1' || processedIP === '::1') {
|
||||
isLocalhost = true
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { address } = await dns.lookup(cleanHostname, { verbatim: true })
|
||||
|
||||
const resolvedIsLoopback =
|
||||
ipaddr.isValid(address) &&
|
||||
(() => {
|
||||
const ip = ipaddr.process(address).toString()
|
||||
return ip === '127.0.0.1' || ip === '::1'
|
||||
})()
|
||||
|
||||
if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) {
|
||||
logger.warn('URL resolves to blocked IP address', {
|
||||
paramName,
|
||||
hostname,
|
||||
resolvedIP: address,
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} resolves to a blocked IP address`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
resolvedIP: address,
|
||||
originalHostname: hostname,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('DNS lookup failed for URL', {
|
||||
paramName,
|
||||
hostname,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} hostname could not be resolved`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a database hostname by resolving DNS and checking the resolved IP
|
||||
* against private/reserved ranges to prevent SSRF via database connections.
|
||||
*
|
||||
* Unlike validateHostname (which enforces strict RFC hostname format), this
|
||||
* function is permissive about hostname format to avoid breaking legitimate
|
||||
* database hostnames (e.g. underscores in Docker/K8s service names). It only
|
||||
* blocks localhost and private/reserved IPs.
|
||||
*
|
||||
* Self-hosted operators can set `ALLOW_PRIVATE_DATABASE_HOSTS` to reach databases
|
||||
* on their private network (e.g. a Docker/Swarm service name that resolves to an
|
||||
* internal IP). The opt-in only bypasses the private/reserved/loopback block; DNS
|
||||
* is still resolved so the caller can pin the connection to the resolved IP. The
|
||||
* bypass is never honored on the hosted platform (see {@link isPrivateDatabaseHostsAllowed}).
|
||||
*
|
||||
* @param host - The database hostname to validate
|
||||
* @param paramName - Name of the parameter for error messages
|
||||
* @returns AsyncValidationResult with resolved IP
|
||||
*/
|
||||
export async function validateDatabaseHost(
|
||||
host: string | null | undefined,
|
||||
paramName = 'host'
|
||||
): Promise<AsyncValidationResult> {
|
||||
if (!host) {
|
||||
return { isValid: false, error: `${paramName} is required` }
|
||||
}
|
||||
|
||||
const lowerHost = host.toLowerCase()
|
||||
const cleanHost =
|
||||
lowerHost.startsWith('[') && lowerHost.endsWith(']') ? lowerHost.slice(1, -1) : lowerHost
|
||||
|
||||
if (cleanHost === 'localhost' && !isPrivateDatabaseHostsAllowed) {
|
||||
return { isValid: false, error: `${paramName} cannot be localhost` }
|
||||
}
|
||||
|
||||
if (
|
||||
ipaddr.isValid(cleanHost) &&
|
||||
isPrivateOrReservedIP(cleanHost) &&
|
||||
!isPrivateDatabaseHostsAllowed
|
||||
) {
|
||||
return { isValid: false, error: `${paramName} cannot be a private IP address` }
|
||||
}
|
||||
|
||||
try {
|
||||
const { address } = await dns.lookup(cleanHost, { verbatim: true })
|
||||
|
||||
if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) {
|
||||
logger.warn('Database host resolves to blocked IP address', {
|
||||
paramName,
|
||||
hostname: host,
|
||||
resolvedIP: address,
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} resolves to a blocked IP address`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
resolvedIP: address,
|
||||
originalHostname: host,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('DNS lookup failed for database host', {
|
||||
paramName,
|
||||
hostname: host,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} hostname could not be resolved`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns run against the WHERE clause with string/identifier literals masked
|
||||
* out (so an attacker cannot smuggle `OR 1` or `; DROP` inside a quoted value).
|
||||
*
|
||||
* The connector-literal rules below are intentionally `OR`-only: only an
|
||||
* `OR <truthy>` term broadens a mutation to every row. `AND <number>` is a no-op
|
||||
* for broadening and is also exactly what `BETWEEN low AND high` produces, so
|
||||
* matching it would reject legitimate range filters (e.g. `id BETWEEN 1 AND 10`).
|
||||
*/
|
||||
const SQL_WHERE_MASKED_PATTERNS: readonly RegExp[] = [
|
||||
/;\s*\w/, // stacked statement
|
||||
/\bunion\s+(?:all\s+)?select\b/i,
|
||||
/\binto\s+(?:out|dump)file\b/i,
|
||||
/--/,
|
||||
/\/\*/,
|
||||
/\*\//,
|
||||
/\b(?:sleep|pg_sleep|benchmark)\s*\(/i,
|
||||
/\b(\w+)\s*=\s*\1\b/i, // same (unquoted) operand both sides: x=x, 1=1
|
||||
/\b\d+(?:\.\d+)?\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+(?:\.\d+)?\b/, // constant vs constant: 1=1, 1<2, 2>1
|
||||
/\bor\s+(?:true|false)\b/i, // OR TRUE / OR FALSE
|
||||
/\bor\s+\d+(?:\.\d+)?\b(?!\s*[=<>!+\-*/%])/i, // standalone truthy literal after OR: OR 1, OR 42
|
||||
/^\s*(?:\d+(?:\.\d+)?|true|false)\s*$/i, // bare constant: "1" / "true" / "false"
|
||||
]
|
||||
|
||||
/**
|
||||
* Patterns run against the raw WHERE clause (need the literal contents intact),
|
||||
* e.g. equality between two identical string literals.
|
||||
*/
|
||||
const SQL_WHERE_RAW_PATTERNS: readonly RegExp[] = [
|
||||
/(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/, // 'a'='a' / "x"="x"
|
||||
]
|
||||
|
||||
/**
|
||||
* Replaces the contents of string literals ('...'), double-quoted and
|
||||
* backtick-quoted identifiers with spaces (preserving length) so structural
|
||||
* scans do not treat data inside quotes as SQL. Comments are intentionally left
|
||||
* intact so comment-injection sequences are still detected.
|
||||
*/
|
||||
function maskSqlStringLiterals(sql: string): string {
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i < sql.length) {
|
||||
const ch = sql[i]
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
out += ' '
|
||||
i++
|
||||
while (i < sql.length && sql[i] !== ch) {
|
||||
if (ch !== '`' && sql[i] === '\\') {
|
||||
out += ' '
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
out += ' '
|
||||
i++
|
||||
}
|
||||
if (i < sql.length) {
|
||||
out += ' '
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a free-form SQL `WHERE` condition for injection and always-true
|
||||
* tautology patterns. Returns a {@link ValidationResult}; callers decide whether
|
||||
* to throw or surface the error.
|
||||
*
|
||||
* IMPORTANT: this is **defense-in-depth, not a security boundary**. A free-form
|
||||
* SQL condition cannot be exhaustively validated against every always-true
|
||||
* expression (e.g. `OR 2 > 1`, `OR (1)`, `OR NOT 0`, `OR length(x) >= 0`). The
|
||||
* real boundary is that the caller supplies their own database credentials and
|
||||
* could run equivalent SQL directly (e.g. via a raw-SQL/execute operation). This
|
||||
* guard stops the easy, obvious ways an injected condition broadens a mutation
|
||||
* to every row; it is not a substitute for constraining untrusted input upstream.
|
||||
*
|
||||
* @param where - The WHERE clause condition (without the `WHERE` keyword)
|
||||
* @param paramName - Label used in the error message
|
||||
*/
|
||||
export function validateSqlWhereClause(
|
||||
where: string | null | undefined,
|
||||
paramName = 'WHERE clause'
|
||||
): ValidationResult {
|
||||
if (typeof where !== 'string' || where.trim().length === 0) {
|
||||
return { isValid: false, error: `${paramName} is required` }
|
||||
}
|
||||
|
||||
const masked = maskSqlStringLiterals(where)
|
||||
const matched =
|
||||
SQL_WHERE_MASKED_PATTERNS.some((pattern) => pattern.test(masked)) ||
|
||||
SQL_WHERE_RAW_PATTERNS.some((pattern) => pattern.test(where))
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} contains a disallowed or always-true expression`,
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
export interface SecureFetchOptions {
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: string | Buffer | Uint8Array
|
||||
timeout?: number
|
||||
maxRedirects?: number
|
||||
maxResponseBytes?: number
|
||||
signal?: AbortSignal
|
||||
/** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */
|
||||
stripAuthOnRedirect?: boolean
|
||||
}
|
||||
|
||||
export class SecureFetchHeaders {
|
||||
private headers: Map<string, string>
|
||||
private setCookies: string[]
|
||||
|
||||
constructor(headers: Record<string, string>, setCookies: string[] = []) {
|
||||
this.headers = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
|
||||
this.setCookies = setCookies
|
||||
}
|
||||
|
||||
get(name: string): string | null {
|
||||
return this.headers.get(name.toLowerCase()) ?? null
|
||||
}
|
||||
|
||||
/** Returns the raw `Set-Cookie` header values as an array. Each entry is one cookie. */
|
||||
getSetCookie(): string[] {
|
||||
return [...this.setCookies]
|
||||
}
|
||||
|
||||
toRecord(): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
for (const [key, value] of this.headers) {
|
||||
record[key] = value
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.headers.entries()
|
||||
}
|
||||
}
|
||||
|
||||
export interface SecureFetchResponse {
|
||||
ok: boolean
|
||||
status: number
|
||||
statusText: string
|
||||
headers: SecureFetchHeaders
|
||||
body: ReadableStream<Uint8Array> | null
|
||||
text: () => Promise<string>
|
||||
json: () => Promise<unknown>
|
||||
arrayBuffer: () => Promise<ArrayBuffer>
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_REDIRECTS = 5
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return status >= 300 && status < 400 && status !== 304
|
||||
}
|
||||
|
||||
function isRetryableHttpStatus(status: number): boolean {
|
||||
return status === 429 || (status >= 500 && status <= 599)
|
||||
}
|
||||
|
||||
function resolveRedirectUrl(baseUrl: string, location: string): string {
|
||||
try {
|
||||
return new URL(location, baseUrl).toString()
|
||||
} catch {
|
||||
throw new Error(`Invalid redirect location: ${location}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DNS lookup function that always returns a pre-resolved IP address.
|
||||
* Use this to prevent DNS rebinding (TOCTOU) attacks when connecting to
|
||||
* user-controlled hostnames via non-HTTP protocols (SMTP, SSH, IMAP, etc.).
|
||||
*/
|
||||
export function createPinnedLookup(resolvedIP: string): LookupFunction {
|
||||
const isIPv6 = resolvedIP.includes(':')
|
||||
const family = isIPv6 ? 6 : 4
|
||||
|
||||
return (_hostname, options, callback) => {
|
||||
if (options.all) {
|
||||
callback(null, [{ address: resolvedIP, family }])
|
||||
} else {
|
||||
callback(null, resolvedIP, family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a standard `fetch`-compatible function that pins every outbound
|
||||
* connection to `resolvedIP`, preventing DNS-rebinding (TOCTOU) between URL
|
||||
* validation and connection. The original hostname is preserved for TLS SNI and
|
||||
* the `Host` header so it still matches the certificate. This is the single
|
||||
* source of truth for pinned outbound fetches — both the LLM providers and the
|
||||
* MCP transport consume it.
|
||||
*
|
||||
* Pass the returned function as the `fetch` option to the OpenAI/Anthropic SDKs
|
||||
* (or call it directly) after validating the URL with {@link validateUrlWithDNS}
|
||||
* and capturing `resolvedIP`. Because the pinned lookup always returns
|
||||
* `resolvedIP` regardless of hostname, any redirect the server returns also
|
||||
* connects to the validated IP — an attacker cannot rebind a redirect target to
|
||||
* an internal address.
|
||||
*
|
||||
* The `Agent` is captured for the lifetime of the returned function, so repeated
|
||||
* calls (e.g. a provider tool loop) reuse its keep-alive connections.
|
||||
*/
|
||||
export function createPinnedFetch(resolvedIP: string): typeof fetch {
|
||||
const dispatcher = new Agent({ connect: { lookup: createPinnedLookup(resolvedIP) } })
|
||||
|
||||
const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
// double-cast-allowed: DOM RequestInfo/URL and undici fetch input types differ but are structurally compatible at runtime (Node's global fetch IS undici)
|
||||
const undiciInput = input as unknown as Parameters<typeof undiciFetch>[0]
|
||||
// double-cast-allowed: DOM RequestInit and undici RequestInit are structurally compatible at runtime but the TS types differ
|
||||
const undiciInit: UndiciRequestInit = { ...(init as unknown as UndiciRequestInit), dispatcher }
|
||||
const response = await undiciFetch(undiciInput, undiciInit)
|
||||
// double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime
|
||||
return response as unknown as Response
|
||||
}
|
||||
|
||||
return pinned
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a fetch with IP pinning to prevent DNS rebinding attacks.
|
||||
* Uses the pre-resolved IP address while preserving the original hostname for TLS SNI.
|
||||
* Follows redirects securely by validating each redirect target.
|
||||
*/
|
||||
export async function secureFetchWithPinnedIP(
|
||||
url: string,
|
||||
resolvedIP: string,
|
||||
options: SecureFetchOptions & { allowHttp?: boolean } = {},
|
||||
redirectCount = 0
|
||||
): Promise<SecureFetchResponse> {
|
||||
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
||||
const maxResponseBytes = options.maxResponseBytes
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url)
|
||||
const isHttps = parsed.protocol === 'https:'
|
||||
const defaultPort = isHttps ? 443 : 80
|
||||
const port = parsed.port ? Number.parseInt(parsed.port, 10) : defaultPort
|
||||
|
||||
const lookup = createPinnedLookup(resolvedIP)
|
||||
|
||||
const agentOptions: http.AgentOptions = { lookup }
|
||||
|
||||
const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions)
|
||||
|
||||
const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {}
|
||||
|
||||
const requestOptions: http.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: options.method || 'GET',
|
||||
headers: sanitizedHeaders,
|
||||
agent,
|
||||
timeout: options.timeout || 300000,
|
||||
}
|
||||
|
||||
const protocol = isHttps ? https : http
|
||||
const req = protocol.request(requestOptions, (res) => {
|
||||
const statusCode = res.statusCode || 0
|
||||
const location = res.headers.location
|
||||
|
||||
if (isRedirectStatus(statusCode) && location && redirectCount < maxRedirects) {
|
||||
res.resume()
|
||||
const redirectUrl = resolveRedirectUrl(url, location)
|
||||
|
||||
validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp })
|
||||
.then((validation) => {
|
||||
if (!validation.isValid) {
|
||||
settledReject(new Error(`Redirect blocked: ${validation.error}`))
|
||||
return
|
||||
}
|
||||
const redirectOptions = options.stripAuthOnRedirect
|
||||
? {
|
||||
...options,
|
||||
headers: omit(options.headers ?? {}, ['Authorization', 'authorization']),
|
||||
}
|
||||
: options
|
||||
return secureFetchWithPinnedIP(
|
||||
redirectUrl,
|
||||
validation.resolvedIP!,
|
||||
redirectOptions,
|
||||
redirectCount + 1
|
||||
)
|
||||
})
|
||||
.then((response) => {
|
||||
if (response) settledResolve(response)
|
||||
})
|
||||
.catch(settledReject)
|
||||
return
|
||||
}
|
||||
|
||||
if (isRedirectStatus(statusCode) && location && redirectCount >= maxRedirects) {
|
||||
res.resume()
|
||||
settledReject(new Error(`Too many redirects (max: ${maxRedirects})`))
|
||||
return
|
||||
}
|
||||
|
||||
const headersRecord: Record<string, string> = {}
|
||||
let setCookieArray: string[] = []
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (lowerKey === 'set-cookie') {
|
||||
if (Array.isArray(value)) {
|
||||
setCookieArray = value
|
||||
headersRecord[lowerKey] = value.join(', ')
|
||||
} else if (typeof value === 'string') {
|
||||
setCookieArray = [value]
|
||||
headersRecord[lowerKey] = value
|
||||
}
|
||||
} else if (typeof value === 'string') {
|
||||
headersRecord[lowerKey] = value
|
||||
} else if (Array.isArray(value)) {
|
||||
headersRecord[lowerKey] = value.join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = headersRecord['content-length']
|
||||
if (typeof maxResponseBytes === 'number' && maxResponseBytes > 0 && contentLength) {
|
||||
const parsedLength = Number.parseInt(contentLength, 10)
|
||||
if (Number.isFinite(parsedLength) && parsedLength > maxResponseBytes) {
|
||||
cleanupAbort()
|
||||
res.destroy()
|
||||
req.destroy()
|
||||
if (isRetryableHttpStatus(statusCode)) {
|
||||
settledResolve({
|
||||
ok: false,
|
||||
status: statusCode,
|
||||
statusText: res.statusMessage || '',
|
||||
headers: new SecureFetchHeaders(headersRecord, setCookieArray),
|
||||
body: null,
|
||||
text: async () => '',
|
||||
json: async () => ({}),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
})
|
||||
return
|
||||
}
|
||||
settledReject(
|
||||
new PayloadSizeLimitError({
|
||||
label: 'response body',
|
||||
maxBytes: maxResponseBytes,
|
||||
observedBytes: parsedLength,
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let totalBytes = 0
|
||||
const nodeRes = res
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeRes.on('data', (chunk: Buffer) => {
|
||||
totalBytes += chunk.length
|
||||
if (
|
||||
typeof maxResponseBytes === 'number' &&
|
||||
maxResponseBytes > 0 &&
|
||||
totalBytes > maxResponseBytes
|
||||
) {
|
||||
cleanupAbort()
|
||||
controller.error(
|
||||
new PayloadSizeLimitError({
|
||||
label: 'response body',
|
||||
maxBytes: maxResponseBytes,
|
||||
observedBytes: totalBytes,
|
||||
})
|
||||
)
|
||||
nodeRes.destroy()
|
||||
return
|
||||
}
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
nodeRes.on('end', () => {
|
||||
cleanupAbort()
|
||||
controller.close()
|
||||
})
|
||||
nodeRes.on('error', (err) => {
|
||||
cleanupAbort()
|
||||
controller.error(err)
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
cleanupAbort()
|
||||
nodeRes.destroy()
|
||||
},
|
||||
})
|
||||
|
||||
let bodyBufferPromise: Promise<Buffer> | null = null
|
||||
function readBodyAsBuffer(): Promise<Buffer> {
|
||||
if (!bodyBufferPromise) {
|
||||
bodyBufferPromise = (async () => {
|
||||
const reader = body.getReader()
|
||||
const buffers: Uint8Array[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) buffers.push(value)
|
||||
}
|
||||
return Buffer.concat(buffers.map((b) => Buffer.from(b)))
|
||||
})()
|
||||
}
|
||||
return bodyBufferPromise
|
||||
}
|
||||
|
||||
settledResolve({
|
||||
ok: statusCode >= 200 && statusCode < 300,
|
||||
status: statusCode,
|
||||
statusText: res.statusMessage || '',
|
||||
headers: new SecureFetchHeaders(headersRecord, setCookieArray),
|
||||
body,
|
||||
text: async () => (await readBodyAsBuffer()).toString('utf-8'),
|
||||
json: async () => JSON.parse((await readBodyAsBuffer()).toString('utf-8')),
|
||||
arrayBuffer: async () => {
|
||||
const buf = await readBodyAsBuffer()
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
let onAbort: (() => void) | null = null
|
||||
const cleanupAbort = () => {
|
||||
if (onAbort && options.signal) {
|
||||
options.signal.removeEventListener('abort', onAbort)
|
||||
onAbort = null
|
||||
}
|
||||
}
|
||||
const settledResolve: typeof resolve = (value) => {
|
||||
resolve(value)
|
||||
}
|
||||
const settledReject: typeof reject = (reason) => {
|
||||
cleanupAbort()
|
||||
reject(reason)
|
||||
}
|
||||
|
||||
req.on('error', (error) => {
|
||||
settledReject(error)
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
settledReject(new Error(`Request timed out after ${requestOptions.timeout}ms`))
|
||||
})
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
req.destroy()
|
||||
settledReject(options.signal.reason ?? new Error('Aborted'))
|
||||
return
|
||||
}
|
||||
onAbort = () => {
|
||||
req.destroy()
|
||||
settledReject(options.signal?.reason ?? new Error('Aborted'))
|
||||
}
|
||||
options.signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body)
|
||||
}
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL and performs a secure fetch with DNS pinning in one call.
|
||||
* Combines validateUrlWithDNS and secureFetchWithPinnedIP for convenience.
|
||||
*
|
||||
* @param url - The URL to fetch
|
||||
* @param options - Fetch options (method, headers, body, etc.)
|
||||
* @param paramName - Name of the parameter for error messages (default: 'url')
|
||||
* @returns SecureFetchResponse
|
||||
* @throws Error if URL validation fails
|
||||
*/
|
||||
export async function secureFetchWithValidation(
|
||||
url: string,
|
||||
options: SecureFetchOptions & { allowHttp?: boolean } = {},
|
||||
paramName = 'url'
|
||||
): Promise<SecureFetchResponse> {
|
||||
const validation = await validateUrlWithDNS(url, paramName, {
|
||||
allowHttp: options.allowHttp,
|
||||
})
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.error)
|
||||
}
|
||||
return secureFetchWithPinnedIP(url, validation.resolvedIP!, options)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
import { randomInt } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { verification } from '@sim/db/schema'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { getStorageMethod } from '@/lib/core/storage'
|
||||
|
||||
export type DeploymentKind = 'chat' | 'file'
|
||||
|
||||
/**
|
||||
* Shared OTP configuration for deployment email-auth gates (chat + public file shares).
|
||||
*/
|
||||
export const OTP_EXPIRY_SECONDS = 15 * 60
|
||||
export const OTP_EXPIRY_MS = OTP_EXPIRY_SECONDS * 1000
|
||||
export const MAX_OTP_ATTEMPTS = 5
|
||||
|
||||
export const OTP_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 10,
|
||||
refillRate: 10,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
export const OTP_EMAIL_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 3,
|
||||
refillRate: 3,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
/**
|
||||
* Key formats are kept per-kind to preserve any in-flight OTPs already issued
|
||||
* against existing chat deployments. The chat Redis key uses the legacy `otp:`
|
||||
* prefix; the chat DB identifier uses `chat-otp:`.
|
||||
*/
|
||||
const OTP_KEYS = {
|
||||
chat: {
|
||||
redisKey: (email: string, deploymentId: string) => `otp:${email}:${deploymentId}`,
|
||||
dbIdentifier: (email: string, deploymentId: string) => `chat-otp:${deploymentId}:${email}`,
|
||||
},
|
||||
file: {
|
||||
redisKey: (email: string, deploymentId: string) => `otp:file:${email}:${deploymentId}`,
|
||||
dbIdentifier: (email: string, deploymentId: string) => `file-otp:${deploymentId}:${email}`,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
DeploymentKind,
|
||||
{
|
||||
redisKey: (email: string, deploymentId: string) => string
|
||||
dbIdentifier: (email: string, deploymentId: string) => string
|
||||
}
|
||||
>
|
||||
|
||||
/** Returns a cryptographically random 6-digit OTP code. */
|
||||
export function generateOTP(): string {
|
||||
return randomInt(100000, 1000000).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* OTP values are stored as `"code:attempts"` (e.g. `"654321:0"`).
|
||||
* This keeps the attempt counter in the same key/row as the OTP itself.
|
||||
*/
|
||||
function encodeOTPValue(otp: string, attempts: number): string {
|
||||
return `${otp}:${attempts}`
|
||||
}
|
||||
|
||||
export function decodeOTPValue(value: string): { otp: string; attempts: number } {
|
||||
const lastColon = value.lastIndexOf(':')
|
||||
if (lastColon === -1) return { otp: value, attempts: 0 }
|
||||
const attempts = Number.parseInt(value.slice(lastColon + 1), 10)
|
||||
return { otp: value.slice(0, lastColon), attempts: Number.isNaN(attempts) ? 0 : attempts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an OTP for a deployment+email pair, choosing Redis or the
|
||||
* `verification` table based on the configured storage method.
|
||||
*/
|
||||
export async function storeOTP(
|
||||
kind: DeploymentKind,
|
||||
deploymentId: string,
|
||||
email: string,
|
||||
otp: string
|
||||
): Promise<void> {
|
||||
const keys = OTP_KEYS[kind]
|
||||
const value = encodeOTPValue(otp, 0)
|
||||
const storageMethod = getStorageMethod()
|
||||
|
||||
if (storageMethod === 'redis') {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) throw new Error('Redis configured but client unavailable')
|
||||
await redis.set(keys.redisKey(email, deploymentId), value, 'EX', OTP_EXPIRY_SECONDS)
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + OTP_EXPIRY_MS)
|
||||
const identifier = keys.dbIdentifier(email, deploymentId)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(verification).where(eq(verification.identifier, identifier))
|
||||
await tx.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier,
|
||||
value,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getOTP(
|
||||
kind: DeploymentKind,
|
||||
deploymentId: string,
|
||||
email: string
|
||||
): Promise<string | null> {
|
||||
const keys = OTP_KEYS[kind]
|
||||
const storageMethod = getStorageMethod()
|
||||
|
||||
if (storageMethod === 'redis') {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) throw new Error('Redis configured but client unavailable')
|
||||
return redis.get(keys.redisKey(email, deploymentId))
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const [record] = await db
|
||||
.select({ value: verification.value })
|
||||
.from(verification)
|
||||
.where(
|
||||
and(
|
||||
eq(verification.identifier, keys.dbIdentifier(email, deploymentId)),
|
||||
gt(verification.expiresAt, now)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return record?.value ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lua script for atomic OTP attempt increment in Redis.
|
||||
* Returns `'LOCKED'` if max attempts reached (key deleted), new encoded value
|
||||
* otherwise, nil if key missing.
|
||||
*/
|
||||
const ATOMIC_INCREMENT_SCRIPT = `
|
||||
local val = redis.call('GET', KEYS[1])
|
||||
if not val then return nil end
|
||||
local colon = val:find(':([^:]*$)')
|
||||
local otp, attempts
|
||||
if colon then
|
||||
otp = val:sub(1, colon - 1)
|
||||
attempts = tonumber(val:sub(colon + 1)) or 0
|
||||
else
|
||||
otp = val
|
||||
attempts = 0
|
||||
end
|
||||
attempts = attempts + 1
|
||||
if attempts >= tonumber(ARGV[1]) then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return 'LOCKED'
|
||||
end
|
||||
local newVal = otp .. ':' .. attempts
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
if ttl > 0 then
|
||||
redis.call('SET', KEYS[1], newVal, 'EX', ttl)
|
||||
else
|
||||
redis.call('SET', KEYS[1], newVal)
|
||||
end
|
||||
return newVal
|
||||
`
|
||||
|
||||
/**
|
||||
* Atomically increments an OTP's failed-attempt counter. Returns `'locked'`
|
||||
* if the max-attempts threshold was reached (and the OTP was deleted), or
|
||||
* `'incremented'` otherwise. The DB path uses optimistic locking with retry.
|
||||
*/
|
||||
export async function incrementOTPAttempts(
|
||||
kind: DeploymentKind,
|
||||
deploymentId: string,
|
||||
email: string,
|
||||
currentValue: string
|
||||
): Promise<'locked' | 'incremented'> {
|
||||
const keys = OTP_KEYS[kind]
|
||||
const storageMethod = getStorageMethod()
|
||||
|
||||
if (storageMethod === 'redis') {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) throw new Error('Redis configured but client unavailable')
|
||||
const key = keys.redisKey(email, deploymentId)
|
||||
const result = await redis.eval(ATOMIC_INCREMENT_SCRIPT, 1, key, MAX_OTP_ATTEMPTS)
|
||||
if (result === null || result === 'LOCKED') return 'locked'
|
||||
return 'incremented'
|
||||
}
|
||||
|
||||
const identifier = keys.dbIdentifier(email, deploymentId)
|
||||
const MAX_RETRIES = 3
|
||||
let value = currentValue
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
const { otp, attempts } = decodeOTPValue(value)
|
||||
const newAttempts = attempts + 1
|
||||
|
||||
if (newAttempts >= MAX_OTP_ATTEMPTS) {
|
||||
await db.delete(verification).where(eq(verification.identifier, identifier))
|
||||
return 'locked'
|
||||
}
|
||||
|
||||
const newValue = encodeOTPValue(otp, newAttempts)
|
||||
const updated = await db
|
||||
.update(verification)
|
||||
.set({ value: newValue, updatedAt: new Date() })
|
||||
.where(and(eq(verification.identifier, identifier), eq(verification.value, value)))
|
||||
.returning({ id: verification.id })
|
||||
|
||||
if (updated.length > 0) return 'incremented'
|
||||
|
||||
const fresh = await getOTP(kind, deploymentId, email)
|
||||
if (!fresh) return 'locked'
|
||||
value = fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry exhaustion under heavy DB-path contention: this request did not
|
||||
* succeed in writing its own +1, so the stored count may not reflect it.
|
||||
* Fail closed — invalidate the OTP rather than return `'incremented'` with
|
||||
* a possibly-undercounted attempt total.
|
||||
*/
|
||||
await db.delete(verification).where(eq(verification.identifier, identifier))
|
||||
return 'locked'
|
||||
}
|
||||
|
||||
export async function deleteOTP(
|
||||
kind: DeploymentKind,
|
||||
deploymentId: string,
|
||||
email: string
|
||||
): Promise<void> {
|
||||
const keys = OTP_KEYS[kind]
|
||||
const storageMethod = getStorageMethod()
|
||||
|
||||
if (storageMethod === 'redis') {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) throw new Error('Redis configured but client unavailable')
|
||||
await redis.del(keys.redisKey(email, deploymentId))
|
||||
return
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(verification)
|
||||
.where(eq(verification.identifier, keys.dbIdentifier(email, deploymentId)))
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { envFlagsMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockAgent, mockUndiciFetch, capturedAgentOptions, agentCloses } = vi.hoisted(() => {
|
||||
const capturedAgentOptions: unknown[] = []
|
||||
const agentCloses: unknown[] = []
|
||||
class MockAgent {
|
||||
constructor(options: unknown) {
|
||||
capturedAgentOptions.push(options)
|
||||
}
|
||||
close() {
|
||||
agentCloses.push(this)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
return {
|
||||
mockAgent: MockAgent,
|
||||
mockUndiciFetch: vi.fn(),
|
||||
capturedAgentOptions,
|
||||
agentCloses,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch }))
|
||||
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
|
||||
|
||||
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
type LookupCallback = (err: Error | null, address: string, family: number) => void
|
||||
type PinnedLookup = (hostname: string, options: { all?: boolean }, callback: LookupCallback) => void
|
||||
|
||||
describe('createPinnedFetch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedAgentOptions.length = 0
|
||||
agentCloses.length = 0
|
||||
mockUndiciFetch.mockResolvedValue(new Response('ok'))
|
||||
})
|
||||
|
||||
it('builds an undici Agent whose pinned lookup always resolves to the validated IP', async () => {
|
||||
createPinnedFetch('203.0.113.10')
|
||||
|
||||
expect(capturedAgentOptions).toHaveLength(1)
|
||||
const { connect } = capturedAgentOptions[0] as { connect: { lookup: PinnedLookup } }
|
||||
expect(typeof connect.lookup).toBe('function')
|
||||
|
||||
const resolved = await new Promise<{ address: string; family: number }>((resolve) => {
|
||||
connect.lookup('rebind.attacker.tld', {}, (_err, address, family) =>
|
||||
resolve({ address, family })
|
||||
)
|
||||
})
|
||||
expect(resolved).toEqual({ address: '203.0.113.10', family: 4 })
|
||||
})
|
||||
|
||||
it('uses IPv6 family when the validated IP is IPv6', async () => {
|
||||
createPinnedFetch('2606:4700:4700::1111')
|
||||
const { connect } = capturedAgentOptions[0] as { connect: { lookup: PinnedLookup } }
|
||||
const resolved = await new Promise<{ address: string; family: number }>((resolve) => {
|
||||
connect.lookup('example.com', {}, (_err, address, family) => resolve({ address, family }))
|
||||
})
|
||||
expect(resolved).toEqual({ address: '2606:4700:4700::1111', family: 6 })
|
||||
})
|
||||
|
||||
it('forwards the pinned dispatcher on every call while preserving init options', async () => {
|
||||
const pinned = createPinnedFetch('203.0.113.10')
|
||||
const controller = new AbortController()
|
||||
|
||||
await pinned('https://myresource.openai.azure.com/openai/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: { 'api-key': 'secret' },
|
||||
body: '{}',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(mockUndiciFetch).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = mockUndiciFetch.mock.calls[0]
|
||||
expect(url).toBe('https://myresource.openai.azure.com/openai/v1/responses')
|
||||
const typedInit = init as RequestInit & { dispatcher?: unknown }
|
||||
expect(typedInit.dispatcher).toBeInstanceOf(mockAgent)
|
||||
expect(typedInit.method).toBe('POST')
|
||||
expect(typedInit.headers).toEqual({ 'api-key': 'secret' })
|
||||
expect(typedInit.body).toBe('{}')
|
||||
expect(typedInit.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('handles an undefined init by still attaching the dispatcher', async () => {
|
||||
const pinned = createPinnedFetch('203.0.113.10')
|
||||
await pinned('https://example.com')
|
||||
const init = mockUndiciFetch.mock.calls[0][1] as { dispatcher?: unknown }
|
||||
expect(init.dispatcher).toBeInstanceOf(mockAgent)
|
||||
})
|
||||
|
||||
it('reuses one captured dispatcher across all calls of a single instance', async () => {
|
||||
const pinned = createPinnedFetch('203.0.113.10')
|
||||
await pinned('https://example.com/a')
|
||||
await pinned('https://example.com/b')
|
||||
|
||||
expect(capturedAgentOptions).toHaveLength(1)
|
||||
const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher
|
||||
const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher
|
||||
expect(d1).toBe(d2)
|
||||
})
|
||||
|
||||
it('creates an independent dispatcher per instance', async () => {
|
||||
const a = createPinnedFetch('203.0.113.10')
|
||||
const b = createPinnedFetch('203.0.113.10')
|
||||
await a('https://example.com/a')
|
||||
await b('https://example.com/b')
|
||||
|
||||
expect(capturedAgentOptions).toHaveLength(2)
|
||||
const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher
|
||||
const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher
|
||||
expect(d1).not.toBe(d2)
|
||||
})
|
||||
|
||||
it('returns the response produced by undici fetch', async () => {
|
||||
mockUndiciFetch.mockResolvedValueOnce(new Response('pong', { status: 201 }))
|
||||
const pinned = createPinnedFetch('203.0.113.10')
|
||||
const response = await pinned('https://example.com')
|
||||
expect(response.status).toBe(201)
|
||||
expect(await response.text()).toBe('pong')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,771 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isLargeDataKey,
|
||||
isSensitiveKey,
|
||||
REDACTED_MARKER,
|
||||
redactApiKeys,
|
||||
redactSensitiveValues,
|
||||
sanitizeEventData,
|
||||
sanitizeForLogging,
|
||||
TRUNCATED_MARKER,
|
||||
} from './redaction'
|
||||
|
||||
/**
|
||||
* Security-focused edge case tests for redaction utilities
|
||||
*/
|
||||
|
||||
describe('REDACTED_MARKER', () => {
|
||||
it.concurrent('should be the standard marker', () => {
|
||||
expect(REDACTED_MARKER).toBe('[REDACTED]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TRUNCATED_MARKER', () => {
|
||||
it.concurrent('should be the standard marker', () => {
|
||||
expect(TRUNCATED_MARKER).toBe('[TRUNCATED]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLargeDataKey', () => {
|
||||
it.concurrent('should identify base64 as large data key', () => {
|
||||
expect(isLargeDataKey('base64')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not identify other keys as large data', () => {
|
||||
expect(isLargeDataKey('content')).toBe(false)
|
||||
expect(isLargeDataKey('data')).toBe(false)
|
||||
expect(isLargeDataKey('base')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSensitiveKey', () => {
|
||||
describe('exact matches', () => {
|
||||
it.concurrent('should match apiKey variations', () => {
|
||||
expect(isSensitiveKey('apiKey')).toBe(true)
|
||||
expect(isSensitiveKey('api_key')).toBe(true)
|
||||
expect(isSensitiveKey('api-key')).toBe(true)
|
||||
expect(isSensitiveKey('APIKEY')).toBe(true)
|
||||
expect(isSensitiveKey('API_KEY')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match token variations', () => {
|
||||
expect(isSensitiveKey('access_token')).toBe(true)
|
||||
expect(isSensitiveKey('refresh_token')).toBe(true)
|
||||
expect(isSensitiveKey('auth_token')).toBe(true)
|
||||
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match secret variations', () => {
|
||||
expect(isSensitiveKey('client_secret')).toBe(true)
|
||||
expect(isSensitiveKey('clientSecret')).toBe(true)
|
||||
expect(isSensitiveKey('secret')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match other sensitive keys', () => {
|
||||
expect(isSensitiveKey('private_key')).toBe(true)
|
||||
expect(isSensitiveKey('authorization')).toBe(true)
|
||||
expect(isSensitiveKey('bearer')).toBe(true)
|
||||
expect(isSensitiveKey('private')).toBe(true)
|
||||
expect(isSensitiveKey('auth')).toBe(true)
|
||||
expect(isSensitiveKey('password')).toBe(true)
|
||||
expect(isSensitiveKey('credential')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suffix matches', () => {
|
||||
it.concurrent('should match keys ending in secret', () => {
|
||||
expect(isSensitiveKey('clientSecret')).toBe(true)
|
||||
expect(isSensitiveKey('appSecret')).toBe(true)
|
||||
expect(isSensitiveKey('mySecret')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending in password', () => {
|
||||
expect(isSensitiveKey('userPassword')).toBe(true)
|
||||
expect(isSensitiveKey('dbPassword')).toBe(true)
|
||||
expect(isSensitiveKey('adminPassword')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending in token', () => {
|
||||
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||
expect(isSensitiveKey('refreshToken')).toBe(true)
|
||||
expect(isSensitiveKey('bearerToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending in credential', () => {
|
||||
expect(isSensitiveKey('userCredential')).toBe(true)
|
||||
expect(isSensitiveKey('dbCredential')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-sensitive keys (no false positives)', () => {
|
||||
it.concurrent('should not match keys with sensitive words as prefix only', () => {
|
||||
expect(isSensitiveKey('tokenCount')).toBe(false)
|
||||
expect(isSensitiveKey('tokenizer')).toBe(false)
|
||||
expect(isSensitiveKey('secretKey')).toBe(false)
|
||||
expect(isSensitiveKey('passwordStrength')).toBe(false)
|
||||
expect(isSensitiveKey('authMethod')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending with sensitive words (intentional)', () => {
|
||||
expect(isSensitiveKey('hasSecret')).toBe(true)
|
||||
expect(isSensitiveKey('userPassword')).toBe(true)
|
||||
expect(isSensitiveKey('sessionToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not match normal field names', () => {
|
||||
expect(isSensitiveKey('name')).toBe(false)
|
||||
expect(isSensitiveKey('email')).toBe(false)
|
||||
expect(isSensitiveKey('id')).toBe(false)
|
||||
expect(isSensitiveKey('value')).toBe(false)
|
||||
expect(isSensitiveKey('data')).toBe(false)
|
||||
expect(isSensitiveKey('count')).toBe(false)
|
||||
expect(isSensitiveKey('status')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactSensitiveValues', () => {
|
||||
it.concurrent('should redact Bearer tokens', () => {
|
||||
const input = 'Authorization: Bearer abc123xyz456'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toBe('Authorization: Bearer [REDACTED]')
|
||||
expect(result).not.toContain('abc123xyz456')
|
||||
})
|
||||
|
||||
it.concurrent('should redact Basic auth', () => {
|
||||
const input = 'Authorization: Basic dXNlcjpwYXNz'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toBe('Authorization: Basic [REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should redact API key prefixes', () => {
|
||||
const input = 'Using key sk-1234567890abcdefghijklmnop'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('sk-1234567890abcdefghijklmnop')
|
||||
})
|
||||
|
||||
it.concurrent('should redact JSON-style password fields', () => {
|
||||
const input = 'password: "mysecretpass123"'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('mysecretpass123')
|
||||
})
|
||||
|
||||
it.concurrent('should redact JSON-style token fields', () => {
|
||||
const input = 'token: "tokenvalue123"'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('tokenvalue123')
|
||||
})
|
||||
|
||||
it.concurrent('should redact JSON-style api_key fields', () => {
|
||||
const input = 'api_key: "key123456"'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('key123456')
|
||||
})
|
||||
|
||||
it.concurrent('should not modify safe strings', () => {
|
||||
const input = 'This is a normal string with no secrets'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty strings', () => {
|
||||
expect(redactSensitiveValues('')).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should handle null/undefined gracefully', () => {
|
||||
expect(redactSensitiveValues(null as any)).toBe(null)
|
||||
expect(redactSensitiveValues(undefined as any)).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactApiKeys', () => {
|
||||
describe('object redaction', () => {
|
||||
it.concurrent('should redact sensitive keys in flat objects', () => {
|
||||
const obj = {
|
||||
apiKey: 'secret-key',
|
||||
api_key: 'another-secret',
|
||||
access_token: 'token-value',
|
||||
secret: 'secret-value',
|
||||
password: 'password-value',
|
||||
normalField: 'normal-value',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
expect(result.api_key).toBe('[REDACTED]')
|
||||
expect(result.access_token).toBe('[REDACTED]')
|
||||
expect(result.secret).toBe('[REDACTED]')
|
||||
expect(result.password).toBe('[REDACTED]')
|
||||
expect(result.normalField).toBe('normal-value')
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive keys in nested objects', () => {
|
||||
const obj = {
|
||||
config: {
|
||||
apiKey: 'secret-key',
|
||||
normalField: 'normal-value',
|
||||
},
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.config.apiKey).toBe('[REDACTED]')
|
||||
expect(result.config.normalField).toBe('normal-value')
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive keys in arrays', () => {
|
||||
const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }]
|
||||
|
||||
const result = redactApiKeys(arr)
|
||||
|
||||
expect(result[0].apiKey).toBe('[REDACTED]')
|
||||
expect(result[1].apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle deeply nested structures', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{
|
||||
name: 'John',
|
||||
credentials: {
|
||||
apiKey: 'secret-key',
|
||||
username: 'john_doe',
|
||||
},
|
||||
},
|
||||
],
|
||||
config: {
|
||||
database: {
|
||||
password: 'db-password',
|
||||
host: 'localhost',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.users[0].name).toBe('John')
|
||||
expect(result.users[0].credentials.apiKey).toBe('[REDACTED]')
|
||||
expect(result.users[0].credentials.username).toBe('john_doe')
|
||||
expect(result.config.database.password).toBe('[REDACTED]')
|
||||
expect(result.config.database.host).toBe('localhost')
|
||||
})
|
||||
|
||||
it.concurrent('should truncate base64 fields', () => {
|
||||
const obj = {
|
||||
id: 'file-123',
|
||||
name: 'document.pdf',
|
||||
base64: 'VGhpcyBpcyBhIHZlcnkgbG9uZyBiYXNlNjQgc3RyaW5n...',
|
||||
size: 12345,
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.id).toBe('file-123')
|
||||
expect(result.name).toBe('document.pdf')
|
||||
expect(result.base64).toBe('[TRUNCATED]')
|
||||
expect(result.size).toBe(12345)
|
||||
})
|
||||
|
||||
it.concurrent('should truncate base64 in nested UserFile objects', () => {
|
||||
const obj = {
|
||||
files: [
|
||||
{
|
||||
id: 'file-1',
|
||||
name: 'doc1.pdf',
|
||||
url: 'http://example.com/file1',
|
||||
size: 1000,
|
||||
base64: 'base64content1...',
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
name: 'doc2.pdf',
|
||||
url: 'http://example.com/file2',
|
||||
size: 2000,
|
||||
base64: 'base64content2...',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.files[0].id).toBe('file-1')
|
||||
expect(result.files[0].base64).toBe('[TRUNCATED]')
|
||||
expect(result.files[1].base64).toBe('[TRUNCATED]')
|
||||
})
|
||||
|
||||
it.concurrent('should filter UserFile objects to only expose allowed fields', () => {
|
||||
const obj = {
|
||||
processedFiles: [
|
||||
{
|
||||
id: 'file-123',
|
||||
name: 'document.pdf',
|
||||
url: 'http://localhost/api/files/serve/...',
|
||||
size: 12345,
|
||||
type: 'application/pdf',
|
||||
key: 'execution/workspace/workflow/file.pdf',
|
||||
context: 'execution',
|
||||
base64: 'VGhpcyBpcyBhIGJhc2U2NCBzdHJpbmc=',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
// Exposed fields should be present
|
||||
expect(result.processedFiles[0].id).toBe('file-123')
|
||||
expect(result.processedFiles[0].name).toBe('document.pdf')
|
||||
expect(result.processedFiles[0].url).toBe('http://localhost/api/files/serve/...')
|
||||
expect(result.processedFiles[0].size).toBe(12345)
|
||||
expect(result.processedFiles[0].type).toBe('application/pdf')
|
||||
expect(result.processedFiles[0].base64).toBe('[TRUNCATED]')
|
||||
|
||||
// Internal fields should be filtered out
|
||||
expect(result.processedFiles[0]).not.toHaveProperty('key')
|
||||
expect(result.processedFiles[0]).not.toHaveProperty('context')
|
||||
})
|
||||
})
|
||||
|
||||
describe('primitive handling', () => {
|
||||
it.concurrent('should return primitives unchanged', () => {
|
||||
expect(redactApiKeys('string')).toBe('string')
|
||||
expect(redactApiKeys(123)).toBe(123)
|
||||
expect(redactApiKeys(true)).toBe(true)
|
||||
expect(redactApiKeys(null)).toBe(null)
|
||||
expect(redactApiKeys(undefined)).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('no false positives', () => {
|
||||
it.concurrent('should not redact keys with sensitive words as prefix only', () => {
|
||||
const obj = {
|
||||
tokenCount: 100,
|
||||
secretKey: 'not-actually-secret',
|
||||
passwordStrength: 'strong',
|
||||
authMethod: 'oauth',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.tokenCount).toBe(100)
|
||||
expect(result.secretKey).toBe('not-actually-secret')
|
||||
expect(result.passwordStrength).toBe('strong')
|
||||
expect(result.authMethod).toBe('oauth')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeForLogging', () => {
|
||||
it.concurrent('should truncate long strings', () => {
|
||||
const longString = 'a'.repeat(200)
|
||||
const result = sanitizeForLogging(longString, 50)
|
||||
expect(result.length).toBe(50)
|
||||
})
|
||||
|
||||
it.concurrent('should use default max length of 100', () => {
|
||||
const longString = 'a'.repeat(200)
|
||||
const result = sanitizeForLogging(longString)
|
||||
expect(result.length).toBe(100)
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive patterns', () => {
|
||||
const input = 'Bearer abc123xyz456'
|
||||
const result = sanitizeForLogging(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('abc123xyz456')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty strings', () => {
|
||||
expect(sanitizeForLogging('')).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should not modify safe short strings', () => {
|
||||
const input = 'Safe string'
|
||||
const result = sanitizeForLogging(input)
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeEventData', () => {
|
||||
describe('object sanitization', () => {
|
||||
it.concurrent('should remove sensitive keys entirely', () => {
|
||||
const event = {
|
||||
action: 'login',
|
||||
apiKey: 'secret-key',
|
||||
password: 'secret-pass',
|
||||
userId: '123',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.action).toBe('login')
|
||||
expect(result.userId).toBe('123')
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
expect(result).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive patterns in string values', () => {
|
||||
const event = {
|
||||
message: 'Auth: Bearer abc123token',
|
||||
normal: 'normal value',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.message).toContain('[REDACTED]')
|
||||
expect(result.message).not.toContain('abc123token')
|
||||
expect(result.normal).toBe('normal value')
|
||||
})
|
||||
|
||||
it.concurrent('should handle nested objects', () => {
|
||||
const event = {
|
||||
user: {
|
||||
id: '123',
|
||||
accessToken: 'secret-token',
|
||||
},
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.user.id).toBe('123')
|
||||
expect(result.user).not.toHaveProperty('accessToken')
|
||||
})
|
||||
|
||||
it.concurrent('should handle arrays', () => {
|
||||
const event = {
|
||||
items: [
|
||||
{ id: 1, apiKey: 'key1' },
|
||||
{ id: 2, apiKey: 'key2' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.items[0].id).toBe(1)
|
||||
expect(result.items[0]).not.toHaveProperty('apiKey')
|
||||
expect(result.items[1].id).toBe(2)
|
||||
expect(result.items[1]).not.toHaveProperty('apiKey')
|
||||
})
|
||||
})
|
||||
|
||||
describe('primitive handling', () => {
|
||||
it.concurrent('should return primitives appropriately', () => {
|
||||
expect(sanitizeEventData(null)).toBe(null)
|
||||
expect(sanitizeEventData(undefined)).toBe(undefined)
|
||||
expect(sanitizeEventData(123)).toBe(123)
|
||||
expect(sanitizeEventData(true)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive patterns in top-level strings', () => {
|
||||
const result = sanitizeEventData('Bearer secrettoken123')
|
||||
expect(result).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should not redact normal strings', () => {
|
||||
const result = sanitizeEventData('normal string')
|
||||
expect(result).toBe('normal string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('no false positives', () => {
|
||||
it.concurrent('should not remove keys with sensitive words in middle', () => {
|
||||
const event = {
|
||||
tokenCount: 500,
|
||||
isAuthenticated: true,
|
||||
hasSecretFeature: false,
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.tokenCount).toBe(500)
|
||||
expect(result.isAuthenticated).toBe(true)
|
||||
expect(result.hasSecretFeature).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Security edge cases', () => {
|
||||
describe('redactApiKeys security', () => {
|
||||
it.concurrent('should handle objects with prototype-like key names safely', () => {
|
||||
const obj = {
|
||||
protoField: { isAdmin: true },
|
||||
name: 'test',
|
||||
apiKey: 'secret',
|
||||
}
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.name).toBe('test')
|
||||
expect(result.protoField).toEqual({ isAdmin: true })
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle objects with constructor key', () => {
|
||||
const obj = {
|
||||
constructor: 'test-value',
|
||||
normalField: 'normal',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.constructor).toBe('test-value')
|
||||
expect(result.normalField).toBe('normal')
|
||||
})
|
||||
|
||||
it.concurrent('should handle objects with toString key', () => {
|
||||
const obj = {
|
||||
toString: 'custom-tostring',
|
||||
valueOf: 'custom-valueof',
|
||||
apiKey: 'secret',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.toString).toBe('custom-tostring')
|
||||
expect(result.valueOf).toBe('custom-valueof')
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should not mutate original object', () => {
|
||||
const original = {
|
||||
apiKey: 'secret-key',
|
||||
nested: {
|
||||
password: 'secret-password',
|
||||
},
|
||||
}
|
||||
|
||||
const originalCopy = structuredClone(original)
|
||||
redactApiKeys(original)
|
||||
|
||||
expect(original).toEqual(originalCopy)
|
||||
})
|
||||
|
||||
it.concurrent('should handle very deeply nested structures', () => {
|
||||
let obj: any = { data: 'value' }
|
||||
for (let i = 0; i < 50; i++) {
|
||||
obj = { nested: obj, apiKey: `secret-${i}` }
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
expect(result.nested.apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle arrays with mixed types', () => {
|
||||
const arr = [
|
||||
{ apiKey: 'secret' },
|
||||
'string',
|
||||
123,
|
||||
null,
|
||||
undefined,
|
||||
true,
|
||||
[{ password: 'nested' }],
|
||||
]
|
||||
|
||||
const result = redactApiKeys(arr)
|
||||
|
||||
expect(result[0].apiKey).toBe('[REDACTED]')
|
||||
expect(result[1]).toBe('string')
|
||||
expect(result[2]).toBe(123)
|
||||
expect(result[3]).toBe(null)
|
||||
expect(result[4]).toBe(undefined)
|
||||
expect(result[5]).toBe(true)
|
||||
expect(result[6][0].password).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty arrays', () => {
|
||||
const result = redactApiKeys([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty objects', () => {
|
||||
const result = redactApiKeys({})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactSensitiveValues security', () => {
|
||||
it.concurrent('should handle multiple API key patterns in one string', () => {
|
||||
const input = 'Keys: sk-abc123defghijklmnopqr and pk-xyz789abcdefghijklmnop'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).not.toContain('sk-abc123defghijklmnopqr')
|
||||
expect(result).not.toContain('pk-xyz789abcdefghijklmnop')
|
||||
expect(result.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it.concurrent('should handle multiline strings with sensitive data', () => {
|
||||
const input = `Line 1: Bearer token123abc456def
|
||||
Line 2: password: "secretpass"
|
||||
Line 3: Normal content`
|
||||
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('token123abc456def')
|
||||
expect(result).not.toContain('secretpass')
|
||||
expect(result).toContain('Normal content')
|
||||
})
|
||||
|
||||
it.concurrent('should handle unicode in strings', () => {
|
||||
const input = 'Bearer abc123'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('abc123')
|
||||
})
|
||||
|
||||
it.concurrent('should handle very long strings', () => {
|
||||
const longSecret = 'a'.repeat(10000)
|
||||
const input = `Bearer ${longSecret}`
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result.length).toBeLessThan(input.length)
|
||||
})
|
||||
|
||||
it.concurrent('should not match partial patterns', () => {
|
||||
const input = 'This is a Bear without er suffix'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it.concurrent('should handle special regex characters safely', () => {
|
||||
const input = 'Test with special chars: $^.*+?()[]{}|'
|
||||
const result = redactSensitiveValues(input)
|
||||
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeEventData security', () => {
|
||||
it.concurrent('should strip sensitive keys entirely (not redact)', () => {
|
||||
const event = {
|
||||
action: 'login',
|
||||
apiKey: 'should-be-stripped',
|
||||
password: 'should-be-stripped',
|
||||
userId: '123',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
expect(result).not.toHaveProperty('password')
|
||||
expect(Object.keys(result)).not.toContain('apiKey')
|
||||
expect(Object.keys(result)).not.toContain('password')
|
||||
})
|
||||
|
||||
it.concurrent('should handle Symbol keys gracefully', () => {
|
||||
const sym = Symbol('test')
|
||||
const event: any = {
|
||||
[sym]: 'symbol-value',
|
||||
normalKey: 'normal-value',
|
||||
}
|
||||
|
||||
expect(() => sanitizeEventData(event)).not.toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should handle Date objects as objects', () => {
|
||||
const date = new Date('2024-01-01')
|
||||
const event = {
|
||||
createdAt: date,
|
||||
apiKey: 'secret',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.createdAt).toBeDefined()
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
})
|
||||
|
||||
it.concurrent('should handle objects with numeric keys', () => {
|
||||
const event: any = {
|
||||
0: 'first',
|
||||
1: 'second',
|
||||
apiKey: 'secret',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result[0]).toBe('first')
|
||||
expect(result[1]).toBe('second')
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSensitiveKey security', () => {
|
||||
it.concurrent('should handle case variations', () => {
|
||||
expect(isSensitiveKey('APIKEY')).toBe(true)
|
||||
expect(isSensitiveKey('ApiKey')).toBe(true)
|
||||
expect(isSensitiveKey('apikey')).toBe(true)
|
||||
expect(isSensitiveKey('API_KEY')).toBe(true)
|
||||
expect(isSensitiveKey('api_key')).toBe(true)
|
||||
expect(isSensitiveKey('Api_Key')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty string', () => {
|
||||
expect(isSensitiveKey('')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle very long key names', () => {
|
||||
const longKey = `${'a'.repeat(10000)}password`
|
||||
expect(isSensitiveKey(longKey)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle keys with special characters', () => {
|
||||
expect(isSensitiveKey('api-key')).toBe(true)
|
||||
expect(isSensitiveKey('api_key')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect oauth tokens', () => {
|
||||
expect(isSensitiveKey('access_token')).toBe(true)
|
||||
expect(isSensitiveKey('refresh_token')).toBe(true)
|
||||
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||
expect(isSensitiveKey('refreshToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect various credential patterns', () => {
|
||||
expect(isSensitiveKey('userCredential')).toBe(true)
|
||||
expect(isSensitiveKey('dbCredential')).toBe(true)
|
||||
expect(isSensitiveKey('appCredential')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeForLogging edge cases', () => {
|
||||
it.concurrent('should handle string with only sensitive content', () => {
|
||||
const input = 'Bearer abc123xyz456'
|
||||
const result = sanitizeForLogging(input)
|
||||
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('abc123xyz456')
|
||||
})
|
||||
|
||||
it.concurrent('should truncate strings to specified length', () => {
|
||||
const longString = 'a'.repeat(200)
|
||||
const result = sanitizeForLogging(longString, 60)
|
||||
|
||||
expect(result.length).toBe(60)
|
||||
})
|
||||
|
||||
it.concurrent('should handle maxLength of 0', () => {
|
||||
const result = sanitizeForLogging('test', 0)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should handle negative maxLength gracefully', () => {
|
||||
const result = sanitizeForLogging('test', -5)
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should handle maxLength larger than string', () => {
|
||||
const input = 'short'
|
||||
const result = sanitizeForLogging(input, 1000)
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Centralized redaction utilities for sensitive data
|
||||
*/
|
||||
|
||||
import { filterUserFileForDisplay, isUserFile } from '@/lib/core/utils/user-file'
|
||||
|
||||
export const REDACTED_MARKER = '[REDACTED]'
|
||||
export const TRUNCATED_MARKER = '[TRUNCATED]'
|
||||
|
||||
const BYPASS_REDACTION_KEYS = new Set(['nextPageToken'])
|
||||
|
||||
/** Keys that contain large binary/encoded data that should be truncated in logs */
|
||||
const LARGE_DATA_KEYS = new Set(['base64'])
|
||||
|
||||
const SENSITIVE_KEY_PATTERNS: RegExp[] = [
|
||||
/^api[_-]?key$/i,
|
||||
/^access[_-]?token$/i,
|
||||
/^refresh[_-]?token$/i,
|
||||
/^client[_-]?secret$/i,
|
||||
/^private[_-]?key$/i,
|
||||
/^auth[_-]?token$/i,
|
||||
/^.*secret$/i,
|
||||
/^.*password$/i,
|
||||
/^.*token$/i,
|
||||
/^.*credential$/i,
|
||||
/^authorization$/i,
|
||||
/^bearer$/i,
|
||||
/^private$/i,
|
||||
/^auth$/i,
|
||||
]
|
||||
|
||||
/**
|
||||
* Patterns for sensitive values in strings (for redacting values, not keys)
|
||||
* Each pattern has a replacement function
|
||||
*/
|
||||
const SENSITIVE_VALUE_PATTERNS: Array<{
|
||||
pattern: RegExp
|
||||
replacement: string
|
||||
}> = [
|
||||
// Bearer tokens
|
||||
{
|
||||
pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
|
||||
replacement: `Bearer ${REDACTED_MARKER}`,
|
||||
},
|
||||
// Basic auth
|
||||
{
|
||||
pattern: /Basic\s+[A-Za-z0-9+/]+=*/gi,
|
||||
replacement: `Basic ${REDACTED_MARKER}`,
|
||||
},
|
||||
// API keys that look like sk-..., pk-..., etc.
|
||||
{
|
||||
pattern: /\b(sk|pk|api|key)[_-][A-Za-z0-9\-._]{20,}\b/gi,
|
||||
replacement: REDACTED_MARKER,
|
||||
},
|
||||
// JSON-style password fields: password: "value" or password: 'value'
|
||||
{
|
||||
pattern: /password['":\s]*['"][^'"]+['"]/gi,
|
||||
replacement: `password: "${REDACTED_MARKER}"`,
|
||||
},
|
||||
// JSON-style token fields: token: "value" or token: 'value'
|
||||
{
|
||||
pattern: /token['":\s]*['"][^'"]+['"]/gi,
|
||||
replacement: `token: "${REDACTED_MARKER}"`,
|
||||
},
|
||||
// JSON-style api_key fields: api_key: "value" or api-key: "value"
|
||||
{
|
||||
pattern: /api[_-]?key['":\s]*['"][^'"]+['"]/gi,
|
||||
replacement: `api_key: "${REDACTED_MARKER}"`,
|
||||
},
|
||||
]
|
||||
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
if (BYPASS_REDACTION_KEYS.has(key)) {
|
||||
return false
|
||||
}
|
||||
const lowerKey = key.toLowerCase()
|
||||
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey))
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive patterns from a string value
|
||||
* @param value - The string to redact
|
||||
* @returns The string with sensitive patterns redacted
|
||||
*/
|
||||
export function redactSensitiveValues(value: string): string {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
let result = value
|
||||
for (const { pattern, replacement } of SENSITIVE_VALUE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isLargeDataKey(key: string): boolean {
|
||||
return LARGE_DATA_KEYS.has(key)
|
||||
}
|
||||
|
||||
export function redactApiKeys(obj: any): any {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => redactApiKeys(item))
|
||||
}
|
||||
|
||||
if (isUserFile(obj)) {
|
||||
const filtered = filterUserFileForDisplay(obj)
|
||||
const result: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(filtered)) {
|
||||
if (isLargeDataKey(key) && typeof value === 'string') {
|
||||
result[key] = TRUNCATED_MARKER
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
result[key] = REDACTED_MARKER
|
||||
} else if (isLargeDataKey(key) && typeof value === 'string') {
|
||||
result[key] = TRUNCATED_MARKER
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
result[key] = redactApiKeys(value)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string for safe logging by truncating and redacting sensitive patterns
|
||||
*
|
||||
* @param value - The string to sanitize
|
||||
* @param maxLength - Maximum length of the output (default: 100)
|
||||
* @returns The sanitized string
|
||||
*/
|
||||
export function sanitizeForLogging(value: string, maxLength = 100): string {
|
||||
if (!value) return ''
|
||||
|
||||
let sanitized = value.substring(0, maxLength)
|
||||
|
||||
sanitized = redactSensitiveValues(sanitized)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes event data for error reporting/analytics
|
||||
*
|
||||
* @param event - The event data to sanitize
|
||||
* @returns Sanitized event data safe for external reporting
|
||||
*/
|
||||
export function sanitizeEventData(event: any): any {
|
||||
if (event === null || event === undefined) {
|
||||
return event
|
||||
}
|
||||
|
||||
if (typeof event === 'string') {
|
||||
return redactSensitiveValues(event)
|
||||
}
|
||||
|
||||
if (typeof event !== 'object') {
|
||||
return event
|
||||
}
|
||||
|
||||
if (Array.isArray(event)) {
|
||||
return event.map((item) => sanitizeEventData(item))
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
sanitized[key] = redactSensitiveValues(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
sanitized[key] = value.map((v) => sanitizeEventData(v))
|
||||
} else if (value && typeof value === 'object') {
|
||||
sanitized[key] = sanitizeEventData(value)
|
||||
} else {
|
||||
sanitized[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin'
|
||||
|
||||
function makeRequest(headers: Record<string, string>): NextRequest {
|
||||
return { headers: new Headers(headers) } as unknown as NextRequest
|
||||
}
|
||||
|
||||
describe('isCrossSiteSessionRequest', () => {
|
||||
it('rejects cross-site requests', () => {
|
||||
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('allows same-origin browser fetches', () => {
|
||||
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('allows same-site fetches (sibling subdomains, e.g. www.<domain> -> <domain>)', () => {
|
||||
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('allows user-initiated requests (Sec-Fetch-Site: none)', () => {
|
||||
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('allows requests with no Sec-Fetch-Site header (older clients)', () => {
|
||||
expect(isCrossSiteSessionRequest(makeRequest({}))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
/**
|
||||
* Returns true when a request is provably cross-site — a browser fetch driven
|
||||
* from a different site than our own. Used to reject session-cookie CSRF on
|
||||
* state-changing routes.
|
||||
*
|
||||
* `Sec-Fetch-Site` is browser-set and a forbidden header, so page JavaScript
|
||||
* cannot forge it. A cross-site browser request (the CSRF threat) always reports
|
||||
* `cross-site`. We deliberately accept `same-origin`, `same-site`, and `none`:
|
||||
* the app is served across sibling subdomains (e.g. `www.<domain>` calling
|
||||
* `<domain>`), so a legitimate `same-site` fetch must NOT be blocked — rejecting
|
||||
* it 403s real "Run" requests on those origins. An absent header (older clients)
|
||||
* is also allowed; the conventional CSRF posture is to reject only a provable
|
||||
* cross-site request.
|
||||
*
|
||||
* This is CSRF protection only. It does not defend against a non-browser client
|
||||
* that forges headers directly (no header check can); that surface is covered by
|
||||
* the credit and execution rate-limit gates.
|
||||
*/
|
||||
export function isCrossSiteSessionRequest(req: NextRequest): boolean {
|
||||
return req.headers.get('sec-fetch-site') === 'cross-site'
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const TURNSTILE_SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
const TURNSTILE_TIMEOUT_MS = 5_000
|
||||
|
||||
const logger = createLogger('TurnstileVerify')
|
||||
|
||||
interface TurnstileSiteverifyResponse {
|
||||
success: boolean
|
||||
'error-codes'?: string[]
|
||||
challenge_ts?: string
|
||||
hostname?: string
|
||||
action?: string
|
||||
cdata?: string
|
||||
metadata?: { interactive?: boolean }
|
||||
}
|
||||
|
||||
export interface VerifyTurnstileResult {
|
||||
success: boolean
|
||||
errorCodes?: string[]
|
||||
/** True when the siteverify request itself failed (network/timeout). */
|
||||
transportError?: boolean
|
||||
}
|
||||
|
||||
export interface VerifyTurnstileOptions {
|
||||
token: string | null | undefined
|
||||
remoteIp?: string
|
||||
/** Rejects the token when Cloudflare's reported hostname differs. */
|
||||
expectedHostname?: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Turnstile token against Cloudflare's siteverify endpoint. Tokens
|
||||
* are single-use and expire after 300 seconds; never cache the result.
|
||||
*/
|
||||
export async function verifyTurnstileToken({
|
||||
token,
|
||||
remoteIp,
|
||||
expectedHostname,
|
||||
idempotencyKey,
|
||||
}: VerifyTurnstileOptions): Promise<VerifyTurnstileResult> {
|
||||
const secret = env.TURNSTILE_SECRET_KEY
|
||||
if (!secret) {
|
||||
logger.warn('Turnstile verification called without TURNSTILE_SECRET_KEY configured')
|
||||
return { success: false, errorCodes: ['missing-input-secret'] }
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return { success: false, errorCodes: ['missing-input-response'] }
|
||||
}
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('secret', secret)
|
||||
body.set('response', token)
|
||||
if (remoteIp && remoteIp !== 'unknown') body.set('remoteip', remoteIp)
|
||||
if (idempotencyKey) body.set('idempotency_key', idempotencyKey)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TURNSTILE_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const response = await fetch(TURNSTILE_SITEVERIFY_URL, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Turnstile siteverify returned non-2xx', { status: response.status })
|
||||
return { success: false, transportError: true }
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TurnstileSiteverifyResponse
|
||||
|
||||
if (!data.success) {
|
||||
return { success: false, errorCodes: data['error-codes'] }
|
||||
}
|
||||
|
||||
if (expectedHostname && data.hostname && data.hostname !== expectedHostname) {
|
||||
logger.warn('Turnstile hostname mismatch', {
|
||||
expected: expectedHostname,
|
||||
actual: data.hostname,
|
||||
})
|
||||
return { success: false, errorCodes: ['hostname-mismatch'] }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
const error = toError(err)
|
||||
logger.warn('Turnstile siteverify request failed', {
|
||||
aborted: error.name === 'AbortError',
|
||||
error: error.message,
|
||||
})
|
||||
return { success: false, transportError: true }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function isTurnstileConfigured(): boolean {
|
||||
return Boolean(env.TURNSTILE_SECRET_KEY)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isAllowedExternalUrl, sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
|
||||
|
||||
describe('isAllowedExternalUrl', () => {
|
||||
it('allows http, https, and mailto URLs', () => {
|
||||
expect(isAllowedExternalUrl('https://example.com/deck')).toBe(true)
|
||||
expect(isAllowedExternalUrl('http://example.com/deck')).toBe(true)
|
||||
expect(isAllowedExternalUrl('mailto:support@example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects scriptable, data, and relative URLs', () => {
|
||||
expect(isAllowedExternalUrl('javascript:alert(1)')).toBe(false)
|
||||
expect(isAllowedExternalUrl('data:text/html,<script>alert(1)</script>')).toBe(false)
|
||||
expect(isAllowedExternalUrl('/workspace/files')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeRenderedHyperlinks', () => {
|
||||
function containerWithAnchor(href: string): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
const anchor = document.createElement('a')
|
||||
anchor.setAttribute('href', href)
|
||||
anchor.textContent = 'link'
|
||||
container.appendChild(anchor)
|
||||
return container
|
||||
}
|
||||
|
||||
it('strips javascript: hrefs from a docx-preview hyperlink rendering', () => {
|
||||
const container = containerWithAnchor(
|
||||
"javascript:document.body.setAttribute('data-xss-fired','1')"
|
||||
)
|
||||
sanitizeRenderedHyperlinks(container)
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor?.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
|
||||
it('strips data: and vbscript: hrefs', () => {
|
||||
for (const href of ['data:text/html,<script>alert(1)</script>', 'vbscript:msgbox(1)']) {
|
||||
const container = containerWithAnchor(href)
|
||||
sanitizeRenderedHyperlinks(container)
|
||||
expect(container.querySelector('a')?.hasAttribute('href')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves same-document bookmark anchors', () => {
|
||||
const container = containerWithAnchor('#section-2')
|
||||
sanitizeRenderedHyperlinks(container)
|
||||
expect(container.querySelector('a')?.getAttribute('href')).toBe('#section-2')
|
||||
})
|
||||
|
||||
it('keeps allowed external links and adds rel=noopener noreferrer', () => {
|
||||
const container = containerWithAnchor('https://example.com/report')
|
||||
sanitizeRenderedHyperlinks(container)
|
||||
const anchor = container.querySelector('a')
|
||||
expect(anchor?.getAttribute('href')).toBe('https://example.com/report')
|
||||
expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* URL safety utilities for external hyperlinks/media in untrusted document content
|
||||
* (PPTX, DOCX, and other previews rendered into the app origin).
|
||||
*/
|
||||
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
|
||||
|
||||
/**
|
||||
* Returns true only for absolute URLs with an allowed protocol.
|
||||
*/
|
||||
export function isAllowedExternalUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return ALLOWED_PROTOCOLS.has(parsed.protocol.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralizes anchors rendered from untrusted document content (e.g. docx-preview,
|
||||
* which copies an external-relationship `Target` straight into `href` with no scheme
|
||||
* check). Same-document fragment links (`#bookmark`) are left intact; anything else
|
||||
* that isn't http/https/mailto has its `href` stripped so the anchor can't navigate.
|
||||
* Surviving external links get `rel="noopener noreferrer"` to block tabnabbing.
|
||||
*/
|
||||
export function sanitizeRenderedHyperlinks(root: ParentNode): void {
|
||||
for (const anchor of root.querySelectorAll('a[href]')) {
|
||||
const href = anchor.getAttribute('href') ?? ''
|
||||
if (href.startsWith('#')) continue
|
||||
if (isAllowedExternalUrl(href)) {
|
||||
anchor.setAttribute('rel', 'noopener noreferrer')
|
||||
continue
|
||||
}
|
||||
anchor.removeAttribute('href')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user