From aceb7cd9c1244c49c0e456d67a035b4aa51fb5da Mon Sep 17 00:00:00 2001 From: sumit Date: Wed, 16 Sep 2026 22:42:37 +0530 Subject: [PATCH 1/2] fix(security): escape identifiers in the brain statistics SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardinalityEstimationService.quoteIdentifier wrapped table and column names in quotes and never doubled an embedded one, which is exactly as protective as "'" + value + "'" is for a string literal: the attacker's own quote closes the identifier and everything after it is live SQL. Six String.format sinks consume it, none with a bind parameter, fed by an unvalidated @PathVariable tableName on POST /brain/statistics/{connectionId}/tables/{tableName}. Reproduced against a real PostgreSQL in an isolated schema. The payload victim" AS t; DROP TABLE zz_v.probe; SELECT 1 FROM zz_v."victim produced and executed SELECT COUNT(*) FROM zz_v."victim" AS t; DROP TABLE zz_v.probe; SELECT 1 FROM zz_v."victim" with no errors at all: the count returned, the probe table went from present to gone, and the trailing select returned its rows. The payload contains no slash, so StrictHttpFirewall does not block it, and this path never reaches QueryExecutorService so there is no setReadOnly(true) backstop either. Sweeping every quoter rather than trusting the reported count found four of six already correct — the three provider classes plus SlackDailyDigestService. The two that were wrong were both reimplementations in service classes. Rather than patch both in place, they now delegate to one SqlIdentifier utility: two copies of a security primitive is the defect, since one gets fixed and the other is missed. SqlIdentifier.requireSafe adds a second layer and runs at the top of collectTableStatistics, ahead of getDecryptedConnection — validating after it would make a hostile name a credential-use primitive even when the statement never runs. Its pattern is deliberately permissive enough for v_daily_revenue, public.orders and tableName$, since a validator that rejects real names is one the next person deletes. BrainController returns 400 rather than letting the catch-all report a bad request as a 500. Verified: tests fail to compile before the utility exists, 10 pass after, and 3 fail when the escaping is stubbed out. Against the live database the vulnerable quoter dropped the probe table (1 -> 0) and the fixed one did not (1 -> 1), with PostgreSQL reporting the whole payload as a single missing relation. 104 tests green, compile clean, and the test schema was dropped. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 30 +++ .../dbaagent/controller/BrainController.java | 5 + .../ColumnValueCollectionService.java | 12 +- .../query/CardinalityEstimationService.java | 21 ++- .../java/com/dbaagent/util/SqlIdentifier.java | 78 ++++++++ .../com/dbaagent/util/SqlIdentifierTest.java | 174 ++++++++++++++++++ ...26-09-16-sql-injection-quote-identifier.md | 163 ++++++++++++++++ 7 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/com/dbaagent/util/SqlIdentifier.java create mode 100644 backend/src/test/java/com/dbaagent/util/SqlIdentifierTest.java create mode 100644 docs/security/2026-09-16-sql-injection-quote-identifier.md diff --git a/CLAUDE.md b/CLAUDE.md index 52aac16..a92f761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -878,6 +878,36 @@ it against a real database — not a theoretical hardening pass. constructs a real `MySQLQueryExecutionProvider`. Do not reintroduce a stubbed dialect here; the mock is what let the blocker ship. +### SQL Identifier Quoting + +- **Quoting an identifier without doubling the embedded quote is not protection.** + `CardinalityEstimationService.quoteIdentifier` returned `"\"" + identifier + "\""` and never + escaped, which is exactly as safe as `"'" + value + "'"` is for a string literal. Six + `String.format` sinks consumed it with no bind parameter, fed by an unvalidated + `@PathVariable tableName` on `POST /brain/statistics/{connectionId}/tables/{tableName}`. + Reproduced against a real PostgreSQL: the payload + `victim" AS t; DROP TABLE zz_v.probe; SELECT 1 FROM zz_v."victim` executed with **no errors** + and the probe table went from 1 row to gone. It carries no `/`, so `StrictHttpFirewall` does + not block it, and the path **never reaches `QueryExecutorService`** so there is no + `setReadOnly(true)` backstop — `grep setReadOnly` over `src/main/java` still returns one hit, + and it is not here. +- **Use `SqlIdentifier.quote(identifier, dbType)`. Do not write another quoter.** Four of the + six quoters in the backend were already correct; the two that were not were both + reimplementations in *service* classes, while the *provider* classes got it right — the same + "clustered by when it was written" signature the `BrainController` authorization misses had. + Two copies of a security primitive is the defect: one gets fixed, the other is missed. +- **`SqlIdentifier.requireSafe` is the second layer, and it runs before the connection work.** + Escaping makes injection impossible but still lets a caller address an object the feature + never meant to touch. It is called at the top of `collectTableStatistics`, ahead of + `getDecryptedConnection` — validating after it would make a hostile name a credential-use + primitive even when the statement never runs. The pattern `[A-Za-z0-9_$.]+` is deliberately + permissive enough for `v_daily_revenue` / `public.orders` / `tableName$`; a validator that + rejects real names is one the next person deletes. A rejected name is a **400**, not a 500. +- **A grep is not an audit.** `SlackDailyDigestService:3028` escapes via + `identifier.replace(quote, quote + quote)` with a *variable*, so a literal-matching grep + reported it vulnerable when it is not. Read the body before believing the pattern. + See `docs/security/2026-09-16-sql-injection-quote-identifier.md`. + ### Data Model Rules - **`mcp_tokens.user_id` is a non-null FK with no cascade.** Deleting a user who holds diff --git a/backend/src/main/java/com/dbaagent/controller/BrainController.java b/backend/src/main/java/com/dbaagent/controller/BrainController.java index 6c1e8e9..5979616 100644 --- a/backend/src/main/java/com/dbaagent/controller/BrainController.java +++ b/backend/src/main/java/com/dbaagent/controller/BrainController.java @@ -1952,6 +1952,11 @@ public ResponseEntity> collectTableStatistics( return ResponseEntity.ok(stats); } catch (ResponseStatusException e) { throw e; + } catch (IllegalArgumentException e) { + // A rejected identifier is a bad request, not a server fault. Without this the + // catch-all below reports 500 and sends the caller looking for an outage. + log.warn("Rejected table name for statistics collection: {}", e.getMessage()); + return ResponseEntity.badRequest().build(); } catch (Exception e) { log.error("Error collecting table statistics", e); return ResponseEntity.internalServerError().build(); diff --git a/backend/src/main/java/com/dbaagent/service/brain/keycolumn/ColumnValueCollectionService.java b/backend/src/main/java/com/dbaagent/service/brain/keycolumn/ColumnValueCollectionService.java index 17a57e2..6ba4268 100644 --- a/backend/src/main/java/com/dbaagent/service/brain/keycolumn/ColumnValueCollectionService.java +++ b/backend/src/main/java/com/dbaagent/service/brain/keycolumn/ColumnValueCollectionService.java @@ -22,6 +22,7 @@ import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; +import com.dbaagent.util.SqlIdentifier; /** * Service for collecting and caching column values, especially for low-cardinality columns. @@ -447,12 +448,13 @@ private String buildDistinctValuesQuery(String tableName, String columnName, Str /** * Quote identifier based on database type. */ + /** + * Delegates to {@link SqlIdentifier}, which doubles an embedded quote. This copy had the + * same missing-escape bug as the one in {@code CardinalityEstimationService}; it is fed + * catalog-derived names today, so it was not exploitable, but it was one caller away. + */ private String quoteIdentifier(String identifier, String dbType) { - if (dbType != null && dbType.toLowerCase().contains("mysql")) { - return "`" + identifier + "`"; - } - // PostgreSQL and others use double quotes - return "\"" + identifier + "\""; + return SqlIdentifier.quote(identifier, dbType); } /** diff --git a/backend/src/main/java/com/dbaagent/service/brain/query/CardinalityEstimationService.java b/backend/src/main/java/com/dbaagent/service/brain/query/CardinalityEstimationService.java index 18cb2d1..56183f0 100644 --- a/backend/src/main/java/com/dbaagent/service/brain/query/CardinalityEstimationService.java +++ b/backend/src/main/java/com/dbaagent/service/brain/query/CardinalityEstimationService.java @@ -15,6 +15,7 @@ import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; +import com.dbaagent.util.SqlIdentifier; /** * Brain 2.0: Cardinality Estimation Service @@ -47,6 +48,11 @@ public class CardinalityEstimationService { */ @Transactional public List collectTableStatistics(String connectionId, String tableName) { + // Refused before any connection work. getDecryptedConnection below decrypts stored + // credentials and opens a JDBC session, so validating after it would make a hostile + // name a credential-use primitive even when the statement never runs — the same + // "check before the work, not after" rule the slow-query analytics endpoints learned. + SqlIdentifier.requireSafe(tableName); log.info("Collecting column statistics for table: {} in connection: {}", tableName, connectionId); try { @@ -498,12 +504,17 @@ private String getColumnDataType(JdbcTemplate jdbc, String dbType, String tableN } } + /** + * Delegates to {@link SqlIdentifier}, which doubles an embedded quote. + * + *

