Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
742bc513cb 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 <noreply@anthropic.com>
2026-02-12 04:53:43 +00:00
4 changed files with 144 additions and 187 deletions

View File

@@ -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";

View File

@@ -460,13 +460,13 @@ pub const Archiver = struct {
if (comptime Environment.isWindows) {
try bun.MakePath.makePath(u16, dir, path);
} else {
std.posix.mkdiratZ(dir_fd, path, @intCast(mode)) catch |err| {
std.posix.mkdiratZ(dir_fd, pathname, @intCast(mode)) catch |err| {
// It's possible for some tarballs to return a directory twice, with and
// without `./` in the beginning. So if it already exists, continue to the
// next entry.
if (err == error.PathAlreadyExists or err == error.NotDir) continue;
bun.makePath(dir, std.fs.path.dirname(path_slice) orelse return err) catch {};
std.posix.mkdiratZ(dir_fd, path, 0o777) catch {};
std.posix.mkdiratZ(dir_fd, pathname, 0o777) catch {};
};
}
},

View File

@@ -1,184 +0,0 @@
import { describe, expect, test } from "bun:test";
import { existsSync } from "fs";
import { tempDir } from "harness";
import { join } from "path";
// Helper to create tar files programmatically with exact control over entry paths
function createTarHeader(
name: string,
size: number,
type: "0" | "2" | "5", // 0=file, 2=symlink, 5=directory
linkname: string = "",
): Uint8Array {
const header = new Uint8Array(512);
const encoder = new TextEncoder();
// Name (100 bytes)
const nameBytes = encoder.encode(name);
header.set(nameBytes.slice(0, 100), 0);
// Mode (8 bytes) - octal
const modeStr = type === "5" ? "0000755" : "0000644";
header.set(encoder.encode(modeStr.padStart(7, "0") + " "), 100);
// UID (8 bytes)
header.set(encoder.encode("0000000 "), 108);
// GID (8 bytes)
header.set(encoder.encode("0000000 "), 116);
// Size (12 bytes) - octal
const sizeStr = size.toString(8).padStart(11, "0") + " ";
header.set(encoder.encode(sizeStr), 124);
// Mtime (12 bytes)
const mtime = Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0");
header.set(encoder.encode(mtime + " "), 136);
// Checksum placeholder (8 spaces)
header.set(encoder.encode(" "), 148);
// Type flag (1 byte)
header[156] = type.charCodeAt(0);
// Link name (100 bytes) - for symlinks
if (linkname) {
const linkBytes = encoder.encode(linkname);
header.set(linkBytes.slice(0, 100), 157);
}
// USTAR magic
header.set(encoder.encode("ustar"), 257);
header[262] = 0; // null terminator
header.set(encoder.encode("00"), 263);
// Calculate and set checksum
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksumStr = checksum.toString(8).padStart(6, "0") + "\0 ";
header.set(encoder.encode(checksumStr), 148);
return header;
}
function padToBlock(data: Uint8Array): Uint8Array[] {
const result = [data];
const remainder = data.length % 512;
if (remainder > 0) {
result.push(new Uint8Array(512 - remainder));
}
return result;
}
function createTarball(
entries: Array<{ name: string; type: "file" | "symlink" | "dir"; content?: string; linkname?: string }>,
): Uint8Array {
const blocks: Uint8Array[] = [];
const encoder = new TextEncoder();
for (const entry of entries) {
if (entry.type === "dir") {
blocks.push(createTarHeader(entry.name, 0, "5"));
} else if (entry.type === "symlink") {
blocks.push(createTarHeader(entry.name, 0, "2", entry.linkname || ""));
} else {
const content = encoder.encode(entry.content || "");
blocks.push(createTarHeader(entry.name, content.length, "0"));
blocks.push(...padToBlock(content));
}
}
// End of archive (two empty blocks)
blocks.push(new Uint8Array(512));
blocks.push(new Uint8Array(512));
// Combine all blocks
const totalLength = blocks.reduce((sum, b) => sum + b.length, 0);
const tarball = new Uint8Array(totalLength);
let offset = 0;
for (const block of blocks) {
tarball.set(block, offset);
offset += block.length;
}
return tarball;
}
// Skip on Windows - the bug is POSIX-only (Windows uses the correct normalized path)
const isWindows = process.platform === "win32";
describe.skipIf(isWindows)("directory path traversal prevention", () => {
test("should not create directories outside extraction root via ../ in directory entry", async () => {
// Create a temp dir structure:
// root/
// extract/ <-- extraction target
// canary/ <-- should NOT be created by extraction
using root = tempDir("dir-traversal-root", {});
const rootStr = String(root);
const extractDir = join(rootStr, "extract");
const canaryDir = join(rootStr, "canary");
// Create the extraction directory
const { mkdirSync } = require("fs");
mkdirSync(extractDir, { recursive: true });
// Craft a tarball with a directory entry that tries to escape via ../
// The entry "../canary" should be normalized to "" or "canary" inside the extract dir,
// NOT create a directory at the sibling level
const maliciousTarball = createTarball([
{ name: "safe-dir/", type: "dir" },
{ name: "safe-dir/file.txt", type: "file", content: "safe content" },
// Malicious directory entry that attempts to traverse out
{ name: "../canary/", type: "dir" },
]);
const archive = new Bun.Archive(maliciousTarball);
// Extract - this should NOT create ../canary relative to extractDir
try {
await archive.extract(extractDir);
} catch {
// It's acceptable if extraction throws for malicious paths
}
// The canary directory should NOT exist at the sibling level
expect(existsSync(canaryDir)).toBe(false);
// The safe file should have been extracted successfully
expect(existsSync(join(extractDir, "safe-dir/file.txt"))).toBe(true);
});
test("should not create deeply traversed directories outside extraction root", async () => {
using root = tempDir("dir-traversal-deep", {});
const rootStr = String(root);
const extractDir = join(rootStr, "a", "b", "c", "extract");
const traversedDir = join(rootStr, "a", "b", "pwned");
const { mkdirSync } = require("fs");
mkdirSync(extractDir, { recursive: true });
// Craft a tarball with deeper path traversal
const maliciousTarball = createTarball([
{ name: "legit/", type: "dir" },
{ name: "legit/file.txt", type: "file", content: "legit content" },
// Try to escape multiple levels
{ name: "../../pwned/", type: "dir" },
]);
const archive = new Bun.Archive(maliciousTarball);
try {
await archive.extract(extractDir);
} catch {
// Acceptable if it throws
}
// The traversed directory should NOT exist
expect(existsSync(traversedDir)).toBe(false);
});
});

View File

@@ -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();
});
});