Skip to content

Commit 8b6cbfe

Browse files
d-csclaude
andcommitted
fix(webapp): validate buffered redirect snapshot with a Zod schema
Replaces the ad-hoc \`as Record<string, unknown>\` + \`typeof === "string"\` checks in \`findBufferedRunRedirectInfo\` with a Zod \`safeParse\` against a schema for the subset of fields the redirect needs (envSlug / projectSlug / orgSlug / optional spanId). Wrong-typed or missing fields now collapse into a single parse-fail branch that logs the structured issue list and returns null. Adds a regression test for the structural-vs-typeof distinction: \`environment.slug: 42\` (number) is now rejected, where the previous \`typeof slug === "string"\` chain would silently accept any string- typed value but had no defence against shape drift in other fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7482e21 commit 8b6cbfe

2 files changed

Lines changed: 70 additions & 15 deletions

File tree

apps/webapp/app/v3/mollifier/syntheticRedirectInfo.server.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
import { deserialiseSnapshot, type MollifierBuffer } from "@trigger.dev/redis-worker";
22
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
3+
import { z } from "zod";
34
import { prisma } from "~/db.server";
45
import { logger } from "~/services/logger.server";
56
import { getMollifierBuffer } from "./mollifierBuffer.server";
67

8+
// Validated subset of a mollifier snapshot — just the fields needed to
9+
// rebuild a canonical run-detail URL for a buffered run. Anything else
10+
// in the payload is ignored. `safeParse` against this schema replaces
11+
// the ad-hoc `as Record<string, unknown>` + `typeof === "string"` checks
12+
// that the redirect path used to do by hand; missing or wrong-typed
13+
// fields collapse into a single `parsed.success === false` branch.
14+
const BufferedSnapshotSchema = z.object({
15+
spanId: z.string().optional(),
16+
environment: z.object({
17+
slug: z.string(),
18+
project: z.object({ slug: z.string() }),
19+
organization: z.object({ slug: z.string() }),
20+
}),
21+
});
22+
723
export type BufferedRunRedirectInfo = {
824
organizationSlug: string;
925
projectSlug: string;
@@ -60,9 +76,9 @@ export async function findBufferedRunRedirectInfo(
6076
if (!member) return null;
6177
}
6278

63-
let snapshot: Record<string, unknown>;
79+
let raw: unknown;
6480
try {
65-
snapshot = deserialiseSnapshot(entry.payload) as Record<string, unknown>;
81+
raw = deserialiseSnapshot(entry.payload);
6682
} catch (err) {
6783
logger.warn("buffered redirect: snapshot deserialise failed", {
6884
runFriendlyId: args.runFriendlyId,
@@ -71,22 +87,26 @@ export async function findBufferedRunRedirectInfo(
7187
return null;
7288
}
7389

74-
const environment = snapshot.environment as Record<string, unknown> | undefined;
75-
if (!environment || typeof environment !== "object") return null;
76-
const project = environment.project as Record<string, unknown> | undefined;
77-
const organization = environment.organization as Record<string, unknown> | undefined;
78-
79-
const envSlug = environment.slug;
80-
const projectSlug = project?.slug;
81-
const orgSlug = organization?.slug;
82-
if (typeof envSlug !== "string" || typeof projectSlug !== "string" || typeof orgSlug !== "string") {
90+
const parsed = BufferedSnapshotSchema.safeParse(raw);
91+
if (!parsed.success) {
92+
// Either the snapshot is from a different writer that doesn't carry
93+
// environment slugs (in which case we genuinely can't build a URL)
94+
// or a buffer-format drift snuck through. Log at debug; the caller
95+
// 404s and the user sees the standard not-found page, not a 500.
96+
logger.debug("buffered redirect: snapshot shape mismatch", {
97+
runFriendlyId: args.runFriendlyId,
98+
issues: parsed.error.issues.map((issue) => ({
99+
path: issue.path.join("."),
100+
code: issue.code,
101+
})),
102+
});
83103
return null;
84104
}
85105

86106
return {
87-
organizationSlug: orgSlug,
88-
projectSlug,
89-
environmentSlug: envSlug,
90-
spanId: typeof snapshot.spanId === "string" ? snapshot.spanId : undefined,
107+
organizationSlug: parsed.data.environment.organization.slug,
108+
projectSlug: parsed.data.environment.project.slug,
109+
environmentSlug: parsed.data.environment.slug,
110+
spanId: parsed.data.spanId,
91111
};
92112
}

apps/webapp/test/mollifierSyntheticRedirectInfo.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,39 @@ describe("findBufferedRunRedirectInfo (testcontainers)", () => {
159159
await buffer.close();
160160
}
161161
});
162+
163+
redisTest(
164+
"rejects snapshots where a slug is the wrong type (Zod guard, not just typeof)",
165+
async ({ redisOptions }) => {
166+
// Regression for the pre-Zod implementation: the slug check was
167+
// `typeof slug !== "string"` so any string passed, including ones
168+
// that should've been rejected on shape grounds. The Zod schema
169+
// gives us full structural validation — a `slug: 42` (number)
170+
// collapses into the parse-fail branch like any other shape
171+
// mismatch and we return null instead of leaking a half-built
172+
// redirect URL.
173+
const buffer = new MollifierBuffer({ redisOptions });
174+
try {
175+
await buffer.accept({
176+
runId: "run_real_7",
177+
envId: "env_a",
178+
orgId: "org_1",
179+
payload: JSON.stringify({
180+
environment: {
181+
slug: 42,
182+
project: { slug: "p" },
183+
organization: { slug: "o" },
184+
},
185+
}),
186+
});
187+
const info = await findBufferedRunRedirectInfo(
188+
{ runFriendlyId: "run_real_7", userId: "user_1" },
189+
{ getBuffer: () => buffer, prismaClient: fakePrisma({ id: "member_1" }) },
190+
);
191+
expect(info).toBeNull();
192+
} finally {
193+
await buffer.close();
194+
}
195+
},
196+
);
162197
});

0 commit comments

Comments
 (0)