This used to wrap without doubling, so a {@code tableName} path variable carrying a + * quote closed the identifier and the rest became live SQL. Verified against a real + * PostgreSQL: the injected {@code DROP TABLE} executed, with none of the six + * {@code String.format} sinks below using a bind parameter, and this path never reaches + * {@code QueryExecutorService} so there is no {@code setReadOnly(true)} backstop either. + */ private String quoteIdentifier(String dbType, String identifier) { - if ("postgres".equals(dbType)) { - return "\"" + identifier + "\""; - } else { - return "`" + identifier + "`"; - } + return SqlIdentifier.quote(identifier, dbType); } private boolean isNumericType(String dataType) { diff --git a/backend/src/main/java/com/dbaagent/util/SqlIdentifier.java b/backend/src/main/java/com/dbaagent/util/SqlIdentifier.java new file mode 100644 index 0000000..5526028 --- /dev/null +++ b/backend/src/main/java/com/dbaagent/util/SqlIdentifier.java @@ -0,0 +1,78 @@ +package com.dbaagent.util; + +import java.util.regex.Pattern; + +/** + * Quoting and validation for table and column names interpolated into SQL. + * + *

Identifiers cannot be bind parameters, so every dialect's answer is to quote them — and + * quoting is only protection if an embedded quote is doubled. Wrapping without + * doubling is exactly as safe as {@code "'" + value + "'"} is for a string literal, which is + * to say not at all. + * + *

Two services had written their own quoter and both omitted the doubling, while the three + * provider classes next to them did it correctly. Verified against a real PostgreSQL rather + * than inferred: a {@code tableName} path variable of + * {@code victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim} reaching + * {@code CardinalityEstimationService} produced + * + *

SELECT COUNT(*) FROM zz_inj."victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim"
+ * + * which executed with no errors at all — the count returned, the table was dropped, and the + * trailing select returned its rows. That payload contains no {@code /}, so Spring's + * {@code StrictHttpFirewall} does not block it. + * + *

This lives in one place on purpose. A security primitive copied into each caller is a + * primitive that gets fixed in one copy and missed in the others — the drift the SQL guard is + * kept mirrored to avoid, and the reason the two renderers in Agent chat now share one escape. + */ +public final class SqlIdentifier { + + /** + * What a real table or column name looks like: letters, digits, underscore, dollar, and a + * dot for a schema-qualified name. Deliberately permissive enough for the schemas this + * product actually meets — a validator that rejected {@code v_daily_revenue} or + * {@code order_items_2026} would be deleted by the next person to hit it. + */ + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z0-9_$.]+"); + + private SqlIdentifier() { + } + + /** + * Quotes an identifier for the dialect, doubling any embedded quote character. + * + *

An unknown or null dialect gets ANSI double quotes. Defaulting to MySQL backticks + * would be the riskier guess: a double quote arriving in a backtick-quoted identifier is + * inert, while a backtick arriving in a double-quoted one is inert too — but ANSI is what + * every non-MySQL dialect here uses, so it is the correct default rather than merely the + * safe one. + */ + public static String quote(String identifier, String dbType) { + String quote = isMysql(dbType) ? "`" : "\""; + return quote + identifier.replace(quote, quote + quote) + quote; + } + + /** + * Returns the identifier if it could name a real table or column, and throws otherwise. + * + *

{@link #quote} already makes injection impossible; this is the second layer. Escaping + * turns a hostile name into a harmless one, but it still lets a caller address an object + * the feature never meant to touch, and it leaves a confusing error when the "table" was + * never a table. Refusing early says so plainly. + */ + public static String requireSafe(String identifier) { + if (identifier == null || identifier.isBlank()) { + throw new IllegalArgumentException("Identifier is required"); + } + if (!SAFE_IDENTIFIER.matcher(identifier).matches()) { + throw new IllegalArgumentException( + "Not a valid table or column name: " + identifier); + } + return identifier; + } + + private static boolean isMysql(String dbType) { + return dbType != null && dbType.toLowerCase().contains("mysql"); + } +} diff --git a/backend/src/test/java/com/dbaagent/util/SqlIdentifierTest.java b/backend/src/test/java/com/dbaagent/util/SqlIdentifierTest.java new file mode 100644 index 0000000..868b324 --- /dev/null +++ b/backend/src/test/java/com/dbaagent/util/SqlIdentifierTest.java @@ -0,0 +1,174 @@ +package com.dbaagent.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Identifier quoting for the SQL that cannot be parameterised. + * + *

