From 742bc513cbd1d21ddbb287881393a12771fcea89 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 12 Feb 2026 04:38:56 +0000 Subject: [PATCH] fix(sql): validate array type parameter to prevent SQL injection The `sql.array(values, type)` function interpolated the user-provided type string directly into the SQL query without validation, allowing SQL injection via crafted type names like `INT); DROP TABLE users--`. Add character validation in `getArrayType()` to reject type names containing characters outside [a-zA-Z0-9_ .], which covers all valid PostgreSQL type names (including schema-qualified names like `myschema.INTEGER`) while blocking injection payloads. Uses `$ERR_INVALID_ARG_VALUE` for consistency with the rest of the codebase. Co-Authored-By: Claude --- src/js/internal/sql/postgres.ts | 36 +++++++- test/js/sql/sql-array-injection.test.ts | 107 ++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 test/js/sql/sql-array-injection.test.ts diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index af4502cd9a..e2508ecaa2 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -204,13 +204,47 @@ function arrayValueSerializer(type: ArrayType, is_numeric: boolean, is_json: boo return `"${arrayEscape(JSON.stringify(value))}"`; } } +function validateArrayTypeName(type: string): void { + if (type.length === 0) { + throw $ERR_INVALID_ARG_VALUE("type", type, "must not be empty"); + } + // Support schema-qualified names like "myschema.INTEGER" by splitting on dots + // and validating each segment individually. + const segments = type.split("."); + const lastIdx = segments.length - 1; + for (let s = 0; s <= lastIdx; s++) { + const seg = segments[s]; + if (seg.length === 0) { + throw $ERR_INVALID_ARG_VALUE("type", type, "must not contain empty segments"); + } + for (let i = 0; i < seg.length; i++) { + const c = seg.charCodeAt(i); + if ( + (c >= 65 && c <= 90) || // A-Z + (c >= 97 && c <= 122) || // a-z + (c >= 48 && c <= 57) || // 0-9 + c === 95 // _ + ) { + continue; + } + // Only the last segment may contain spaces (for "DOUBLE PRECISION") + if (c === 32 && s === lastIdx) { + continue; + } + throw $ERR_INVALID_ARG_VALUE("type", type, "contains invalid characters"); + } + } +} + function getArrayType(typeNameOrID: number | ArrayType | undefined = undefined): ArrayType { const typeOfType = typeof typeNameOrID; if (typeOfType === "number") { return getPostgresArrayType(typeNameOrID as number) ?? "JSON"; } if (typeOfType === "string") { - return (typeNameOrID as string)?.toUpperCase(); + const upper = (typeNameOrID as string).toUpperCase(); + validateArrayTypeName(upper); + return upper; } // default to JSON so we accept most of the types return "JSON"; diff --git a/test/js/sql/sql-array-injection.test.ts b/test/js/sql/sql-array-injection.test.ts new file mode 100644 index 0000000000..3799e951b8 --- /dev/null +++ b/test/js/sql/sql-array-injection.test.ts @@ -0,0 +1,107 @@ +import { sql } from "bun"; +import { describe, expect, test } from "bun:test"; + +// This test validates that sql.array() rejects malicious type parameters +// that could lead to SQL injection via the array type interpolation in +// normalizeQuery (src/js/internal/sql/postgres.ts line 1382). +// +// The vulnerability: sql.array(values, type) interpolates `type` directly +// into the query string as `$N::TYPE[]` without validation. + +describe("sql.array type parameter validation", () => { + test("sql.array rejects type with SQL injection payload (semicolon)", () => { + expect(() => { + sql.array([1, 2, 3], "INT); DROP TABLE users--" as any); + }).toThrow(); + }); + + test("sql.array rejects type with UNION injection", () => { + expect(() => { + sql.array([1, 2, 3], "INT[] UNION SELECT password FROM users--" as any); + }).toThrow(); + }); + + test("sql.array rejects type with subquery injection", () => { + expect(() => { + sql.array([1, 2, 3], "INT[] (SELECT 1)" as any); + }).toThrow(); + }); + + test("sql.array rejects type with parentheses", () => { + expect(() => { + sql.array([1, 2, 3], "INT()" as any); + }).toThrow(); + }); + + test("sql.array rejects type with single quotes", () => { + expect(() => { + sql.array([1, 2, 3], "INT' OR '1'='1" as any); + }).toThrow(); + }); + + test("sql.array rejects type with double quotes", () => { + expect(() => { + sql.array([1, 2, 3], 'INT" OR "1"="1' as any); + }).toThrow(); + }); + + test("sql.array rejects empty type", () => { + expect(() => { + sql.array([1, 2, 3], "" as any); + }).toThrow(); + }); + + test("sql.array rejects type with empty segment (leading dot)", () => { + expect(() => { + sql.array([1, 2, 3], ".INTEGER" as any); + }).toThrow(); + }); + + test("sql.array rejects type with empty segment (trailing dot)", () => { + expect(() => { + sql.array([1, 2, 3], "myschema." as any); + }).toThrow(); + }); + + test("sql.array rejects type with empty segment (consecutive dots)", () => { + expect(() => { + sql.array([1, 2, 3], "myschema..INTEGER" as any); + }).toThrow(); + }); + + test("sql.array rejects space in schema segment", () => { + expect(() => { + sql.array([1, 2, 3], "my schema.INTEGER" as any); + }).toThrow(); + }); + + test("sql.array accepts valid types", () => { + expect(() => sql.array([1, 2], "INTEGER")).not.toThrow(); + expect(() => sql.array([1, 2], "INT")).not.toThrow(); + expect(() => sql.array([1, 2], "BIGINT")).not.toThrow(); + expect(() => sql.array(["a", "b"], "TEXT")).not.toThrow(); + expect(() => sql.array(["a", "b"], "VARCHAR")).not.toThrow(); + expect(() => sql.array([true, false], "BOOLEAN")).not.toThrow(); + expect(() => sql.array([1.5, 2.5], "DOUBLE PRECISION")).not.toThrow(); + expect(() => sql.array([1, 2], "INT2VECTOR")).not.toThrow(); + expect(() => sql.array(["{}", "[]"], "JSON")).not.toThrow(); + expect(() => sql.array(["{}", "[]"], "JSONB")).not.toThrow(); + }); + + test("sql.array accepts lowercase valid types", () => { + expect(() => sql.array([1, 2], "integer")).not.toThrow(); + expect(() => sql.array([1, 2], "int")).not.toThrow(); + expect(() => sql.array(["a", "b"], "text")).not.toThrow(); + expect(() => sql.array([1.5, 2.5], "double precision")).not.toThrow(); + }); + + test("sql.array accepts schema-qualified type names", () => { + expect(() => sql.array([1, 2], "myschema.INTEGER" as any)).not.toThrow(); + expect(() => sql.array([1, 2], "pg_catalog.int4" as any)).not.toThrow(); + expect(() => sql.array([1, 2], "public.my_type" as any)).not.toThrow(); + }); + + test("sql.array accepts schema-qualified type with space in last segment", () => { + expect(() => sql.array([1, 2], "myschema.DOUBLE PRECISION" as any)).not.toThrow(); + }); +});