Files
bun.sh/test/regression/issue/postgres-null-byte-injection.test.ts
robobun 44541eb574 fix(sql): reject null bytes in connection parameters to prevent protocol injection (#26952)
## Summary

- Reject null bytes in `username`, `password`, `database`, and `path`
connection parameters for both PostgreSQL and MySQL to prevent wire
protocol parameter injection
- Both the Postgres and MySQL wire protocols use null-terminated strings
in their startup/handshake messages, so embedded null bytes in these
fields act as field terminators, allowing injection of arbitrary
protocol parameters (e.g. `search_path` for schema hijacking)
- The fix validates these fields immediately after UTF-8 conversion and
throws `InvalidArguments` error with a clear message if null bytes are
found

## Test plan

- [x] New test
`test/regression/issue/postgres-null-byte-injection.test.ts` verifies:
- Null bytes in username are rejected with an error before any data is
sent
- Null bytes in database are rejected with an error before any data is
sent
- Null bytes in password are rejected with an error before any data is
sent
  - Normal connections without null bytes still work correctly
- [x] Test verified to fail with `USE_SYSTEM_BUN=1` (unfixed bun) and
pass with `bun bd test` (fixed build)
- [x] Existing SQL tests pass (`adapter-env-var-precedence.test.ts`,
`postgres-stringbuilder-assertion-aggressive.test.ts`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 00:27:00 -08:00

188 lines
4.9 KiB
TypeScript

import { SQL } from "bun";
import { expect, test } from "bun:test";
import net from "net";
test("postgres connection rejects null bytes in username", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice\x00search_path\x00evil_schema,public",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
// The server should never have received any data because the null byte
// should be rejected before the connection is established.
expect(serverReceivedData).toBe(false);
});
test("postgres connection rejects null bytes in database", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb\x00search_path\x00evil_schema,public",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
expect(serverReceivedData).toBe(false);
});
test("postgres connection rejects null bytes in password", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
password: "pass\x00search_path\x00evil_schema",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
expect(serverReceivedData).toBe(false);
});
test("postgres connection does not use truncated path with null bytes", async () => {
// The JS layer's fs.existsSync() rejects paths containing null bytes,
// so the path is dropped before reaching the native layer. Verify that a
// path with null bytes doesn't silently connect via a truncated path.
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb",
path: "/tmp\x00injected",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
} catch {
// Expected to fail
} finally {
server.close();
}
// The path had null bytes so it should have been dropped by the JS layer,
// falling back to TCP where it hits our mock server (not a truncated Unix socket).
expect(serverReceivedData).toBe(true);
});
test("postgres connection works with normal parameters (no null bytes)", async () => {
// Verify that normal connections without null bytes still work.
// Use a mock server that sends an auth error so we can verify the
// startup message is sent correctly.
let receivedData = false;
const server = net.createServer(socket => {
socket.once("data", () => {
receivedData = true;
const errMsg = Buffer.from("SFATAL\0VFATAL\0C28000\0Mauthentication failed\0\0");
const len = errMsg.length + 4;
const header = Buffer.alloc(5);
header.write("E", 0);
header.writeInt32BE(len, 1);
socket.write(Buffer.concat([header, errMsg]));
socket.destroy();
});
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
} catch {
// Expected - mock server sends auth error
} finally {
server.close();
}
// Normal parameters should connect fine - the server should receive data
expect(receivedData).toBe(true);
});