Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/lib/downloadFilters.test.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/downloadFilters.ts | 41++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/components/ChannelForm.tsx | 2+-
Meditor/app/channels/components/parseChannelForm.ts | 9+++++++++
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;