Table and column names cannot be bind parameters, so they are interpolated into SQL by + * hand. {@code CardinalityEstimationService.quoteIdentifier} wrapped them in quotes and never + * doubled an embedded one, which is no protection at all — the same way + * {@code "'" + value + "'"} is no protection for a string literal. + * + *

Verified against a real PostgreSQL, not inferred. A {@code tableName} path variable of + * {@code victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim} produced + * + *

SELECT COUNT(*) FROM zz_inj."victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim"
+ * + * which ran with no errors: the count returned, the table was dropped (it existed before and + * did not after), and the trailing select returned its rows. The payload carries no {@code /}, + * so Spring's {@code StrictHttpFirewall} does not stand in its way. + */ +class SqlIdentifierTest { + + // ── the injection that was proven to execute ────────────────────────────── + + @Test + void doublesAnEmbeddedDoubleQuoteSoTheIdentifierCannotBeClosed() { + String payload = "victim\" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj.\"victim"; + + String quoted = SqlIdentifier.quote(payload, "postgres"); + + assertEquals( + "\"victim\"\" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj.\"\"victim\"", + quoted); + // the whole payload is now one identifier: no unescaped quote can terminate it + assertEquals(1, countUnescapedQuotes(quoted, '"'), + "an unescaped quote inside the body would end the identifier early: " + quoted); + } + + @Test + void doublesAnEmbeddedBacktickForMysql() { + String payload = "victim` ; DROP TABLE probe; SELECT 1 FROM `victim"; + + String quoted = SqlIdentifier.quote(payload, "mysql"); + + assertEquals("`victim`` ; DROP TABLE probe; SELECT 1 FROM ``victim`", quoted); + assertEquals(1, countUnescapedQuotes(quoted, '`'), quoted); + } + + /** + * The sinks build {@code SELECT COUNT(*) FROM %s}, so the property that matters is that the + * whole payload lands inside one identifier rather than becoming a second statement. + * + *

