Skip to content

Commit dd6f00a

Browse files
Waleed Latifwaleedlatif1
authored andcommitted
fix(security): close ReDoS, zip-bomb, SSRF and redaction gaps found by audit
A dependency re-audit asked "does this library actually deliver the guarantee the calling code assumes" rather than "is it popular". These are the results. Security: - Copilot VFS glob() handed a caller-controlled pattern to micromatch, whose picomatch backend compiles `*` into a backtracking regex. A 25-char pattern took 41s; 29 chars exceeded 180s. Matching now runs on RE2, mirroring the grep() sibling in the same file that already did this. micromatch.makeRe() emits lookaheads RE2 cannot express, so picomatch's dot-assertions are translated into character exclusions; a differential test covers 519,688 pattern/path pairs against micromatch with no mismatches. Patterns that still carry an assertion fail closed rather than falling back. - doc-parser was the only OOXML-adjacent parser without the zip-bomb guard. officeparser sniffs content rather than extension, so a docx renamed .doc reached an unguarded unzip and could OOM the shared server. Genuine OLE2 .doc files are unaffected. Same guard added to the copilot style reader. - json-yaml-chunker called yaml.load with no alias-expansion cap. js-yaml has no maxAliasCount, so this is routed through assertYamlWithinLimits like the file parser already was. - Video tools and falai fetched provider-supplied URLs raw, including one that replayed an Authorization header to a polled status_url. Those URLs are now origin-pinned and revalidated on every poll. Cross-origin redirects drop credential headers by default, keyed on registrable domain rather than exact origin so same-site subdomain hops and http->https upgrades keep working. - Redaction key patterns were fully anchored, so openai_api_key, x-api-key, set-cookie, secretAccessKey and session_id were logged in the clear. The matcher now tokenizes keys, with exemptions so token-usage fields such as promptTokens stay readable. - drawtext escaping did not match ffmpeg's quoting grammar; a quote in agent supplied text escaped into filter options. Text is passed via textfile= so it never enters the filter string. - Loop conditions interpolated a resolved value into a JS string by hand. Supply chain: - Vendor free-email-domains' data. Its postinstall fetched a CDN CSV and overwrote the shipped list, so the lockfile hash covered the tarball but not the data actually used. It is inert under Bun today only because the package is outside trustedDependencies. - Document why xlsx is pinned to the SheetJS CDN: the npm copy is frozen at 0.18.5 with two unfixed CVEs, and a URL dependency is invisible to audit tooling, so upgrades have to be tracked by hand. Dependencies: - @daytonaio/sdk is deprecated in favour of @daytona/sdk (same API). - The @react-email/components 0.x line is deprecated; 1.0.12 needs no source changes here since no template uses <Tailwind> and render() already moved. - Load the pptx preview lazily so echarts leaves the Home, Files and share page bundles. - Drop autoprefixer (absent from the PostCSS config, so it never ran) and tailwind-merge (no importers; @sim/emcn declares its own).
1 parent b69fdbd commit dd6f00a

34 files changed

Lines changed: 8307 additions & 299 deletions

apps/sim/app/api/tools/onedrive/upload/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
4+
/** Pinned to the SheetJS CDN, not npm — see the note in `lib/file-parsers/xlsx-parser.ts`. */
45
import * as XLSX from 'xlsx'
56
import { onedriveUploadContract } from '@/lib/api/contracts/tools/microsoft'
67
import { parseRequest } from '@/lib/api/server'

apps/sim/app/api/tools/video/route.ts

