commit 98ccceb1d57770c44af3b2ea0d0b1c903f180cdd
parent b69a6629c94ea5523aba3540e559a16b29f271c1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 16:40:34 -0400
editor: the other four /api/test routes are behind the same door
worker-token was the sharpest case, not the only one. All four siblings were
mounted unconditionally on the same reasoning — "the editor is a localhost admin
tool" — and all four are unauthenticated GETs that mutate live process state:
invalidate-cache cancels every running job and drops four singletons, stuck-job
writes a fabricated record into the registry, resume-lane stops and restarts a
lane runner, uncaught-count resets a counter a spec asserts on. The operator's
browser is inside the loopback, so each is CSRF-able from any page they have
open.
One guard, `EDITOR_TEST_ROUTES`, set by the two scripts every harness boots
through, and the comments that argued the old position now say why it moved.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 30 insertions(+), 6 deletions(-)
diff --git a/editor/app/api/test/invalidate-cache/route.ts b/editor/app/api/test/invalidate-cache/route.ts
@@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache";
import { resetSnapshotScheduler } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
import { resetChannelSnapshotMemo } from "yt-dlp-transcript-common/controller/channels";
import { resetStorageProbeMemo } from "yt-dlp-transcript-common/controller/storageLocations";
+import { testRouteDenied } from "../_guard";
export const dynamic = "force-dynamic";
@@ -39,13 +40,20 @@ function cancelLiveJobs() {
}
}
-// E2E test harness only. Mounted unconditionally so it's reachable from the
-// dev:test script; the editor is intended for localhost use, not deployment.
+// E2E test harness only, and GUARDED BY `EDITOR_TEST_ROUTES` — which dev:test
+// and start:test set, so it is still reachable from exactly the servers that
+// need it. It cancels every live job and drops four singletons; an
+// unauthenticated GET doing that is CSRF-able from any page the operator has
+// open, loopback or not. See _guard.ts.
export async function POST() {
+ const denied = testRouteDenied();
+ if (denied) return denied;
return invalidate();
}
export async function GET() {
+ const denied = testRouteDenied();
+ if (denied) return denied;
return invalidate();
}
diff --git a/editor/app/api/test/resume-lane/route.ts b/editor/app/api/test/resume-lane/route.ts
@@ -6,6 +6,7 @@ import {
} from "yt-dlp-transcript-common/controller/autoRunner";
import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState";
+import { testRouteDenied } from "../_guard";
export const dynamic = "force-dynamic";
@@ -19,9 +20,14 @@ export const dynamic = "force-dynamic";
// It replaced /api/test/resume-backfill-sweep, which did this for the sweep;
// that route retired with the sweep in slice 1.3.
//
-// Mounted unconditionally, like the other /api/test routes: the editor is a
-// localhost admin tool, not a deployed service.
+// GUARDED BY `EDITOR_TEST_ROUTES`, like every other /api/test route. It was
+// mounted unconditionally on the reasoning that the editor is a localhost admin
+// tool — but the operator's browser is inside the loopback, so an
+// unauthenticated GET that stops and restarts a lane runner is CSRF-able.
+// See _guard.ts.
export async function GET(req: Request) {
+ const denied = testRouteDenied();
+ if (denied) return denied;
const lane = new URL(req.url).searchParams.get("lane") ?? "";
if (!LANES.includes(lane as AutoQueueKind)) {
return NextResponse.json(
diff --git a/editor/app/api/test/stuck-job/route.ts b/editor/app/api/test/stuck-job/route.ts
@@ -7,6 +7,7 @@ import {
type JobRecord,
} from "yt-dlp-transcript-common/jobs/registry";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { testRouteDenied } from "../_guard";
export const dynamic = "force-dynamic";
@@ -17,9 +18,13 @@ export const dynamic = "force-dynamic";
// NOT auto-healed, so it persists across polls and the test can prove
// FORCE-RELEASE (not auto-heal) clears it. We reproduce it directly
// (backdating startedAt) since a genuinely wedged child would be racy.
-// Mounted unconditionally, like the other /api/test routes — the editor is a
-// localhost admin tool, not deployed.
+// GUARDED BY `EDITOR_TEST_ROUTES`, like every other /api/test route. It was
+// mounted unconditionally on the reasoning that the editor is a localhost admin
+// tool — but the operator's browser is inside the loopback, so an
+// unauthenticated GET that writes into the registry is CSRF-able. See _guard.ts.
export async function GET(request: Request) {
+ const denied = testRouteDenied();
+ if (denied) return denied;
const url = new URL(request.url);
const queueKey = url.searchParams.get("queue") || "stuck-queue";
diff --git a/editor/app/api/test/uncaught-count/route.ts b/editor/app/api/test/uncaught-count/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
+import { testRouteDenied } from "../_guard";
export const dynamic = "force-dynamic";
@@ -55,6 +56,8 @@ function getState(): CountState {
}
export async function GET() {
+ const denied = testRouteDenied();
+ if (denied) return denied;
const state = getState();
return NextResponse.json({
uncaught: state.uncaught,
@@ -64,6 +67,8 @@ export async function GET() {
}
export async function DELETE() {
+ const denied = testRouteDenied();
+ if (denied) return denied;
const state = getState();
state.uncaught = 0;
state.unhandled = 0;