From acb43b6dace06cf9adbaca3b5812abb5d6f74a9a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 15 May 2026 17:11:26 +0700 Subject: [PATCH] fix: harden media source resolution --- src/media/mediaResolver.ts | 40 ++++++++++++++++++++----------- tests/media/mediaResolver.test.ts | 23 ++++++++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/media/mediaResolver.ts b/src/media/mediaResolver.ts index c90beaf..14adec3 100644 --- a/src/media/mediaResolver.ts +++ b/src/media/mediaResolver.ts @@ -11,18 +11,14 @@ export async function resolveMediaSource( throw new AppError("Media source is required", "MISSING_MEDIA_SOURCE", 400); } - if (source.startsWith("http://") || source.startsWith("https://")) { - return { - source, - title: titleFromUrl(source), - kind: "url", - }; - } + const urlSource = resolveUrlSource(source); + if (urlSource) return urlSource; - if (existsSync(source) && statSync(source).isFile()) { + const localPath = path.resolve(source); + if (existsSync(localPath) && statSync(localPath).isFile()) { return { - source, - title: path.basename(source), + source: localPath, + title: path.basename(localPath), kind: "local", }; } @@ -34,8 +30,24 @@ export async function resolveMediaSource( ); } -function titleFromUrl(source: string): string { - const url = new URL(source); +function resolveUrlSource(source: string): ResolvedMediaSource | null { + let url: URL; + try { + url = new URL(source); + } catch { + return null; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + + return { + source, + title: titleFromUrl(url), + kind: "url", + }; +} + +function titleFromUrl(url: URL): string { const filename = decodeURIComponent(url.pathname.split("/").pop() || ""); - return filename || url.hostname; -} \ No newline at end of file + return path.basename(filename) || url.hostname; +} diff --git a/tests/media/mediaResolver.test.ts b/tests/media/mediaResolver.test.ts index db66086..1f6c8dc 100644 --- a/tests/media/mediaResolver.test.ts +++ b/tests/media/mediaResolver.test.ts @@ -33,10 +33,33 @@ describe("resolveMediaSource", () => { } satisfies Partial); }); + it("sanitizes URL titles", async () => { + await expect( + resolveMediaSource("https://example.com/%2e%2e%2fsecret.mp3"), + ).resolves.toMatchObject({ + title: "secret.mp3", + kind: "url", + }); + }); + it("rejects unsupported sources", async () => { await expect(resolveMediaSource("not a url or file")).rejects.toMatchObject({ code: "UNSUPPORTED_MEDIA_SOURCE", statusCode: 400, } satisfies Partial); }); + + it("rejects non-http URL sources", async () => { + await expect(resolveMediaSource("file:///tmp/song.mp3")).rejects.toMatchObject({ + code: "UNSUPPORTED_MEDIA_SOURCE", + statusCode: 400, + } satisfies Partial); + }); + + it("rejects malformed http URLs as unsupported sources", async () => { + await expect(resolveMediaSource("https://[invalid")).rejects.toMatchObject({ + code: "UNSUPPORTED_MEDIA_SOURCE", + statusCode: 400, + } satisfies Partial); + }); });