Lines changed: 110 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { videoProviders, videoToolContract } from '@/lib/api/contracts/tools/med
88
import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server'
99
import { checkInternalAuth } from '@/lib/auth/hybrid'
1010
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
11+
import {
12+
secureFetchWithPinnedIP,
13+
validateUrlWithDNS,
14+
} from '@/lib/core/security/input-validation.server'
1115
import {
1216
assertKnownSizeWithinLimit,
1317
DEFAULT_MAX_ERROR_BODY_BYTES,
@@ -18,6 +22,7 @@ import {
1822
readResponseToBufferWithLimit,
1923
} from '@/lib/core/utils/stream-limits'
2024
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
25+
import { buildFalQueueUrl, FAL_QUEUE_ORIGIN, resolveFalQueueUrl } from '@/lib/media/falai'
2126
import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing'
2227
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
2328
import { assertToolFileAccess } from '@/app/api/files/authorization'
@@ -36,15 +41,31 @@ export const dynamic = 'force-dynamic'
3641
*/
3742
export const maxDuration = 5400
3843

39-
async function readVideoResponseBuffer(response: Response, label: string): Promise<Buffer> {
44+
/**
45+
* Structural shape shared by `fetch`'s `Response` and the SSRF-guarded
46+
* `SecureFetchResponse`, so the body readers below accept either.
47+
*/
48+
interface ReadableHttpResponse {
49+
ok: boolean
50+
status: number
51+
headers: { get(name: string): string | null }
52+
body?: ReadableStream<Uint8Array> | null
53+
arrayBuffer?: () => Promise<ArrayBuffer>
54+
text?: () => Promise<string>
55+
}
56+
57+
async function readVideoResponseBuffer(
58+
response: ReadableHttpResponse,
59+
label: string
60+
): Promise<Buffer> {
4061
return readResponseToBufferWithLimit(response, {
4162
maxBytes: MAX_VIDEO_OUTPUT_BYTES,
4263
label,
4364
})
4465
}
4566

4667
async function readVideoJson<T = Record<string, unknown>>(
47-
response: Response,
68+
response: ReadableHttpResponse,
4869
label: string
4970
): Promise<T> {
5071
return readResponseJsonWithLimit<T>(response, {
@@ -53,13 +74,52 @@ async function readVideoJson<T = Record<string, unknown>>(
5374
})
5475
}
5576

56-
async function readVideoErrorText(response: Response, label: string): Promise<string> {
77+
async function readVideoErrorText(response: ReadableHttpResponse, label: string): Promise<string> {
5778
return readResponseTextWithLimit(response, {
5879
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
5980
label,
6081
}).catch(() => '')
6182
}
6283

84+
/**
85+
* Fetches a provider-supplied URL through the SSRF-guarded client. These URLs come out of
86+
* provider response bodies, so they are untrusted input.
87+
*/
88+
async function secureGetFromUntrustedUrl(
89+
url: string,
90+
paramName: string,
91+
options: { headers?: Record<string, string>; maxResponseBytes: number }
92+
) {
93+
const validation = await validateUrlWithDNS(url, paramName)
94+
if (!validation.isValid || !validation.resolvedIP) {
95+
throw new Error(validation.error || `${paramName} failed validation`)
96+
}
97+
return secureFetchWithPinnedIP(url, validation.resolvedIP, {
98+
method: 'GET',
99+
headers: options.headers,
100+
maxResponseBytes: options.maxResponseBytes,
101+
})
102+
}
103+
104+
/** Downloads a provider-supplied video URL through the SSRF-guarded client. */
105+
async function downloadVideoFromUrl(
106+
url: string,
107+
label: string,
108+
headers?: Record<string, string>
109+
): Promise<Buffer> {
110+
const response = await secureGetFromUntrustedUrl(url, 'videoUrl', {
111+
headers,
112+
maxResponseBytes: MAX_VIDEO_OUTPUT_BYTES,
113+
})
114+
115+
if (!response.ok) {
116+
await readVideoErrorText(response, `${label} error response`)
117+
throw new Error(`Failed to download video: ${response.status}`)
118+
}
119+
120+
return readVideoResponseBuffer(response, `${label} response`)
121+
}
122+
63123
export const POST = withRouteHandler(async (request: NextRequest) => {
64124
const requestId = generateId()
65125
logger.info(`[${requestId}] Video generation request started`)
@@ -461,14 +521,8 @@ async function generateWithRunway(
461521
throw new Error('No video URL in response')
462522
}
463523

464-
const videoResponse = await fetch(videoUrl)
465-
if (!videoResponse.ok) {
466-
await readVideoErrorText(videoResponse, 'Runway video error response')
467-
throw new Error(`Failed to download video: ${videoResponse.status}`)
468-
}
469-
470524
return {
471-
buffer: await readVideoResponseBuffer(videoResponse, 'Runway video response'),
525+
buffer: await downloadVideoFromUrl(videoUrl, 'Runway video'),
472526
width: dimensions.width,
473527
height: dimensions.height,
474528
jobId: taskId,
@@ -486,6 +540,21 @@ async function generateWithRunway(
486540
throw new Error('Runway generation timed out')
487541
}
488542

543+
/**
544+
* True when a provider-supplied URI is served by Google. The Veo download URI comes out of
545+
* the operation status body, so the `x-goog-api-key` credential is only attached when the
546+
* target really is a Google host — a hostile or spoofed URI never receives the key.
547+
*/
548+
function isGoogleApiHost(url: string): boolean {
549+
try {
550+
const { protocol, hostname } = new URL(url)
551+
if (protocol !== 'https:') return false
552+
return hostname === 'googleapis.com' || hostname.endsWith('.googleapis.com')
553+
} catch {
554+
return false
555+
}
556+
}
557+
489558
async function generateWithVeo(
490559
apiKey: string,
491560
model: string,
@@ -583,19 +652,12 @@ async function generateWithVeo(
583652
throw new Error('No video URI in response')
584653
}
585654

586-
const videoResponse = await fetch(videoUri, {
587-
headers: {
588-
'x-goog-api-key': apiKey,
589-
},
590-
})
591-
592-
if (!videoResponse.ok) {
593-
await readVideoErrorText(videoResponse, 'Veo video error response')
594-
throw new Error(`Failed to download video: ${videoResponse.status}`)
595-
}
596-
597655
return {
598-
buffer: await readVideoResponseBuffer(videoResponse, 'Veo video response'),
656+
buffer: await downloadVideoFromUrl(
657+
videoUri,
658+
'Veo video',
659+
isGoogleApiHost(videoUri) ? { 'x-goog-api-key': apiKey } : undefined
660+
),
599661
width: dimensions.width,
600662
height: dimensions.height,
601663
jobId: operationName,
@@ -697,14 +759,8 @@ async function generateWithLuma(
697759
throw new Error('No video URL in response')
698760
}
699761

700-
const videoResponse = await fetch(videoUrl)
701-
if (!videoResponse.ok) {
702-
await readVideoErrorText(videoResponse, 'Luma video error response')
703-
throw new Error(`Failed to download video: ${videoResponse.status}`)
704-
}
705-
706762
return {
707-
buffer: await readVideoResponseBuffer(videoResponse, 'Luma video response'),
763+
buffer: await downloadVideoFromUrl(videoUrl, 'Luma video'),
708764
width: dimensions.width,
709765
height: dimensions.height,
710766
jobId: generationId,
@@ -859,15 +915,8 @@ async function generateWithMiniMax(
859915
throw new Error('No download URL in file response')
860916
}
861917

862-
// Download the actual video file
863-
const videoResponse = await fetch(videoUrl)
864-
if (!videoResponse.ok) {
865-
await readVideoErrorText(videoResponse, 'MiniMax video error response')
866-
throw new Error(`Failed to download video from URL: ${videoResponse.status}`)
867-
}
868-
869918
return {
870-
buffer: await readVideoResponseBuffer(videoResponse, 'MiniMax video response'),
919+
buffer: await downloadVideoFromUrl(videoUrl, 'MiniMax video'),
871920
width: dimensions.width,
872921
height: dimensions.height,
873922
jobId: taskId,
@@ -1160,12 +1209,15 @@ function getFalAIErrorMessage(error: unknown): string {
11601209
return 'Unknown error'
11611210
}
11621211

1163-
function buildFalAIQueueUrl(
1164-
endpoint: string,
1165-
requestId: string,
1166-
path: 'response' | 'status'
1167-
): string {
1168-
return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}`
1212+
/**
1213+
* Requests a Fal.ai queue URL through the SSRF-guarded client. Invoked on every poll so
1214+
* the provider-supplied URL is revalidated each time rather than trusted once.
1215+
*/
1216+
async function fetchFalAIQueue(url: string, apiKey: string) {
1217+
return secureGetFromUntrustedUrl(url, 'falQueueUrl', {
1218+
headers: { Authorization: `Key ${apiKey}` },
1219+
maxResponseBytes: MAX_VIDEO_JSON_BYTES,
1220+
})
11691221
}
11701222

11711223
async function generateWithFalAI(
@@ -1218,7 +1270,7 @@ async function generateWithFalAI(
12181270
requestBody.generate_audio = generateAudio
12191271
}
12201272

1221-
const createResponse = await fetch(`https://queue.fal.run/${modelConfig.endpoint}`, {
1273+
const createResponse = await fetch(`${FAL_QUEUE_ORIGIN}/${modelConfig.endpoint}`, {
12221274
method: 'POST',
12231275
headers: {
12241276
Authorization: `Key ${apiKey}`,
@@ -1242,12 +1294,14 @@ async function generateWithFalAI(
12421294
throw new Error('Fal.ai queue response missing request_id')
12431295
}
12441296

1245-
const statusUrl =
1246-
getStringProperty(createData, 'status_url') ||
1247-
buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'status')
1248-
const responseUrl =
1249-
getStringProperty(createData, 'response_url') ||
1250-
buildFalAIQueueUrl(modelConfig.endpoint, requestIdFal, 'response')
1297+
const statusUrl = resolveFalQueueUrl(
1298+
getStringProperty(createData, 'status_url'),
1299+
buildFalQueueUrl(modelConfig.endpoint, requestIdFal, 'status')
1300+
)
1301+
const responseUrl = resolveFalQueueUrl(
1302+
getStringProperty(createData, 'response_url'),
1303+
buildFalQueueUrl(modelConfig.endpoint, requestIdFal, 'result')
1304+
)
12511305

12521306
logger.info(`[${requestId}] Fal.ai request created: ${requestIdFal}`)
12531307

@@ -1258,11 +1312,7 @@ async function generateWithFalAI(
12581312
while (attempts < maxAttempts) {
12591313
await sleep(pollIntervalMs)
12601314

1261-
const statusResponse = await fetch(statusUrl, {
1262-
headers: {
1263-
Authorization: `Key ${apiKey}`,
1264-
},
1265-
})
1315+
const statusResponse = await fetchFalAIQueue(statusUrl, apiKey)
12661316

12671317
if (!statusResponse.ok) {
12681318
await readVideoErrorText(statusResponse, 'Fal.ai status error response')
@@ -1282,13 +1332,9 @@ async function generateWithFalAI(
12821332

12831333
logger.info(`[${requestId}] Fal.ai generation completed after ${attempts * 5}s`)
12841334

1285-
const resultResponse = await fetch(
1286-
getStringProperty(statusData, 'response_url') || responseUrl,
1287-
{
1288-
headers: {
1289-
Authorization: `Key ${apiKey}`,
1290-
},
1291-
}
1335+
const resultResponse = await fetchFalAIQueue(
1336+
resolveFalQueueUrl(getStringProperty(statusData, 'response_url'), responseUrl),
1337+
apiKey
12921338
)
12931339

12941340
if (!resultResponse.ok) {
@@ -1309,11 +1355,7 @@ async function generateWithFalAI(
13091355
throw new Error('No video URL in response')
13101356
}
13111357

1312-
const videoResponse = await fetch(videoUrl)
1313-
if (!videoResponse.ok) {
1314-
await readVideoErrorText(videoResponse, 'Fal.ai video error response')
1315-
throw new Error(`Failed to download video: ${videoResponse.status}`)
1316-
}
1358+
const videoBuffer = await downloadVideoFromUrl(videoUrl, 'Fal.ai video')
13171359

13181360
let width = getNumberProperty(videoOutput, 'width') || 1920
13191361
let height = getNumberProperty(videoOutput, 'height') || 1080
@@ -1325,7 +1367,7 @@ async function generateWithFalAI(
13251367
}
13261368

13271369
return {
1328-
buffer: await readVideoResponseBuffer(videoResponse, 'Fal.ai video response'),
1370+
buffer: videoBuffer,
13291371
width,
13301372
height,
13311373
jobId: requestIdFal,

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { DocxPreview } from './docx-preview'
1616
import { resolveFileCategory } from './file-category'
1717
import { ImagePreview } from './image-preview'
1818
import type { PdfDocumentSource } from './pdf-viewer'
19-
import { PptxPreview } from './pptx-preview'
2019
import { PreviewPanel, resolvePreviewType } from './preview-panel'
2120
import {
2221
PREVIEW_LOADING_OVERLAY,
@@ -33,6 +32,19 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView
3332
ssr: false,
3433
})
3534

35+
/**
36+
* Lazy so `echarts` (~330 KB gzip, reached via `pptx-sandbox-host` →
37+
* `lib/pptx-renderer/renderer/chart-renderer`) stays out of every bundle that
38+
* statically imports this viewer - the Files page, the Home view and the public
39+
* share page - instead of only the visitors who open a PowerPoint file. The
40+
* fallback matches {@link PptxPreview}'s own pre-fetch frame, so the chunk load
41+
* and the binary fetch look like one continuous loading state.
42+
*/
43+
const PptxPreview = dynamic(() => import('./pptx-preview').then((m) => m.PptxPreview), {
44+
ssr: false,
45+
loading: () => <PreviewLoadingFrame className='h-full flex-1' tone='surface' />,
46+
})
47+
3648
const RichMarkdownEditor = dynamic(
3749
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
3850
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }

0 commit comments

Comments
 (0)