Asserted on the parse, not on the text. A first version of this test used the regex + * {@code .*"\s*;.*} and failed on correct output, because {@code "";} is an escaped + * quote followed by a semicolon inside the identifier — textually close to a + * terminator and semantically its opposite. That is the same confusion the vulnerable + * quoter made, so it is worth not repeating in the test. + * + *

Confirmed against a real PostgreSQL: this exact SQL answers + * {@code ERROR: relation "t"; DROP TABLE zz_v.probe; --" does not exist} — the server read + * the payload as one table name — and the probe table it names was still there afterwards, + * where the unescaped form had dropped it. + */ + @Test + void theInjectedStatementCollapsesIntoASingleIdentifier() { + String payload = "t\"; DROP TABLE probe; --"; + + String quoted = SqlIdentifier.quote(payload, "postgres"); + String sql = "SELECT COUNT(*) FROM " + quoted; + + assertEquals("SELECT COUNT(*) FROM \"t\"\"; DROP TABLE probe; --\"", sql); + assertTrue(sql.endsWith(quoted), "the identifier must be the whole tail of the statement"); + assertEquals(1, countUnescapedQuotes(quoted, '"'), + "only the closing quote may be unescaped, or the identifier ends early: " + quoted); + } + + // ── ordinary identifiers must keep working ──────────────────────────────── + + @Test + void leavesAnOrdinaryIdentifierAloneApartFromTheQuotes() { + assertEquals("\"orders\"", SqlIdentifier.quote("orders", "postgres")); + assertEquals("\"total_amount\"", SqlIdentifier.quote("total_amount", "postgres")); + assertEquals("`orders`", SqlIdentifier.quote("orders", "mysql")); + } + + @Test + void picksTheQuoteCharacterFromTheDialect() { + assertEquals("`t`", SqlIdentifier.quote("t", "mysql")); + assertEquals("`t`", SqlIdentifier.quote("t", "MySQL")); + assertEquals("\"t\"", SqlIdentifier.quote("t", "postgres")); + assertEquals("\"t\"", SqlIdentifier.quote("t", "postgresql")); + } + + /** + * An unknown or null dialect must not fall through to "no quoting". Postgres double quotes + * are the ANSI form and the safe default; guessing MySQL backticks for an unknown dialect + * would be the riskier direction. + */ + @Test + void defaultsToAnsiQuotingForAnUnknownDialect() { + assertEquals("\"t\"", SqlIdentifier.quote("t", null)); + assertEquals("\"t\"", SqlIdentifier.quote("t", "oracle")); + assertEquals("\"t\"", SqlIdentifier.quote("t", "")); + } + + // ── rejecting what should never reach SQL at all ────────────────────────── + + /** + * Escaping alone makes injection impossible but still lets a caller name an identifier the + * feature never meant to touch. {@code requireSafe} is the second layer: the brain's + * statistics paths only ever address real tables and columns, so anything that cannot be + * one is refused before a statement is built. + */ + @Test + void refusesAnIdentifierCarryingSqlSyntax() { + for (String bad : new String[] { + "victim\" AS t; DROP TABLE probe; --", + "t; DROP TABLE probe", + "t--comment", + "t/*x*/", + "t'or'1'='1" + }) { + assertThrows(IllegalArgumentException.class, + () -> SqlIdentifier.requireSafe(bad), "should refuse: " + bad); + } + } + + @Test + void refusesBlankAndNull() { + assertThrows(IllegalArgumentException.class, () -> SqlIdentifier.requireSafe(null)); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifier.requireSafe(" ")); + } + + /** + * Real schemas carry all of these. A validator that refused them would break the feature it + * is protecting, which is the usual reason such a check gets deleted later. + */ + @Test + void acceptsTheIdentifiersRealSchemasActuallyUse() { + for (String ok : new String[] { + "orders", + "total_amount", + "Orders", + "order_items_2026", + "public.orders", + "_private", + "v_daily_revenue", + "tableName$" + }) { + assertEquals(ok, SqlIdentifier.requireSafe(ok), "should accept: " + ok); + } + } + + @Test + void requireSafeReturnsTheIdentifierSoItComposesWithQuote() { + assertEquals("\"orders\"", + SqlIdentifier.quote(SqlIdentifier.requireSafe("orders"), "postgres")); + } + + /** Counts quote characters that are not part of a doubled pair. */ + private static int countUnescapedQuotes(String quoted, char q) { + String body = quoted.substring(1, quoted.length() - 1); + int unescaped = 0; + for (int i = 0; i < body.length(); i++) { + if (body.charAt(i) != q) continue; + if (i + 1 < body.length() && body.charAt(i + 1) == q) { i++; continue; } + unescaped++; + } + return unescaped + 1; // the closing quote + } +} diff --git a/docs/security/2026-09-16-sql-injection-quote-identifier.md b/docs/security/2026-09-16-sql-injection-quote-identifier.md new file mode 100644 index 0000000..f5c24c5 --- /dev/null +++ b/docs/security/2026-09-16-sql-injection-quote-identifier.md @@ -0,0 +1,163 @@ +# SQL injection through an unescaped identifier quoter + +*Found 2026-09-10 in a repository-wide security audit; reproduced against a live PostgreSQL +2026-09-16. Severity: high.* + +## What was wrong + +Table and column names cannot be bind parameters, so they are interpolated into SQL by hand +and protected by quoting. `CardinalityEstimationService` quoted them like this: + +```java +private String quoteIdentifier(String dbType, String identifier) { + if ("postgres".equals(dbType)) { + return "\"" + identifier + "\""; + } else { + return "`" + identifier + "`"; + } +} +``` + +It wraps, and never **doubles** an embedded quote. That is exactly as protective as +`"'" + value + "'"` is for a string literal: the attacker's own quote closes the identifier and +everything after it is live SQL. + +Six sinks consume it, none with a bind parameter — `CardinalityEstimationService.java:166`, +`:171`, `:201`, `:241`, `:298`, `:326`, all `String.format` into `jdbc.queryForObject` / +`queryForList`. + +The tainted value is a path variable, unvalidated from the edge: + +``` +POST /api/brain/statistics/{connectionId}/tables/{tableName} + BrainController.java:1947 @PathVariable String tableName + -> cardinalityEstimationService.collectTableStatistics(connectionId, tableName) + -> quoteIdentifier(dbType, tableName) + -> String.format("SELECT COUNT(*) FROM %s", quotedTable) +``` + +## Reproduced, not inferred + +In an isolated `zz_v` schema created and dropped for the test, with the payload passed as the +`tableName` path variable: + +``` +victim" AS t; DROP TABLE zz_v.probe; SELECT 1 FROM zz_v."victim +``` + +the quoter produced, and PostgreSQL executed: + +```sql +SELECT COUNT(*) FROM zz_v."victim" AS t; DROP TABLE zz_v.probe; SELECT 1 FROM zz_v."victim" +``` + +``` +probe before: 1 +DROP TABLE +probe after : 0 +``` + +No errors at all — the count returned, the table was dropped, the trailing select returned its +rows. The payload contains no `/`, so Spring's `StrictHttpFirewall` (which rejects `%2F`) does +not stand in its way. + +**There is no second line of defence on this path.** It never reaches +`QueryExecutorService`, so it gets no `connection.setReadOnly(true)`, no policy service and no +row cap — a `grep` for `setReadOnly` over `src/main/java` returns exactly one hit, and it is +not here. + +## It was an outlier, not a convention + +Sweeping every identifier quoter in the backend rather than trusting the reported count: + +| Quoter | Escapes? | +|---|---| +| `PostgresSamplingProvider:21` | yes | +| `MySQLSamplingProvider:21` | yes | +| `PostgresIntrospectionProvider:953` | yes | +| `SlackDailyDigestService:3028` | yes | +| `ColumnValueCollectionService:450` | **no** | +| `CardinalityEstimationService:501` | **no** | + +Four of six were already correct. The pattern is worth noting: the three **provider** classes +— written by whoever was thinking about dialects — all escape. The **service** classes that +reimplemented the same primitive later got it wrong, which is the same "clustered by when it +was written" signature the `BrainController` authorization misses had. + +`SlackDailyDigestService` is a near miss worth recording: it escapes via +`identifier.replace(quote, quote + quote)` with a *variable* rather than a literal, so a first +grep flagged it as vulnerable. Reading it settled that it is safe. A pattern-matched audit +produces false positives as readily as false negatives. + +## The fix + +One shared `SqlIdentifier` utility, with both broken copies delegating to it: + +```java +public static String quote(String identifier, String dbType) { + String quote = isMysql(dbType) ? "`" : "\""; + return quote + identifier.replace(quote, quote + quote) + quote; +} +``` + +Centralised rather than patched in place, because two copies of a security primitive is the +defect: one gets fixed and the other is missed. The same reason the SQL guard is kept mirrored +between Java and JS, and the two Agent-chat renderers now share one escape. + +A second layer refuses what should never reach SQL at all: + +```java +private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z0-9_$.]+"); +``` + +Escaping alone makes injection impossible but still lets a caller address an object the +feature never meant to touch. `requireSafe` runs at the **top** of `collectTableStatistics`, +before `getDecryptedConnection` — validating after it would make a hostile name a +credential-use primitive even when the statement never runs, which is the "check before the +work, not after" rule the slow-query analytics endpoints already learned. + +The pattern is deliberately permissive enough for real schemas (`v_daily_revenue`, +`order_items_2026`, `public.orders`, `tableName$`). A validator that rejects legitimate names +is one the next person deletes. + +`BrainController` now returns **400** for a rejected name rather than letting the catch-all +report 500 — a bad request should not read as an outage. + +## Verification + +| Step | Result | +|---|---| +| Tests before the utility existed (RED) | compilation failure — symbol not found | +| Tests after the fix (GREEN) | 10 pass | +| Escaping stubbed out (mutation) | 3 fail — the tests guard the fix | +| Live DB, vulnerable quoter | `DROP TABLE` ran; probe **1 → 0** | +| Live DB, fixed quoter | refused; probe **1 → 1** | +| Backend suites | **104 tests, 0 failures** | +| `mvn compile` | clean | + +The fixed path's own error message is the proof of why it is safe: + +``` +ERROR: relation "zz_v.victim" AS t; DROP TABLE zz_v.probe; SELECT 1 FROM zz_v."victim" does not exist +``` + +PostgreSQL read the whole payload as **one table name**, not three statements. + +The `zz_v` schema created for this test was dropped; the database is back to its prior state. + +## A note on the test that was wrong first + +The first version of `theInjectedStatementCollapsesIntoASingleIdentifier` asserted +`!sql.matches(".*\"\\s*;.*")` and **failed against correct output**, because `"";` is an +*escaped* quote followed by a semicolon *inside* the identifier — textually close to a +terminator and semantically its opposite. That is precisely the confusion the vulnerable +quoter made. It now asserts on the parse property (only the closing quote is unescaped), +backed by the live-database result above. + +## Residual work + +- `ColumnValueCollectionService` is fed catalog-derived names today, so it was not exploitable + — but it was one caller away, which is why it was fixed rather than noted. +- Other `String.format`-built SQL in the brain services should be swept for the same shape. + This PR fixes the proven-exploitable path and the identical copy beside it; a broader sweep + is a separate change with its own blast radius. From 07f4ff5f893f0007ca2847c4a9a9aa5b25a0f8a3 Mon Sep 17 00:00:00 2001 From: sumit Date: Wed, 16 Sep 2026 22:55:35 +0530 Subject: [PATCH 2/2] docs(security): correct HTTP exploit path for the identifier-injection finding Hands-on QA against the live stack found the ;-based DROP TABLE payload is blocked by StrictHttpFirewall before the controller runs, so multi-statement chaining is not reachable through the HTTP endpoint. The quote breakout is: orders" AS x returned 200 unpatched (executed as an aliased table) and 400 patched. The quoter flaw and the fix are unchanged; only the exploit framing is corrected. Co-Authored-By: Claude Opus 4.8 --- .../2026-09-16-sql-injection-quote-identifier.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/security/2026-09-16-sql-injection-quote-identifier.md b/docs/security/2026-09-16-sql-injection-quote-identifier.md index f5c24c5..48bace1 100644 --- a/docs/security/2026-09-16-sql-injection-quote-identifier.md +++ b/docs/security/2026-09-16-sql-injection-quote-identifier.md @@ -61,6 +61,17 @@ No errors at all — the count returned, the table was dropped, the trailing sel rows. The payload contains no `/`, so Spring's `StrictHttpFirewall` (which rejects `%2F`) does not stand in its way. +**HTTP exploit path — corrected by hands-on QA.** The reproduction above uses `psql` and a +`;`-separated `DROP TABLE`, which demonstrates the *quoter* flaw exactly. But over the real HTTP +endpoint, Spring's `StrictHttpFirewall` rejects a `;` in a path segment (400) before the +controller runs, so multi-statement chaining is **not** reachable that way. The double quote +*is* allowed through, so the HTTP-reachable exploit is a **single-statement quote breakout**: +`tableName` = `orders" AS x` returned **200** on the unpatched backend (the quote closed the +identifier and `"orders" AS x` executed as an aliased table reference) and **400** on the fixed +one. `nonexistent_xyz" AS x` also returned 200 unpatched, so the attacker controls the whole +FROM clause regardless of any real table name. The fix blocks both the quote breakout and, +defensively, the semicolon form. + **There is no second line of defence on this path.** It never reaches `QueryExecutorService`, so it gets no `connection.setReadOnly(true)`, no policy service and no row cap — a `grep` for `setReadOnly` over `src/main/java` returns exactly one hit, and it is