Skip to content

Commit e02e0f7

Browse files
committed
fix(webapp): guard the scenario kit's app origin like its other hosts
The seed script refused a non-local Redis or ClickHouse host outright, but sent the target's API key to whatever `APP_ORIGIN` named. All three now go through one guard, which also fixes it for a bracketed IPv6 host — `URL.hostname` hands back `[::1]`, which the old set membership never matched.
1 parent 137aced commit e02e0f7

3 files changed

Lines changed: 86 additions & 6 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { checkLocalOrigin, isLocalHost, LOCAL_HOSTS } from "./localHostGuard";
3+
4+
describe("checkLocalOrigin", () => {
5+
it("accepts every host the Redis and ClickHouse guards accept", () => {
6+
for (const host of LOCAL_HOSTS) {
7+
const origin = host === "::1" ? "http://[::1]:3030" : `http://${host}:3030`;
8+
expect(checkLocalOrigin(origin)).toEqual({ ok: true, origin });
9+
}
10+
});
11+
12+
it("refuses a remote origin, so a seed script can't send an API key off-box", () => {
13+
expect(checkLocalOrigin("https://cloud.trigger.dev")).toEqual({
14+
ok: false,
15+
reason: "non_local",
16+
hostname: "cloud.trigger.dev",
17+
});
18+
expect(checkLocalOrigin("http://10.0.0.7:3030")).toEqual({
19+
ok: false,
20+
reason: "non_local",
21+
hostname: "10.0.0.7",
22+
});
23+
});
24+
25+
// "localhost.attacker.example" and "notlocalhost" both end or start with a local name.
26+
it("matches the whole hostname, never a prefix or suffix of one", () => {
27+
expect(checkLocalOrigin("http://localhost.attacker.example").ok).toBe(false);
28+
expect(checkLocalOrigin("http://notlocalhost:3030").ok).toBe(false);
29+
expect(isLocalHost("127.0.0.1.attacker.example")).toBe(false);
30+
});
31+
32+
it("refuses what it cannot parse rather than passing it through", () => {
33+
expect(checkLocalOrigin("localhost:3030").ok).toBe(false);
34+
expect(checkLocalOrigin("").ok).toBe(false);
35+
});
36+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* The one definition of "local" the dev-only seed scripts stage against. They carry API keys
3+
* and destructive writes, so every host they touch — Redis, ClickHouse, the webapp itself —
4+
* is checked here rather than each deciding for itself.
5+
*/
6+
7+
export const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
8+
9+
/** `URL.hostname` brackets IPv6, so `::1` arrives as `[::1]`. */
10+
export function isLocalHost(hostname: string): boolean {
11+
return LOCAL_HOSTS.has(hostname.replace(/^\[(.*)\]$/, "$1"));
12+
}
13+
14+
export type LocalOriginCheck =
15+
| { ok: true; origin: string }
16+
| { ok: false; reason: "unparseable" | "non_local"; hostname?: string };
17+
18+
/** Never returns the URL in the failure: an origin can carry credentials. */
19+
export function checkLocalOrigin(origin: string): LocalOriginCheck {
20+
let parsed: URL;
21+
try {
22+
parsed = new URL(origin);
23+
} catch {
24+
return { ok: false, reason: "unparseable" };
25+
}
26+
if (!isLocalHost(parsed.hostname)) {
27+
return { ok: false, reason: "non_local", hostname: parsed.hostname };
28+
}
29+
return { ok: true, origin };
30+
}

apps/webapp/seed-watch-scenarios.mts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,29 @@ import { randomUUID } from "node:crypto";
1010
// module's exports only through `default`.
1111
import dbServer from "./app/db.server";
1212
import errorFingerprinting from "./app/utils/errorFingerprinting";
13+
import localHostGuard from "./app/utils/localHostGuard";
1314
import eventCommon from "./app/v3/eventRepository/common.server";
1415

1516
const { prisma } = dbServer;
1617
const { calculateErrorFingerprint } = errorFingerprinting;
18+
const { isLocalHost, checkLocalOrigin } = localHostGuard;
1719
const { generateTraceId, generateSpanId } = eventCommon;
1820

1921
const APP_ORIGIN = process.env.APP_ORIGIN ?? "http://localhost:3030";
2022

23+
/** Every request to it carries the target's API key, so it is checked like the rest. */
24+
function appOrigin(): string {
25+
const checked = checkLocalOrigin(APP_ORIGIN);
26+
if (!checked.ok) {
27+
fail(
28+
checked.reason === "non_local"
29+
? `Refusing to send an API key to a non-local host: ${checked.hostname}`
30+
: "APP_ORIGIN isn't a URL."
31+
);
32+
}
33+
return checked.origin;
34+
}
35+
2136
const DEFAULT_RUN_SECONDS = 60;
2237
const DEFAULT_FAIL_TASK = "slow-fail";
2338
const DEFAULT_SUCCEED_TASK = "slow-succeed";
@@ -174,8 +189,6 @@ type RedisLike = {
174189

175190
const ZADD_BATCH = 1_000;
176191

177-
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
178-
179192
function envQueueKey(organizationId: string, environmentId: string): string {
180193
return `engine:runqueue:{org:${organizationId}}:env:${environmentId}`;
181194
}
@@ -195,7 +208,7 @@ async function openRedis(): Promise<RedisLike> {
195208
const port = Number(
196209
process.env.RUN_ENGINE_RUN_QUEUE_REDIS_PORT ?? process.env.REDIS_PORT ?? 6379
197210
);
198-
if (!LOCAL_HOSTS.has(host)) {
211+
if (!isLocalHost(host)) {
199212
fail(`Refusing to stage Redis on a non-local host: ${host}`);
200213
}
201214
const { createRedisClient } = await import("@internal/redis");
@@ -267,7 +280,7 @@ function clickhouse(): ClickHouse {
267280
}
268281
const parsed = new URL(url);
269282
// Never echo the URL: it carries credentials.
270-
if (!LOCAL_HOSTS.has(parsed.hostname)) {
283+
if (!isLocalHost(parsed.hostname)) {
271284
fail(`Refusing to run against a non-local ClickHouse host: ${parsed.hostname}`);
272285
}
273286
parsed.searchParams.delete("secure");
@@ -448,7 +461,8 @@ async function runScenario(
448461
taskFlag: string | undefined
449462
) {
450463
const taskId = taskFlag ?? (kind === "fail" ? DEFAULT_FAIL_TASK : DEFAULT_SUCCEED_TASK);
451-
const response = await fetch(`${APP_ORIGIN}/api/v1/tasks/${taskId}/trigger`, {
464+
const origin = appOrigin();
465+
const response = await fetch(`${origin}/api/v1/tasks/${taskId}/trigger`, {
452466
method: "POST",
453467
headers: {
454468
"content-type": "application/json",
@@ -457,7 +471,7 @@ async function runScenario(
457471
body: JSON.stringify({ payload: { seconds } }),
458472
}).catch((error: unknown) => {
459473
fail(
460-
`Could not reach ${APP_ORIGIN} (${error instanceof Error ? error.message : error}). ` +
474+
`Could not reach ${origin} (${error instanceof Error ? error.message : error}). ` +
461475
`Is the webapp running?`
462476
);
463477
});

0 commit comments

Comments
 (0)