The standard advice about SQL injection, “use prepared statements and you are done,” has a hole in it that nobody talks about at conferences, and the hole is DDL: you cannot bind an identifier, a role name, or a permission clause as a query parameter, because placeholders only exist for values, so the moment your application builds a GRANT statement from user-adjacent input you are concatenating SQL text the old-fashioned way, with all the risk that implies. An admin panel that lets an operator pick a role and a permission from a form is exactly this shape, and if that form field ever carries something like select:orders; DROP USER admin; --, the client library is the last line of defense before the string becomes a statement on the wire.
That is why the mongreldb-php client treats the permission string as a grammar to be validated, not a value to be escaped, and the check runs before any HTTP request leaves the process.
The allowlist, not the escape hatch
Escaping is the wrong tool for a permission clause, because the set of legal permissions is small and fully enumerable, and when the legal set is small you whitelist rather than sanitize. Database::grantPermission() and Database::revokePermission() both call a private validatePermission() first, and the accepted shapes are the entire story:
private function validatePermission(string $permission): void
{
// Allowed standalone permissions
$standalone = ['all', 'ddl', 'admin'];
if (in_array(strtolower($permission), $standalone, true)) {
return;
}
// Check table-level permission format: verb:table_name
if (preg_match('/^(select|insert|update|delete):(\\w+)$/i', $permission)) {
return;
}
// Reject anything with injection characters
throw new \InvalidArgumentException(
"Invalid permission '{$permission}'. Expected: all, ddl, admin, " .
'or select:<table>, insert:<table>, update:<table>, delete:<table>'
);
}
Three standalone keywords, four verbs followed by a colon and a table name made of word characters, and that is everything; a semicolon, a quote, a space, a SQL comment marker, none of them can survive either branch, because \w+ does not match them and the standalone list is compared case-insensitively but exactly. The failure mode is deliberately boring: an InvalidArgumentException thrown in your process, with the offending string in the message, before any bytes are spent on the network.
Only after validation does the client translate the friendly select:orders form into the server’s SQL dialect, and the translation is another fixed-shape operation rather than a passthrough:
private function permissionToSqlFragment(string $permission): string
{
if (preg_match('/^(select|insert|update|delete):(\\w+)$/i', $permission, $m)) {
return strtoupper($m[1]) . ' ON ' . $m[2];
}
return $permission;
}
// GRANT SELECT ON orders TO "analyst"
The verb gets uppercased, the table name comes out of a capture group that was already constrained to \w+, and the standalone keywords pass through untouched, so the fragment that lands in the GRANT ... TO statement is assembled entirely from pieces the allowlist already vetted.
Worth knowing where the allowlist’s edge sits, because it is narrower than SQL itself: \w+ happily accepts a table name with a leading digit like select:2026q1, which sails through client validation and then fails server-side where the identifier rules live, and the same pattern turns away legitimately hyphenated or schema-qualified names before they ever leave your process, so the validator is a safety gate, not a schema authority, and when it rejects something legal the answer is to widen the pattern deliberately, never to route around it with string concatenation of your own.
Identifiers still get quoted, because they are a different problem
Role names and usernames are not a closed grammar the way permissions are, since you may genuinely want a role called read-only-2026, so for those the client falls back to correct quoting instead of rejection: every identifier goes through quoteIdent(), which wraps it in double quotes and doubles any embedded double quote, and passwords go through escapeString(), which doubles single quotes inside a single-quoted literal. The adversarial test suite pins this behavior with real attack strings, and reading the assertions tells you exactly what the wire looks like when somebody tries:
$malicious = "alice'; DROP USER admin; --";
$db->createUser($malicious, 'pw');
// The whole string lands inside ONE double-quoted identifier:
// CREATE USER "alice'; DROP USER admin; --" WITH PASSWORD 'pw'
// The semicolon is identifier content, not a statement separator.
The same suite feeds role"; DROP TABLE orders; -- into createRole() and asserts the doubled-quote form "role""; DROP TABLE orders; --" comes out the other end, and it feeds the grant path its own attack, which is the one this article exists because of:
try {
$db->grantPermission('role', "select:orders; DROP USER admin; --");
$this->fail('Expected InvalidArgumentException for injected permission');
} catch (\InvalidArgumentException $e) {
$this->assertStringContainsString('select:orders; DROP USER admin', $e->getMessage());
}
// The decisive assertion: no request ever reached the transport.
$this->assertSame(0, $transport->requestCount);
That last line is the whole argument for doing this in the client, because a rejection that happens after the request is sent is not a rejection at all, it is a log entry.
What belongs in the client, and what does not
The honest boundary is this: the client owns validation for every helper that builds SQL text from structured arguments, because those helpers know the grammar they are building against and can reject before the wire, while the escape hatch, Database::sql(), stays exactly what it says it is, a raw pass-through where you are the one responsible for what you send. That split mirrors how we thought about mysql_query back when the escaping was manual and the mistakes were loud: the dangerous path was never the query API itself, it was the code that pretended concatenation was safe because the input “came from a form we control.” A permission dropdown populated from a database query is still attacker-influenceable the moment an operator’s browser or a stale cache or a second admin with a grudge enters the picture, and the allowlist costs one regex and one in_array to remove that entire class of conversation. The server enforces its own permissions too, because defense in depth is not optional, but the client-side guard is what turns a would-be incident into a typed exception with the attack string sitting in the message, which is the cheapest security boundary you will ever ship.