<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Sql-Injection on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/sql-injection/</link><description>Recent content in Sql-Injection on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/assets/og-mongreldb.png</url><link>https://www.mongreldb.com/assets/og-mongreldb.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Fri, 11 Sep 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/sql-injection/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL Injection Is Still a Thing: Blocking It in Grant Strings</title><link>https://www.mongreldb.com/articles/2026/09/sql-injection-is-still-a-thing-blocking-it-in-grant-strings/</link><pubDate>Fri, 11 Sep 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/09/sql-injection-is-still-a-thing-blocking-it-in-grant-strings/</guid><description>The mongreldb-php client validates every permission string against a strict allowlist before it builds a GRANT statement, because DDL fragments cannot be bound as parameters and the classic prepared-statement advice does not cover them.</description><content:encoded><![CDATA[<p>The standard advice about SQL injection, &ldquo;use prepared statements and you are done,&rdquo; 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 <code>GRANT</code> 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 <code>select:orders; DROP USER admin; --</code>, the client library is the last line of defense before the string becomes a statement on the wire.</p>
<p>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.</p>
<h2 id="the-allowlist-not-the-escape-hatch">The allowlist, not the escape hatch</h2>
<p>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. <code>Database::grantPermission()</code> and <code>Database::revokePermission()</code> both call a private <code>validatePermission()</code> first, and the accepted shapes are the entire story:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">validatePermission</span>(<span style="color:#a6e22e">string</span> $permission)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Allowed standalone permissions
</span></span></span><span style="display:flex;"><span>    $standalone <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#39;all&#39;</span>, <span style="color:#e6db74">&#39;ddl&#39;</span>, <span style="color:#e6db74">&#39;admin&#39;</span>];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">in_array</span>(<span style="color:#a6e22e">strtolower</span>($permission), $standalone, <span style="color:#66d9ef">true</span>)) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Check table-level permission format: verb:table_name
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">preg_match</span>(<span style="color:#e6db74">&#39;/^(select|insert|update|delete):(\\w+)$/i&#39;</span>, $permission)) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Reject anything with injection characters
</span></span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\InvalidArgumentException</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;Invalid permission &#39;</span><span style="color:#e6db74">{</span>$permission<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;. Expected: all, ddl, admin, &#34;</span> <span style="color:#f92672">.</span>
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;or select:&lt;table&gt;, insert:&lt;table&gt;, update:&lt;table&gt;, delete:&lt;table&gt;&#39;</span>
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>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 <code>\w+</code> does not match them and the standalone list is compared case-insensitively but exactly. The failure mode is deliberately boring: an <code>InvalidArgumentException</code> thrown in your process, with the offending string in the message, before any bytes are spent on the network.</p>
<p>Only after validation does the client translate the friendly <code>select:orders</code> form into the server&rsquo;s SQL dialect, and the translation is another fixed-shape operation rather than a passthrough:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">permissionToSqlFragment</span>(<span style="color:#a6e22e">string</span> $permission)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">preg_match</span>(<span style="color:#e6db74">&#39;/^(select|insert|update|delete):(\\w+)$/i&#39;</span>, $permission, $m)) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">strtoupper</span>($m[<span style="color:#ae81ff">1</span>]) <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39; ON &#39;</span> <span style="color:#f92672">.</span> $m[<span style="color:#ae81ff">2</span>];
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> $permission;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// GRANT SELECT ON orders TO &#34;analyst&#34;
</span></span></span></code></pre></div><p>The verb gets uppercased, the table name comes out of a capture group that was already constrained to <code>\w+</code>, and the standalone keywords pass through untouched, so the fragment that lands in the <code>GRANT ... TO</code> statement is assembled entirely from pieces the allowlist already vetted.</p>
<p>Worth knowing where the allowlist&rsquo;s edge sits, because it is narrower than SQL itself: <code>\w+</code> happily accepts a table name with a leading digit like <code>select:2026q1</code>, 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.</p>
<h2 id="identifiers-still-get-quoted-because-they-are-a-different-problem">Identifiers still get quoted, because they are a different problem</h2>
<p>Role names and usernames are not a closed grammar the way permissions are, since you may genuinely want a role called <code>read-only-2026</code>, so for those the client falls back to correct quoting instead of rejection: every identifier goes through <code>quoteIdent()</code>, which wraps it in double quotes and doubles any embedded double quote, and passwords go through <code>escapeString()</code>, 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:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$malicious <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;alice&#39;; DROP USER admin; --&#34;</span>;
</span></span><span style="display:flex;"><span>$db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createUser</span>($malicious, <span style="color:#e6db74">&#39;pw&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The whole string lands inside ONE double-quoted identifier:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">//   CREATE USER &#34;alice&#39;; DROP USER admin; --&#34; WITH PASSWORD &#39;pw&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// The semicolon is identifier content, not a statement separator.
</span></span></span></code></pre></div><p>The same suite feeds <code>role&quot;; DROP TABLE orders; --</code> into <code>createRole()</code> and asserts the doubled-quote form <code>&quot;role&quot;&quot;; DROP TABLE orders; --&quot;</code> comes out the other end, and it feeds the grant path its own attack, which is the one this article exists because of:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    $db<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">grantPermission</span>(<span style="color:#e6db74">&#39;role&#39;</span>, <span style="color:#e6db74">&#34;select:orders; DROP USER admin; --&#34;</span>);
</span></span><span style="display:flex;"><span>    $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">fail</span>(<span style="color:#e6db74">&#39;Expected InvalidArgumentException for injected permission&#39;</span>);
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">\InvalidArgumentException</span> $e) {
</span></span><span style="display:flex;"><span>    $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">assertStringContainsString</span>(<span style="color:#e6db74">&#39;select:orders; DROP USER admin&#39;</span>, $e<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getMessage</span>());
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The decisive assertion: no request ever reached the transport.
</span></span></span><span style="display:flex;"><span>$this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">assertSame</span>(<span style="color:#ae81ff">0</span>, $transport<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">requestCount</span>);
</span></span></code></pre></div><p>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.</p>
<h2 id="what-belongs-in-the-client-and-what-does-not">What belongs in the client, and what does not</h2>
<p>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, <code>Database::sql()</code>, 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 <code>mysql_query</code> 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 &ldquo;came from a form we control.&rdquo; A permission dropdown populated from a database query is still attacker-influenceable the moment an operator&rsquo;s browser or a stale cache or a second admin with a grudge enters the picture, and the allowlist costs one regex and one <code>in_array</code> 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.</p>
]]></content:encoded></item></channel></rss>