commit 61d1f38de62a5fbe45590b345d610196d583f7ce
parent 14c2c7e5bb8785d0974240972a58bbdf256e114c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 20 Sep 2026 03:18:38 -0400
download filter: the operator's regex is server-side code, so bound it
The pattern runs over every stored title+description on every snapshot, every
sync page and every render of the Configure stage — on the server's single
thread. A catastrophically backtracking one does not fail a request, it hangs
the editor. `^(\w+\s?)*$` is not a contrived example: it is what someone types
when they mean "words separated by spaces".
The form now refuses patterns over 200 characters and ones with a nested
quantifier (a repeated group that itself repeats), and says so in the hint so
the rule is learned before it is hit. The check is deliberately crude — the
general problem is undecidable and this does not pretend otherwise; it catches
the shape behind essentially every accidental case. The matcher also caps the
description at 2 KB, which bounds the input side of the same cost. That cap is
a real trade (a needle past 2 KB is not matched) and the test states it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 94 insertions(+), 2 deletions(-)
diff --git a/common/lib/downloadFilters.test.ts b/common/lib/downloadFilters.test.ts
@@ -2,8 +2,10 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import type { ChannelConfig } from "./channelConfig";
import {
+ DOWNLOAD_FILTER_DESCRIPTION_LIMIT,
classifyAgainstFilter,
compileDownloadFilter,
+ downloadFilterPatternProblem,
downloadFilterText,
evaluateDownloadFilters,
titleFilterRejects,
@@ -365,3 +367,45 @@ test("an upcoming stream is accepted by the filter and still deferred by skip-li
assert.equal(d.filter, "skipLive");
assert.match(d.reason, /upcoming/);
});
+
+// --- the operator's regex is server-side code -------------------------------
+
+test("a catastrophically backtracking pattern is refused", () => {
+ // The canonical ReDoS shape, and one a person types by accident when they
+ // mean "words separated by spaces".
+ assert.match(
+ downloadFilterPatternProblem("^(\\w+\\s?)*$") ?? "",
+ /nested quantifier/,
+ );
+ assert.match(downloadFilterPatternProblem("(a+)+") ?? "", /nested quantifier/);
+ assert.match(downloadFilterPatternProblem("(a*)*b") ?? "", /nested quantifier/);
+ assert.match(
+ downloadFilterPatternProblem("x".repeat(201)) ?? "",
+ /200 characters or fewer/,
+ );
+});
+
+test("ordinary patterns are not refused", () => {
+ for (const ok of [
+ "guest",
+ "guest|special",
+ "^Ep\\.? ?\\d+",
+ "(guest|host) episode",
+ "\\bQ&A\\b",
+ "a{2,4}",
+ ]) {
+ assert.equal(downloadFilterPatternProblem(ok), null, ok);
+ }
+});
+
+test("the matched description is capped", () => {
+ const long = "x".repeat(DOWNLOAD_FILTER_DESCRIPTION_LIMIT + 500) + "needle";
+ const text = downloadFilterText({ title: "t", description: long });
+ assert.equal(text.length, 1 + 1 + DOWNLOAD_FILTER_DESCRIPTION_LIMIT);
+ // The cap is what bounds the matcher's worst case, so something past it is
+ // genuinely not matched — that is the trade, and it is stated here.
+ assert.equal(
+ titleFilterRejects(compiled("needle"), { title: "t", description: long }),
+ true,
+ );
+});
diff --git a/common/lib/downloadFilters.ts b/common/lib/downloadFilters.ts
@@ -145,10 +145,49 @@ function isLivestream(meta: FilterableVideo): boolean {
// The only text a download filter ever sees. Kept here so the metadata scan's
// stored entries and the download-time prefetch can never be matched against
// different strings.
+// How much description the matcher ever sees. A description can be tens of
+// kilobytes of links and timestamps, and a backtracking regex's cost grows with
+// the input — over ~1,800 stored entries, re-run on every snapshot, every sync
+// page and every render of the Configure stage. Two kilobytes is far more than
+// a title-matching pattern needs and bounds the worst case.
+export const DOWNLOAD_FILTER_DESCRIPTION_LIMIT = 2048;
+
export function downloadFilterText(
meta: FilterableVideo | null | undefined,
): string {
- return `${meta?.title ?? ""}\n${meta?.description ?? ""}`;
+ const description = (meta?.description ?? "").slice(
+ 0,
+ DOWNLOAD_FILTER_DESCRIPTION_LIMIT,
+ );
+ return `${meta?.title ?? ""}\n${description}`;
+}
+
+// Is this pattern safe to hand to the matcher? The operator's regex runs over
+// every stored title+description on every snapshot, every sync page and every
+// render of the Configure stage, on the server's single thread — so a
+// catastrophically backtracking pattern like `^(\w+\s?)*$` does not fail a
+// request, it hangs the editor.
+//
+// Deliberately CRUDE: a length cap and a check for a quantified group that is
+// itself quantified, which is the shape behind essentially every real
+// ReDoS. This cannot be exhaustive (deciding it in general is undecidable) and
+// does not try to be — it is the form's guard, refusing the patterns a person
+// actually types by accident. Returns null when the pattern is fine.
+export const DOWNLOAD_FILTER_PATTERN_MAX_LENGTH = 200;
+
+export function downloadFilterPatternProblem(pattern: string): string | null {
+ if (pattern.length > DOWNLOAD_FILTER_PATTERN_MAX_LENGTH) {
+ return `must be ${DOWNLOAD_FILTER_PATTERN_MAX_LENGTH} characters or fewer (this one is ${pattern.length})`;
+ }
+ // A group whose contents end in a quantifier, itself quantified:
+ // (a+)+ (a*)* (\w+\s?)* (a{2,}){3,} …
+ // `?` is in the class because `(\w+\s?)*` — "words separated by spaces",
+ // which is what a person means when they type it — is the exact shape that
+ // hangs, and its group ends in `?`.
+ if (/\([^()]*[+*?}][?]?\)\s*[+*{]/.test(pattern)) {
+ return "contains a nested quantifier (a repeated group that itself repeats, e.g. `(\\w+\\s?)*`), which can take exponential time to match";
+ }
+ return null;
}
// Why a video passed, or that it didn't. "livestream" exists so the UI can say
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -712,7 +712,7 @@ export function ChannelForm({
value={filterExclude}
onChange={setFilterExclude}
placeholder="(exclude nothing)"
- hint="Case-insensitive regex, applied to title + description and winning over include. Matching videos are settled until you change this."
+ hint="Case-insensitive regex, applied to title + description and winning over include. Matching videos are settled until you change this. Patterns are capped at 200 characters and must not nest quantifiers — this runs on the server for every stored title."
/>
<label className="flex items-start gap-2 text-sm">
<input
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -21,6 +21,7 @@ import {
import { handleFromAccountUrl } from "yt-dlp-transcript-common/social/fetchers";
import { isCookieMode } from "yt-dlp-transcript-common/lib/cookiePolicy";
import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat";
+import { downloadFilterPatternProblem } from "yt-dlp-transcript-common/lib/downloadFilters";
export type ParsedChannelForm = {
name: string;
@@ -229,6 +230,14 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
}`,
);
}
+ // Valid is not the same as safe. This pattern runs over every stored
+ // title+description on every snapshot, every sync page and every render of
+ // this form, on the server's single thread — a catastrophically
+ // backtracking one does not fail a request, it hangs the editor.
+ const problem = downloadFilterPatternProblem(pattern);
+ if (problem) {
+ throw new Error(`Download filter ${label} ${problem}`);
+ }
}
const includeLivestreams =
formData.get("downloadFilterIncludeLivestreams") != null;