From 48fd80b2e2631126817e25ec3612c92479d07f10 Mon Sep 17 00:00:00 2001 From: purvi09 Date: Fri, 3 Jul 2026 12:36:42 +0530 Subject: [PATCH] fix(webapp): escape realtime tags filter to prevent SQL injection (#3739) --- .../fix-realtime-tags-sql-injection.md | 6 +++++ .../app/services/realtimeClient.server.ts | 10 +++++++- ...altimeClientEscapeSqlStringLiteral.test.ts | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .server-changes/fix-realtime-tags-sql-injection.md create mode 100644 apps/webapp/test/realtimeClientEscapeSqlStringLiteral.test.ts diff --git a/.server-changes/fix-realtime-tags-sql-injection.md b/.server-changes/fix-realtime-tags-sql-injection.md new file mode 100644 index 0000000000..913e2c2e00 --- /dev/null +++ b/.server-changes/fix-realtime-tags-sql-injection.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Escape single quotes in user-supplied realtime `tags` filter to prevent SQL injection into the Electric `where` clause diff --git a/apps/webapp/app/services/realtimeClient.server.ts b/apps/webapp/app/services/realtimeClient.server.ts index 9797eeebf4..0c504511b7 100644 --- a/apps/webapp/app/services/realtimeClient.server.ts +++ b/apps/webapp/app/services/realtimeClient.server.ts @@ -52,6 +52,11 @@ const DEFAULT_ELECTRIC_COLUMNS = [ const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"]; const RESERVED_SEARCH_PARAMS = ["createdAt", "tags", "skipColumns"]; +// Doubles single quotes so a value is safe inside a single-quoted SQL string literal. +export function escapeSqlStringLiteral(value: string): string { + return value.replace(/'/g, "''"); +} + export type RealtimeClientOptions = { electricOrigin: string | string[]; redis: RedisWithClusterOptions; @@ -171,7 +176,10 @@ export class RealtimeClient { const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`]; if (params.tags) { - whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`); + // Escape user-supplied tags so they can't break out of the SQL string literal. + whereClauses.push( + `"runTags" @> ARRAY[${params.tags.map((t) => `'${escapeSqlStringLiteral(t)}'`).join(",")}]` + ); } const createdAtFilter = await this.#calculateCreatedAtFilter(url, params.createdAt); diff --git a/apps/webapp/test/realtimeClientEscapeSqlStringLiteral.test.ts b/apps/webapp/test/realtimeClientEscapeSqlStringLiteral.test.ts new file mode 100644 index 0000000000..5ef92cfb75 --- /dev/null +++ b/apps/webapp/test/realtimeClientEscapeSqlStringLiteral.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { escapeSqlStringLiteral } from "../app/services/realtimeClient.server"; + +describe("escapeSqlStringLiteral", () => { + it("leaves ordinary tag values unchanged", () => { + expect(escapeSqlStringLiteral("important")).toBe("important"); + expect(escapeSqlStringLiteral("user_42")).toBe("user_42"); + expect(escapeSqlStringLiteral("")).toBe(""); + }); + + it("doubles a single quote", () => { + expect(escapeSqlStringLiteral("O'Brien")).toBe("O''Brien"); + }); + + it("neutralizes the tags injection payload from #3739", () => { + const literal = `'${escapeSqlStringLiteral("test' OR '1'='1")}'`; + expect(literal).toBe("'test'' OR ''1''=''1'"); + expect(literal.replace(/''/g, "")).toBe("'test OR 1=1'"); + }); + + it("escapes every quote, not just the first", () => { + expect(escapeSqlStringLiteral("a'b'c'")).toBe("a''b''c''"); + }); +});