
SQL injection becomes command execution when the database user can write a file, run a stacked statement, or talk to a dangerous routine.
A framework that enables multipleStatements turns one concatenated query into several. That is a configuration choice. Parameterization plus a least-privilege DB user closes both steps.
The usual mistake is fixing the query and leaving FILE or stacked queries on, or the reverse.
This page is the path from a string-built query to a shell, and the two controls that cut it.
CVE-2026-73300 is CVSS 9.6. Budibase’s MySQL integration shipped multipleStatements: true in @budibase/server before 3.40.0. GitHub published GHSA-q6x4-v3qx-85qw on 22 July 2026. The patch is one line: set the flag to false. A bind miss with that flag on is a second statement. A bind miss with FILE or xp_cmdshell on the role reaches the OS.
The control is a least-privilege role, a driver that refuses a second statement, and parameterized SQL. Keep the injection hub next to this page. MySQL-specific bugs sit on the MySQL fix list. Incoming fields still need an allowlist. That is input validation.
app_web can SELECT, INSERT, and UPDATE on its schema. FILE and xp_cmdshell stay locked, so a later string in SQL cannot reach the host.
SecureCoding
The miss is a privileged role plus a string
CWE-89 is untrusted data reaching the SQL interpreter as grammar. That is the first miss. The second miss is what the database user is allowed to do after the grammar changes. A role that can only read and write its own tables keeps a bind failure on the data plane. Stacked statements, a file privilege, or a shell procedure take that failure off the schema.
| Hatch | Close it |
|---|---|
| Stacked statements | multipleStatements: false. One execute, one statement. |
| MySQL FILE | No FILE on app_web. secure_file_priv=NULL. |
| SQL Server shell | xp_cmdshell remains 0. No GRANT on it. |
| Postgres program | No pg_execute_server_program. No COPY PROGRAM. |
This page will not walk a write-to-webroot story. The MySQL 8.4 privilege manual is enough: FILE lets a user read any world-readable file the server can read, and write files as the server OS user, subject to secure_file_priv. Microsoft’s xp_cmdshell page, updated 23 June 2025, says the procedure is disabled by default because people use it to raise privilege. Leave it that way.
Stacked queries: leave multipleStatements off
mysql2 accepts a second statement only when you opt in. The default is false. Budibase opted in. GHSA-q6x4-v3qx-85qw says the MySQL integration hardcoded multipleStatements: true at packages/server/src/integrations/mysql.ts around line 173. The published fix in 3.40.0 flips it to false. that advisory. This section does not repeat the advisory’s proof-of-concept.
const mysql = require("mysql2/promise");
const pool = mysql.createPool({
user: process.env.MYSQL_APP_USER, // app_web
database: process.env.MYSQL_DB,
multipleStatements: false,
});
async function ticketById(id) {
const [rows] = await pool.execute(
"SELECT id, status FROM tickets WHERE id = ?",
[id]
);
return rows[0] || null;
}
ticketById and app_web stay the identifiers for the rest of this page. execute sends SQL text and binds apart. query with a glued string is the hatch. If you think you need stacked statements for a migration, run the migration as a different user, from a tool that is not the web pool.
PostgreSQL’s simple query protocol can also run more than one statement in a string. node-postgres and most ORMs send extended query for a parameterized call, which is one statement. The footgun is a helper that concatenates and uses the simple protocol. Prefer $1 binds. Do not turn on a multi-statement mode to “save a round trip.”
FILE is a host hatch. app_web does not get it
MySQL 8.4 privileges page. FILE is global. GRANT ALL ON appdb.* does not include it. The manual tells you to be careful with FILE and the administrative privileges. FILE can load any file the server can read into a table, then SELECT it out. It can write a file as the mysqld OS user, inside the directory secure_file_priv allows.
CREATE USER 'app_web'@'10.0.0.%' IDENTIFIED BY RANDOM PASSWORD;
GRANT SELECT, INSERT, UPDATE ON appdb.* TO 'app_web'@'10.0.0.%';
-- no FILE, no SUPER, no PROCESS, no GRANT OPTION
SELECT user, host, File_priv, Super_priv
FROM mysql.user
WHERE user = 'app_web';
-- expect File_priv N, Super_priv N
Disable SQL file import and export on the server if the app never needs them. CIS for MySQL 8.4 still recommends a null secure_file_priv when LOAD DATA and SELECT-into-file are unused. A directory that is world-writable, or the data directory itself, is the insecure case the server warns about at startup. Admins who need a dump use a locked path and a different account. app_web never does.
Cloud managed MySQL often withholds FILE and SUPER from the customer login. That is a gift. Do not punch a hole to “make OUTFILE work in staging” and then copy the grants to prod. Staging should use the same app_web grant file.
xp_cmdshell stays disabled
Microsoft’s Transact-SQL page for xp_cmdshell, dated 23 June 2025, says the procedure spawns a Windows command shell as the SQL Server service account, and that it is disabled by default. Enablement is a surface-area change through sp_configure or Policy-Based Management. This page will not show the enablement script.
SELECT name, value_in_use
FROM sys.configurations
WHERE name = 'xp_cmdshell';
-- expect value_in_use 0
SELECT princ.name
FROM sys.database_permissions perm
JOIN sys.database_principals princ ON perm.grantee_principal_id = princ.principal_id
WHERE perm.permission_name = 'EXECUTE'
AND OBJECT_NAME(perm.major_id) = 'xp_cmdshell';
-- expect no app_web row
If a vendor installer turned it on, turn it off in the same change window you rotate the app password. A proxy account for non-sysadmin callers is still a shell on the box. The app does not need a shell. Jobs that must touch the OS belong in the orchestrator, not in T-SQL.
Postgres has its own program hatch: COPY... PROGRAM and the pg_execute_server_program role. Do not grant it to app_web. SQLite has load_extension, off by default in the library. Leave it off in the process that serves HTTP.
Bind every value. Allowlist every identifier
jiggawatts’s point is the bind, not a quote function. The client sends placeholders first, then encodes each value on a second channel. Escaping a string and splicing it back into the text is templating. Templating is how a later delimiter wins.
Identifiers cannot be bound. A sort key, a table token, a column the UI named: map the token to a literal you wrote. OpenEMR’s CVE-2026-24908, published with the AISLE set on 28 April 2026, was _sort concatenated into ORDER BY. That is an identifier miss. The injection hub covers it. The map below is the control on this page.
const SORT_OK = new Map([
["created", "created_at"],
["status", "status"],
]);
async function listTickets(sortToken) {
const col = SORT_OK.get(sortToken);
if (!col) return [];
const [rows] = await pool.execute(
`SELECT id, status FROM tickets ORDER BY ${col} ASC LIMIT 50`
);
return rows;
}
SORT_OK is the named fallback when a bind cannot hold the identifier. The template uses col only after the map hit. A missing token returns an empty list. Do not pass sortToken into the string. ticketById stays fully bound. listTickets is the one place a literal column name appears, and it came from your map.
Stored procedures are not a substitute. A procedure that concatenates inside EXECUTE is the same string builder. A procedure that takes parameters and uses them as parameters is fine. Grep EXECUTE plus concatenation in SQL the way you grep $queryRawUnsafe in an ORM.
Prove app_web cannot leave the data plane
You are not probing a database you do not operate. You are proving app_web has no FILE, the pool refuses a second statement, and ticketById uses execute.
rg -n "multipleStatements\\s*:\\s*true" --glob '!node_modules'
rg -n "pool\\.(query|execute)|\\$queryRawUnsafe|whereRaw" --glob '!node_modules'
rg -n "INTO OUTFILE|LOAD_FILE|xp_cmdshell|COPY.+ PROGRAM" --glob '!vendor'
test("ticketById uses a bound placeholder", async () => {
const id = 42;
await ticketById(id);
// assert the test double received SQL containing "?" and params [42]
});
test("listTickets rejects an unknown sort token", async () => {
await expect(listTickets("created;")).resolves.toEqual([]);
});
Run the privilege query on a database you own. Save the result in CI as a grant dump. A new FILE Y on app_web fails the job. A pool constructed with stacked statements on fails the job. That is the review, not a lesson in how a file write would look.
4. Treat the privilege manual as current: do not grant FILE to a non-admin user. If your hoster already withholds it, keep it that way.
Questions we keep getting
Is a least-privilege role enough if we still concatenate?
No. The role keeps a bind miss from becoming a host problem. The bind keeps the miss from becoming a data problem. You want both. Budibase’s flag was the stack hatch. FILE and xp_cmdshell are the OS hatches. ticketById is the bind.
Does a schema-wide ALL grant include FILE?
No. FILE is global. A schema-wide ALL grant is still too wide if the app only needs three verbs, but it does not hand out FILE. Check File_priv anyway. People grant star-dot-star when they mean the schema.
Can we enable xp_cmdshell for one nightly job?
No. Put the job in the orchestrator. The 23 June 2025 Microsoft page is a warning, not a how-to. A nightly job that needs a shell is an OS job. It does not belong on the SQL surface the web role can see.



