Skip to content

Commit 48fd80b

Browse files
committed
fix(webapp): escape realtime tags filter to prevent SQL injection (#3739)
1 parent 0f349dd commit 48fd80b

3 files changed

Lines changed: 39 additions & 1 deletion

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+
Escape single quotes in user-supplied realtime `tags` filter to prevent SQL injection into the Electric `where` clause

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ const DEFAULT_ELECTRIC_COLUMNS = [
5252
const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"];
5353
const RESERVED_SEARCH_PARAMS = ["createdAt", "tags", "skipColumns"];
5454

55+
// Doubles single quotes so a value is safe inside a single-quoted SQL string literal.
56+
export function escapeSqlStringLiteral(value: string): string {
57+
return value.replace(/'/g, "''");
58+
}
59+
5560
export type RealtimeClientOptions = {
5661
electricOrigin: string | string[];
5762
redis: RedisWithClusterOptions;
@@ -171,7 +176,10 @@ export class RealtimeClient {
171176
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
172177

173178
if (params.tags) {
174-
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
179+
// Escape user-supplied tags so they can't break out of the SQL string literal.
180+
whereClauses.push(
181+
`"runTags" @> ARRAY[${params.tags.map((t) => `'${escapeSqlStringLiteral(t)}'`).join(",")}]`
182+
);
175183
}
176184

177185
const createdAtFilter = await this.#calculateCreatedAtFilter(url, params.createdAt);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, it, expect } from "vitest";
2+
import { escapeSqlStringLiteral } from "../app/services/realtimeClient.server";
3+
4+
describe("escapeSqlStringLiteral", () => {
5+
it("leaves ordinary tag values unchanged", () => {
6+
expect(escapeSqlStringLiteral("important")).toBe("important");
7+
expect(escapeSqlStringLiteral("user_42")).toBe("user_42");
8+
expect(escapeSqlStringLiteral("")).toBe("");
9+
});
10+
11+
it("doubles a single quote", () => {
12+
expect(escapeSqlStringLiteral("O'Brien")).toBe("O''Brien");
13+
});
14+
15+
it("neutralizes the tags injection payload from #3739", () => {
16+
const literal = `'${escapeSqlStringLiteral("test' OR '1'='1")}'`;
17+
expect(literal).toBe("'test'' OR ''1''=''1'");
18+
expect(literal.replace(/''/g, "")).toBe("'test OR 1=1'");
19+
});
20+
21+
it("escapes every quote, not just the first", () => {
22+
expect(escapeSqlStringLiteral("a'b'c'")).toBe("a''b''c''");
23+
});
24+
});

0 commit comments

Comments
 (0)