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
This commit is contained in:
Devin Robison
2026-03-25 12:35:16 -07:00
committed by GitHub
parent 84401223c7
commit 4797bbc5b9
2 changed files with 52 additions and 7 deletions

View File

@@ -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);

View File

@@ -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 &&