commit 89029b2c6fecf1f53b9ec1a2afe2c6d2f5956e52
parent dd53e9831b1d30ac760eb80c6173f5cca40bb471
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 20 Sep 2026 19:41:11 -0400
fetch-window: refuse a bare `..` as a slug or an id
/^[\w.-]+$/ was written to stop traversal and allows a dot, so ".." passed the
pattern that exists to reject it. It would have died on a missing config
rather than escaping, which is luck, not a guard.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/editor/app/api/media/fetch-window/route.ts b/editor/app/api/media/fetch-window/route.ts
@@ -24,7 +24,12 @@ export const dynamic = "force-dynamic";
//
// The download PAUSE does not gate it; see fetchWindowAction for why.
+// Both land in a filesystem path. Anchored — and `.` / `..` are refused
+// separately, because the class allows a dot and "`..`" alone would otherwise
+// pass a pattern written to stop traversal.
const ID_RE = /^[\w.-]+$/;
+const isPathSegment = (v: string): boolean =>
+ ID_RE.test(v) && v !== "." && v !== "..";
type Body = {
channelSlug?: unknown;
@@ -98,15 +103,13 @@ export async function POST(request: Request) {
const channelSlug = str(body.channelSlug);
const videoId = str(body.videoId);
- // Both land in a filesystem path. Anchored, so `..` and a separator are
- // refused at the door rather than by whatever the path math happens to do.
- if (!channelSlug || !ID_RE.test(channelSlug)) {
+ if (!channelSlug || !isPathSegment(channelSlug)) {
return NextResponse.json(
{ error: "channelSlug is required and must match /^[\\w.-]+$/" },
{ status: 400 },
);
}
- if (!videoId || !ID_RE.test(videoId)) {
+ if (!videoId || !isPathSegment(videoId)) {
return NextResponse.json(
{ error: "videoId is required and must match /^[\\w.-]+$/" },
{ status: 400 },
diff --git a/editor/e2e/fetch-window.spec.ts b/editor/e2e/fetch-window.spec.ts
@@ -89,6 +89,8 @@ test("a window is refused unless it names who asked and a sane span", async ({
const cases: Array<[Record<string, unknown>, string]> = [
[{ channelSlug: "../escape", videoId: VIDEO, from: 1, to: 2, requestedBy: "umtool" }, "traversal in the slug"],
[{ channelSlug: SLUG, videoId: "a/b", from: 1, to: 2, requestedBy: "umtool" }, "separator in the id"],
+ [{ channelSlug: "..", videoId: VIDEO, from: 1, to: 2, requestedBy: "umtool" }, "a bare .. slug"],
+ [{ channelSlug: SLUG, videoId: "..", from: 1, to: 2, requestedBy: "umtool" }, "a bare .. id"],
[{ channelSlug: SLUG, videoId: VIDEO, from: 42, to: 12, requestedBy: "umtool" }, "backwards window"],
[{ channelSlug: SLUG, videoId: VIDEO, from: 0, to: 1000, requestedBy: "umtool" }, "past the span cap"],
[{ channelSlug: SLUG, videoId: VIDEO, from: 1, to: 2 }, "anonymous"],