From 7d22f4164933f9e4d0e8dda4161eb4012ee492df Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 20 Oct 2026 15:40:09 +0700 Subject: [PATCH 1/2] 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; From 15c653d8053720842189aeadf0466005994cebff Mon Sep 17 00:00:00 2001 From: Lex Date: Tue, 20 Oct 2026 15:51:06 +0700 Subject: [PATCH 2/2] fix(prompt): drop whole terminal escape sequences in readLine (#482) --- src/lib/prompt.ts | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts index bccead99..38edd272 100644 --- a/src/lib/prompt.ts +++ b/src/lib/prompt.ts @@ -50,6 +50,7 @@ function readLine( let buffer = ''; let resolved = false; let listening = false; + let escapeState: 'none' | 'esc' | 'csi' | 'ss3' = 'none'; const onData = (chunk: Buffer | string): void => { const str = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); @@ -59,7 +60,10 @@ function readLine( const processText = (str: string): void => { for (let i = 0; i < str.length; i += 1) { const code = str.charCodeAt(i); - // Enter (CR or LF) + + // Enter (CR or LF) and Ctrl-C take priority over any in-progress + // escape sequence, so a stray unterminated sequence can't swallow + // the submit key. if (code === 13 || code === 10) { let nextIndex = i + 1; if (code === 13 && str.charCodeAt(nextIndex) === 10) { @@ -69,7 +73,6 @@ function readLine( finish(); return; } - // Ctrl-C if (code === 3) { savePendingInput(''); cleanup(); @@ -80,6 +83,36 @@ function readLine( } return; } + + // Drop whole terminal escape sequences (CSI/SS3 and two-byte + // Alt+key) instead of only their ESC byte, so cursor/editing and + // bracketed-paste markers never leak into the answer. Enter and + // Ctrl-C are handled below with priority, but only when not inside + // an escape sequence. + if (escapeState === 'esc') { + if (code === 0x5b) { + escapeState = 'csi'; + continue; + } + if (code === 0x4f) { + escapeState = 'ss3'; + continue; + } + // Two-byte ESC x (Alt+key): drop this byte and reset. + escapeState = 'none'; + continue; + } + if (escapeState === 'csi' || escapeState === 'ss3') { + // Final byte of the sequence (0x40-0x7E) ends it; drop it too. + if (code >= 0x40 && code <= 0x7e) { + escapeState = 'none'; + } + continue; + } + if (code === 0x1b) { + escapeState = 'esc'; + continue; + } // Backspace / DEL if (code === 127 || code === 8) { if (buffer.length > 0) {