From 7d22f4164933f9e4d0e8dda4161eb4012ee492df Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 20 Oct 2026 15:40:09 +0700 Subject: [PATCH] fix(bundle): validate presigned download URLs and disable redirects (#452) --- src/commands/test.ts | 28 ++++++++++++++++++++++++++-- src/lib/bundle.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/commands/test.ts b/src/commands/test.ts index 8bf5ce5b..11631613 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -31,6 +31,7 @@ import { rename, stat, unlink } from 'node:fs/promises'; import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; import { Command } from 'commander'; +import * as net from 'node:net'; import { openInBrowser } from '../lib/browser.js'; import { emitDryRunBanner, @@ -149,7 +150,7 @@ import { } from '../lib/conflict-reason.js'; import { isProxyAgentActive } from '../lib/proxy.js'; import type { CliProjectEnvironment, CliProjectEnvListResponse } from './project-env.js'; -import { assertNotLocal } from '../lib/target-url.js'; +import { assertNotLocal, disallowedIpReason } from '../lib/target-url.js'; import { assertTargetUrlReachable } from '../lib/target-url-preflight.js'; import { assertLocalPortListening, @@ -16612,17 +16613,40 @@ async function streamPresignedBody(url: string, out: Output, deps: TestDeps): Pr return shutdown.runCriticalOperation(() => readPresignedBody(url, out, deps, shutdown.signal)); } +function assertSafeDownloadUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw localValidationError('url', 'must be a valid URL'); + } + if (parsed.protocol !== 'https:') { + throw localValidationError('url', 'must use the https scheme'); + } + const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (host === 'localhost') { + throw localValidationError('url', 'localhost is not allowed'); + } + if (net.isIP(host) !== 0) { + const reason = disallowedIpReason(host); + if (reason !== undefined) { + throw localValidationError('url', reason.reason); + } + } +} + async function readPresignedBody( url: string, out: Output, deps: TestDeps, shutdownSignal: AbortSignal, ): Promise { + assertSafeDownloadUrl(url); if (shutdownSignal.aborted) throw shutdownSignal.reason; const fetchImpl = deps.fetchImpl ?? globalThis.fetch.bind(globalThis); let response: Response; try { - response = await fetchImpl(url, { signal: shutdownSignal }); + response = await fetchImpl(url, { redirect: 'error', signal: shutdownSignal }); } catch (err) { if (shutdownSignal.aborted) throw shutdownSignal.reason; if (err instanceof InterruptError) throw err; diff --git a/src/lib/bundle.ts b/src/lib/bundle.ts index cb816271..54e9b6ef 100644 --- a/src/lib/bundle.ts +++ b/src/lib/bundle.ts @@ -42,6 +42,8 @@ import { createWriteStream } from 'node:fs'; import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { CliFailureContext, CliTestStep } from '../commands/test.js'; import { ApiError, InterruptError, TransportError, localValidationError } from './errors.js'; +import { disallowedIpReason } from './target-url.js'; +import * as net from 'node:net'; import { globalShutdown, type ShutdownHandle } from './interrupt.js'; import { defaultSleep, sleepUnlessInterrupted } from './poll-support.js'; import { requireEnum } from './validate.js'; @@ -860,6 +862,35 @@ function sidecarExtension(kind: 'log' | 'network' | 'console' | 'screenshot' | ' * retried up to STREAM_URL_MAX_RETRIES times with a fixed delay. * Presigned URLs are valid for 15 minutes, so retries are safe. */ + +/** + * SSRF guard for presigned artifact/code downloads: require https and reject + * loopback, link-local, RFC1918, and the IPv6 forms `disallowedIpReason` + * already classifies. Runs before any fetch so a disallowed URL never leaves + * the machine. + */ +function assertSafeDownloadUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw localValidationError('url', 'must be a valid URL'); + } + if (parsed.protocol !== 'https:') { + throw localValidationError('url', 'must use the https scheme'); + } + const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (host === 'localhost') { + throw localValidationError('url', 'localhost is not allowed'); + } + if (net.isIP(host) !== 0) { + const reason = disallowedIpReason(host); + if (reason !== undefined) { + throw localValidationError('url', reason.reason); + } + } +} + export async function streamUrlToFile( url: string, filePath: string, @@ -884,6 +915,7 @@ async function streamUrlToFileTracked( sleepFn: (ms: number) => Promise, shutdownSignal: AbortSignal, ): Promise { + assertSafeDownloadUrl(url); const artifactUrl = redactArtifactUrlForDetails(url); for (let attempt = 1; attempt <= STREAM_URL_MAX_RETRIES; attempt++) { if (shutdownSignal.aborted) throw shutdownSignal.reason;