Skip to content

Commit dcc4b40

Browse files
d-csclaudecarderne
authored
fix(webapp): harden Electric sync routes against SQLi and add org scoping (#28)
* fix(webapp): harden Electric sync routes against SQL injection and cross-tenant leak sync.traces.\$traceId.ts and sync.traces.runs.\$traceId.ts interpolated params.traceId raw into the Electric `where` clause, and that clause had no organizationId predicate. An authenticated user can craft a traceparent header so the runtime SDK persists a malicious traceId into their own org's row, request /sync/traces/<crafted-id>, pass the membership check on that very row, and then watch Electric stream rows from any tenant whose traceId matches the crafted SQL. Closes the vector at four layers: - OtelTraceIdSchema validates the canonical 32-lowercase-hex format at the Zod layer; non-conforming input 404s with no error echo. - buildElectricTraceWhereClause emits `"traceId"='…' AND "organizationId"='…'` so cross-tenant collision doesn't matter even if input validation is ever bypassed. Both inputs are re-checked at the function boundary. - RESERVED_ELECTRIC_SHAPE_PARAMS strips `where`/`table`/`columns` from the incoming searchParams before forwarding to Electric so callers can't override the server-set values. - validateRealtimeTags + a matching Zod allowlist in realtime.v1.runs.ts reject tag values that could break out of the `"runTags" @> ARRAY['<tag>',…]` SQL string and steer the jumpHash-derived shard key. Closes TRI-9903, TRI-9904, TRI-9905, TRI-9906 (4 P0). Also closes TRI-9913 (Shape param allowlist) and TRI-9985 (realtime tag interpolation). 19 unit tests cover the regex, the where-clause builder, the reserved- param set, and the tag allowlist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(webapp): regression tests for Electric sync SQLi + scoping hardening Centralizes the hardening in a pure electricShape.server module: OtelTraceIdSchema (32-hex), buildElectricTraceWhereClause (org-scoped, validated), and realtime tag sanitization (reject unsafe chars, escape quotes), plus reserved-param stripping. Pure test (23 cases). Verified RED with the trace-id validation and tag sanitizer neutered (12 injection tests flip), GREEN with them. Bundles the streamBatchItems timeout bump. * fix(webapp): scope TaskRun trace sync by projectId, not nullable organizationId TaskRun.organizationId is nullable and the table is far too large to backfill, so AND-scoping the trace-runs Electric shape by organizationId silently dropped legacy NULL-org runs from the trace view. Scope by the non-null projectId instead: buildElectricTraceWhereClause now takes a typed tenant column (org for the TaskEvent shape, project for TaskRun). Tenant-safe — membership is verified against the project's org and a trace's runs share one project, and the column is a fixed union, never user input. * docs: reword server-changes note to comply with release-note conventions * chore(webapp): remove electricShape test and shorten comments * fix(webapp): preserve project scoping after primary trace lookup * widen test --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Arderne <chris@trigger.dev>
1 parent 0c2d9ce commit dcc4b40

7 files changed

Lines changed: 154 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Live run and trace subscriptions now validate their identifiers more strictly and only return data from your own organization.

apps/webapp/app/routes/realtime.v1.runs.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,28 @@ import { z } from "zod";
22
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
33
import { resolveRealtimeStreamClient } from "~/services/realtime/resolveRealtimeStreamClient.server";
44
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
5+
import { UNSAFE_REALTIME_TAG_CHARS } from "~/v3/electricShape.server";
56

67
const SearchParamsSchema = z.object({
78
tags: z
89
.string()
910
.optional()
1011
.transform((value) => {
1112
return value ? value.split(",") : undefined;
13+
})
14+
.superRefine((tags, ctx) => {
15+
if (!tags) return;
16+
for (const tag of tags) {
17+
// Mirror the runtime sanitiser's reject list so the API returns 400
18+
// instead of a 500. Single quotes are allowed — escaped downstream.
19+
if (UNSAFE_REALTIME_TAG_CHARS.test(tag) || tag.length === 0) {
20+
ctx.addIssue({
21+
code: z.ZodIssueCode.custom,
22+
message: `Invalid tag: ${JSON.stringify(tag)}`,
23+
});
24+
return;
25+
}
26+
}
1227
}),
1328
createdAt: z.string().optional(),
1429
});

apps/webapp/app/routes/sync.traces.$traceId.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
11
import type { LoaderFunctionArgs } from "@remix-run/node";
2+
import { z } from "zod";
23
import { $replica } from "~/db.server";
34
import { env } from "~/env.server";
45
import { logger } from "~/services/logger.server";
56
import { getUserId } from "~/services/session.server";
67
import { longPollingFetch } from "~/utils/longPollingFetch";
8+
import {
9+
OtelTraceIdSchema,
10+
RESERVED_ELECTRIC_SHAPE_PARAMS,
11+
buildElectricTraceWhereClause,
12+
} from "~/v3/electricShape.server";
13+
14+
const Params = z.object({
15+
traceId: OtelTraceIdSchema,
16+
});
717

818
export async function loader({ params, request }: LoaderFunctionArgs) {
919
try {
1020
const userId = await getUserId(request);
1121

12-
logger.log(`/sync/traces/${params.traceId}`, { userId });
22+
const parsedParams = Params.safeParse(params);
23+
if (!parsedParams.success) {
24+
// Treat a malformed traceId as not-found rather than 400 to avoid
25+
// signalling the validator.
26+
return new Response("Not found", { status: 404 });
27+
}
28+
const { traceId } = parsedParams.data;
29+
30+
logger.log(`/sync/traces/${traceId}`, { userId });
1331

1432
if (!userId) {
1533
return new Response("No user found in cookie", { status: 401 });
@@ -20,7 +38,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
2038
organizationId: true,
2139
},
2240
where: {
23-
traceId: params.traceId,
41+
traceId,
2442
},
2543
});
2644

@@ -41,11 +59,19 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
4159

4260
const url = new URL(request.url);
4361
const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskEvent"`);
62+
// Strip params we set ourselves so the caller can't override them.
4463
url.searchParams.forEach((value, key) => {
64+
if (RESERVED_ELECTRIC_SHAPE_PARAMS.has(key)) return;
4565
originUrl.searchParams.set(key, value);
4666
});
4767

48-
originUrl.searchParams.set("where", `"traceId"='${params.traceId}'`);
68+
originUrl.searchParams.set(
69+
"where",
70+
buildElectricTraceWhereClause({
71+
traceId,
72+
scope: { column: "organizationId", id: trace.organizationId },
73+
})
74+
);
4975

5076
const finalUrl = originUrl.toString();
5177

apps/webapp/app/routes/sync.traces.runs.$traceId.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,27 @@ import { env } from "~/env.server";
55
import { logger } from "~/services/logger.server";
66
import { getUserId } from "~/services/session.server";
77
import { longPollingFetch } from "~/utils/longPollingFetch";
8-
import { runStore } from "~/v3/runStore.server";
8+
import {
9+
OtelTraceIdSchema,
10+
RESERVED_ELECTRIC_SHAPE_PARAMS,
11+
buildElectricTraceWhereClause,
12+
} from "~/v3/electricShape.server";
913
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
14+
import { runStore } from "~/v3/runStore.server";
1015

1116
const Params = z.object({
12-
traceId: z.string(),
17+
traceId: OtelTraceIdSchema,
1318
});
1419

1520
export async function loader({ params, request }: LoaderFunctionArgs) {
1621
try {
1722
const userId = await getUserId(request);
18-
const { traceId } = Params.parse(params);
23+
24+
const parsedParams = Params.safeParse(params);
25+
if (!parsedParams.success) {
26+
return new Response("Not found", { status: 404 });
27+
}
28+
const { traceId } = parsedParams.data;
1929

2030
logger.log(`/sync/runs/${traceId}`, { userId });
2131

@@ -29,6 +39,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
2939
},
3040
{
3141
select: {
42+
projectId: true,
3243
runtimeEnvironmentId: true,
3344
},
3445
},
@@ -40,7 +51,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
4051
// primary before 404ing so a live run's realtime trace feed isn't spuriously not-found.
4152
run = await runStore.findRunOnPrimary(
4253
{ traceId },
43-
{ select: { runtimeEnvironmentId: true } }
54+
{ select: { projectId: true, runtimeEnvironmentId: true } }
4455
);
4556
}
4657

@@ -67,11 +78,22 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
6778

6879
const url = new URL(request.url);
6980
const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskRun"`);
81+
// Strip params we set ourselves so the caller can't override them.
7082
url.searchParams.forEach((value, key) => {
83+
if (RESERVED_ELECTRIC_SHAPE_PARAMS.has(key)) return;
7184
originUrl.searchParams.set(key, value);
7285
});
7386

74-
originUrl.searchParams.set("where", `"traceId"='${traceId}'`);
87+
originUrl.searchParams.set(
88+
"where",
89+
// Scope by non-null projectId, not the nullable organizationId (legacy
90+
// rows would vanish). Tenant-safe: membership was verified against this
91+
// project's org and a trace's runs all live in one project.
92+
buildElectricTraceWhereClause({
93+
traceId,
94+
scope: { column: "projectId", id: run.projectId },
95+
})
96+
);
7597

7698
const finalUrl = originUrl.toString();
7799

apps/webapp/app/services/realtimeClient.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { RedisCacheStore } from "./unkey/redisCacheStore.server";
1515
import { env } from "~/env.server";
1616
import type { API_VERSIONS } from "~/api/versions";
1717
import { CURRENT_API_VERSION } from "~/api/versions";
18+
import { sanitizeRealtimeTagsForSql } from "~/v3/electricShape.server";
1819

1920
export interface CachedLimitProvider {
2021
getCachedLimit: (organizationId: string, defaultValue: number) => Promise<number | undefined>;
@@ -171,7 +172,10 @@ export class RealtimeClient {
171172
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
172173

173174
if (params.tags) {
174-
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
175+
// Reject unsafe chars and escape single quotes so tag values can't
176+
// break out of the Electric SQL string literal.
177+
const safeTags = sanitizeRealtimeTagsForSql(params.tags);
178+
whereClauses.push(`"runTags" @> ARRAY[${safeTags.map((t) => `'${t}'`).join(",")}]`);
175179
}
176180

177181
const createdAtFilter = await this.#calculateCreatedAtFilter(url, params.createdAt);
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* OTel trace IDs are 32 lowercase hex chars. The traceparent parser only
5+
* checks the dash-delimited format, so crafted ids can be persisted and later
6+
* interpolated into shape `where` clauses. Validate here to close the SQLi vector.
7+
*/
8+
export const OtelTraceIdSchema = z
9+
.string()
10+
.regex(/^[0-9a-f]{32}$/, "traceId must be 32 lowercase hex characters");
11+
12+
/** Params the sync routes set themselves; stripped from incoming requests. */
13+
export const RESERVED_ELECTRIC_SHAPE_PARAMS = new Set(["where", "table", "columns"]);
14+
15+
const CUID_LIKE = /^[a-z][a-z0-9_]*$/i;
16+
17+
/**
18+
* Tenant column a trace shape is scoped by. TaskEvent scopes by non-null
19+
* organizationId; TaskRun scopes by non-null projectId (its organizationId is
20+
* nullable). The column is from this fixed union, never user input, so it's
21+
* safe to interpolate.
22+
*/
23+
export type TraceScope =
24+
| { column: "organizationId"; id: string }
25+
| { column: "projectId"; id: string };
26+
27+
/**
28+
* Build the Electric Shape `where` clause for the trace sync routes. Both ids
29+
* are re-validated as defense-in-depth so a missed call site can't bypass scope.
30+
*/
31+
export function buildElectricTraceWhereClause(args: {
32+
traceId: string;
33+
scope: TraceScope;
34+
}): string {
35+
const { traceId, scope } = args;
36+
if (!OtelTraceIdSchema.safeParse(traceId).success) {
37+
throw new Error("buildElectricTraceWhereClause: unsafe traceId");
38+
}
39+
if (!CUID_LIKE.test(scope.id)) {
40+
throw new Error("buildElectricTraceWhereClause: unsafe scope id");
41+
}
42+
return `"traceId"='${traceId}' AND "${scope.column}"='${scope.id}'`;
43+
}
44+
45+
/**
46+
* Characters rejected in realtime tag values — the single source of truth
47+
* shared by the apiBuilder Zod refine (`realtime.v1.runs.ts`) and the runtime
48+
* sanitiser. Rejects control chars/DEL, backslash, and double-quote. Single
49+
* quotes are allowed and escaped (`'` → `''`) in `sanitizeRealtimeTagForSql`.
50+
*/
51+
export const UNSAFE_REALTIME_TAG_CHARS = /[\x00-\x1f\x7f\\"]/;
52+
53+
/**
54+
* Sanitise a tag value for interpolation into an Electric Shape `where` clause:
55+
* reject unsafe chars, escape single quotes per SQL standard.
56+
*/
57+
export function sanitizeRealtimeTagForSql(tag: string): string {
58+
if (typeof tag !== "string" || tag.length === 0) {
59+
throw new Error("Invalid realtime tag: empty");
60+
}
61+
if (UNSAFE_REALTIME_TAG_CHARS.test(tag)) {
62+
throw new Error(`Invalid realtime tag: ${JSON.stringify(tag)} — contains unsafe character`);
63+
}
64+
return tag.replace(/'/g, "''");
65+
}
66+
67+
export function sanitizeRealtimeTagsForSql(tags: string[]): string[] {
68+
return tags.map(sanitizeRealtimeTagForSql);
69+
}

apps/webapp/test/spanTraceRoutes.replicaLag.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ describe("run-trace/span-detail route loaders under a lagging replica", () => {
475475
const seed = await seedTenant(prisma14, suffix);
476476
const runId = `run_${CUID_25}`;
477477
const friendlyId = `run_${suffix}`;
478-
const traceId = `trace_${suffix}`;
478+
const traceId = "a".repeat(32);
479479
const userId = `user_${suffix}`;
480480

481481
// The dashboard user, joined to the org so the route's real orgMember check passes.
@@ -538,7 +538,8 @@ describe("run-trace/span-detail route loaders under a lagging replica", () => {
538538
holder.resolvedEnv = { organizationId: seed.organization.id };
539539
holder.replicaMarker = { orgMember: prisma14.orgMember };
540540

541-
const res = (await syncTraceRunsLoader(syncRequest("trace_does_not_exist"))) as Response;
541+
const res = (await syncTraceRunsLoader(syncRequest("b".repeat(32)))) as Response;
542+
expect(lagged.legacyReplica.wasHit("taskRun")).toBe(true);
542543
expect(res.status).toBe(404);
543544
}
544545
);

0 commit comments

Comments
 (0)