From 4797bbc5b96e2cca5532e43b58915c051746fe37 Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 25 Mar 2026 12:35:16 -0700 Subject: [PATCH] fix: reject path traversal and home-dir patterns in media parse layer (#54642) * fix: reject path traversal and home-dir patterns in media parse layer * Update parse tests --- src/media/parse.test.ts | 17 +++++++++++++++-- src/media/parse.ts | 42 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index 1fab5dc13fa..880cf7e2c0d 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -12,8 +12,6 @@ describe("splitMediaFromOutput", () => { const pathCases = [ ["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"], ["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'], - ["~/Pictures/My File.png", "MEDIA:~/Pictures/My File.png"], - ["../../etc/passwd", "MEDIA:../../etc/passwd"], ["./screenshots/image.png", "MEDIA:./screenshots/image.png"], ["media/inbound/image.png", "MEDIA:media/inbound/image.png"], ["./screenshot.png", " MEDIA:./screenshot.png"], @@ -31,6 +29,21 @@ describe("splitMediaFromOutput", () => { } }); + it("rejects traversal and home-dir paths and strips them from output", () => { + const traversalCases = [ + "MEDIA:../../../etc/passwd", + "MEDIA:../../.env", + "MEDIA:~/.ssh/id_rsa", + "MEDIA:~/Pictures/My File.png", + "MEDIA:./foo/../../../etc/shadow", + ]; + for (const input of traversalCases) { + const result = splitMediaFromOutput(input); + expect(result.mediaUrls, `should reject media: ${input}`).toBeUndefined(); + expect(result.text, `should strip from text: ${input}`).toBe(""); + } + }); + it("keeps audio_as_voice detection stable across calls", () => { const input = "Hello [[audio_as_voice]]"; const first = splitMediaFromOutput(input); diff --git a/src/media/parse.ts b/src/media/parse.ts index 9aa8893d095..3d068464564 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -18,10 +18,21 @@ const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; const HAS_FILE_EXT = /\.\w{1,10}$/; -// Recognize local file path patterns. Security validation is deferred to the -// load layer (loadWebMedia / resolveSandboxedMediaSource) which has the context -// needed to enforce sandbox roots and allowed directories. -function isLikelyLocalPath(candidate: string): boolean { +// Matches ".." as a standalone path segment (start, middle, or end). +const TRAVERSAL_SEGMENT_RE = /(?:^|[/\\])\.\.(?:[/\\]|$)/; + +function hasTraversalOrHomeDirPrefix(candidate: string): boolean { + return ( + candidate.startsWith("../") || + candidate === ".." || + candidate.startsWith("~") || + TRAVERSAL_SEGMENT_RE.test(candidate) + ); +} + +// Broad structural check: does this look like a local file path? Used only for +// stripping MEDIA: lines from output text — never for media approval. +function looksLikeLocalFilePath(candidate: string): boolean { return ( candidate.startsWith("/") || candidate.startsWith("./") || @@ -33,6 +44,21 @@ function isLikelyLocalPath(candidate: string): boolean { ); } +// Recognize safe local file path patterns for media approval, rejecting +// traversal and home-dir paths so they never reach downstream load/send logic. +function isLikelyLocalPath(candidate: string): boolean { + if (hasTraversalOrHomeDirPrefix(candidate)) { + return false; + } + return ( + candidate.startsWith("/") || + candidate.startsWith("./") || + WINDOWS_DRIVE_RE.test(candidate) || + candidate.startsWith("\\\\") || + (!SCHEME_RE.test(candidate) && (candidate.includes("/") || candidate.includes("\\"))) + ); +} + function isValidMedia( candidate: string, opts?: { allowSpaces?: boolean; allowBareFilename?: boolean }, @@ -54,6 +80,12 @@ function isValidMedia( return true; } + // Hard reject traversal/home-dir patterns before the bare-filename fallback + // to prevent path traversal bypasses (e.g. "../../.env" matching HAS_FILE_EXT). + if (hasTraversalOrHomeDirPrefix(candidate)) { + return false; + } + // Accept bare filenames (e.g. "image.png") only when the caller opts in. // This avoids treating space-split path fragments as separate media items. if (opts?.allowBareFilename && !SCHEME_RE.test(candidate) && HAS_FILE_EXT.test(candidate)) { @@ -169,7 +201,7 @@ export function splitMediaFromOutput(raw: string): { const trimmedPayload = payloadValue.trim(); const looksLikeLocalPath = - isLikelyLocalPath(trimmedPayload) || trimmedPayload.startsWith("file://"); + looksLikeLocalFilePath(trimmedPayload) || trimmedPayload.startsWith("file://"); if ( !unwrapped && validCount === 1 &&