Skip to content

Commit aceb7cd

Browse files
notSumit25claude
andcommitted
fix(security): escape identifiers in the brain statistics SQL
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) <noreply@anthropic.com>
1 parent 8b391c7 commit aceb7cd

7 files changed

Lines changed: 473 additions & 10 deletions

File tree

‎CLAUDE.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,36 @@ it against a real database — not a theoretical hardening pass.
878878
constructs a real `MySQLQueryExecutionProvider`. Do not reintroduce a stubbed
879879
dialect here; the mock is what let the blocker ship.
880880

881+
### SQL Identifier Quoting
882+
883+
- **Quoting an identifier without doubling the embedded quote is not protection.**
884+
`CardinalityEstimationService.quoteIdentifier` returned `"\"" + identifier + "\""` and never
885+
escaped, which is exactly as safe as `"'" + value + "'"` is for a string literal. Six
886+
`String.format` sinks consumed it with no bind parameter, fed by an unvalidated
887+
`@PathVariable tableName` on `POST /brain/statistics/{connectionId}/tables/{tableName}`.
888+
Reproduced against a real PostgreSQL: the payload
889+
`victim" AS t; DROP TABLE zz_v.probe; SELECT 1 FROM zz_v."victim` executed with **no errors**
890+
and the probe table went from 1 row to gone. It carries no `/`, so `StrictHttpFirewall` does
891+
not block it, and the path **never reaches `QueryExecutorService`** so there is no
892+
`setReadOnly(true)` backstop — `grep setReadOnly` over `src/main/java` still returns one hit,
893+
and it is not here.
894+
- **Use `SqlIdentifier.quote(identifier, dbType)`. Do not write another quoter.** Four of the
895+
six quoters in the backend were already correct; the two that were not were both
896+
reimplementations in *service* classes, while the *provider* classes got it right — the same
897+
"clustered by when it was written" signature the `BrainController` authorization misses had.
898+
Two copies of a security primitive is the defect: one gets fixed, the other is missed.
899+
- **`SqlIdentifier.requireSafe` is the second layer, and it runs before the connection work.**
900+
Escaping makes injection impossible but still lets a caller address an object the feature
901+
never meant to touch. It is called at the top of `collectTableStatistics`, ahead of
902+
`getDecryptedConnection` — validating after it would make a hostile name a credential-use
903+
primitive even when the statement never runs. The pattern `[A-Za-z0-9_$.]+` is deliberately
904+
permissive enough for `v_daily_revenue` / `public.orders` / `tableName$`; a validator that
905+
rejects real names is one the next person deletes. A rejected name is a **400**, not a 500.
906+
- **A grep is not an audit.** `SlackDailyDigestService:3028` escapes via
907+
`identifier.replace(quote, quote + quote)` with a *variable*, so a literal-matching grep
908+
reported it vulnerable when it is not. Read the body before believing the pattern.
909+
See `docs/security/2026-09-16-sql-injection-quote-identifier.md`.
910+
881911
### Data Model Rules
882912

883913
- **`mcp_tokens.user_id` is a non-null FK with no cascade.** Deleting a user who holds

‎backend/src/main/java/com/dbaagent/controller/BrainController.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1952,6 +1952,11 @@ public ResponseEntity<List<ColumnStatistics>> collectTableStatistics(
19521952
return ResponseEntity.ok(stats);
19531953
} catch (ResponseStatusException e) {
19541954
throw e;
1955+
} catch (IllegalArgumentException e) {
1956+
// A rejected identifier is a bad request, not a server fault. Without this the
1957+
// catch-all below reports 500 and sends the caller looking for an outage.
1958+
log.warn("Rejected table name for statistics collection: {}", e.getMessage());
1959+
return ResponseEntity.badRequest().build();
19551960
} catch (Exception e) {
19561961
log.error("Error collecting table statistics", e);
19571962
return ResponseEntity.internalServerError().build();

‎backend/src/main/java/com/dbaagent/service/brain/keycolumn/ColumnValueCollectionService.java‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.time.LocalDateTime;
2323
import java.util.*;
2424
import java.util.stream.Collectors;
25+
import com.dbaagent.util.SqlIdentifier;
2526

2627
/**
2728
* Service for collecting and caching column values, especially for low-cardinality columns.
@@ -447,12 +448,13 @@ private String buildDistinctValuesQuery(String tableName, String columnName, Str
447448
/**
448449
* Quote identifier based on database type.
449450
*/
451+
/**
452+
* Delegates to {@link SqlIdentifier}, which doubles an embedded quote. This copy had the
453+
* same missing-escape bug as the one in {@code CardinalityEstimationService}; it is fed
454+
* catalog-derived names today, so it was not exploitable, but it was one caller away.
455+
*/
450456
private String quoteIdentifier(String identifier, String dbType) {
451-
if (dbType != null && dbType.toLowerCase().contains("mysql")) {
452-
return "`" + identifier + "`";
453-
}
454-
// PostgreSQL and others use double quotes
455-
return "\"" + identifier + "\"";
457+
return SqlIdentifier.quote(identifier, dbType);
456458
}
457459

458460
/**

‎backend/src/main/java/com/dbaagent/service/brain/query/CardinalityEstimationService.java‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.time.LocalDateTime;
1616
import java.util.*;
1717
import java.util.stream.Collectors;
18+
import com.dbaagent.util.SqlIdentifier;
1819

1920
/**
2021
* Brain 2.0: Cardinality Estimation Service
@@ -47,6 +48,11 @@ public class CardinalityEstimationService {
4748
*/
4849
@Transactional
4950
public List<ColumnStatistics> collectTableStatistics(String connectionId, String tableName) {
51+
// Refused before any connection work. getDecryptedConnection below decrypts stored
52+
// credentials and opens a JDBC session, so validating after it would make a hostile
53+
// name a credential-use primitive even when the statement never runs — the same
54+
// "check before the work, not after" rule the slow-query analytics endpoints learned.
55+
SqlIdentifier.requireSafe(tableName);
5056
log.info("Collecting column statistics for table: {} in connection: {}", tableName, connectionId);
5157

5258
try {
@@ -498,12 +504,17 @@ private String getColumnDataType(JdbcTemplate jdbc, String dbType, String tableN
498504
}
499505
}
500506

507+
/**
508+
* Delegates to {@link SqlIdentifier}, which doubles an embedded quote.
509+
*
510+
* <p>This used to wrap without doubling, so a {@code tableName} path variable carrying a
511+
* quote closed the identifier and the rest became live SQL. Verified against a real
512+
* PostgreSQL: the injected {@code DROP TABLE} executed, with none of the six
513+
* {@code String.format} sinks below using a bind parameter, and this path never reaches
514+
* {@code QueryExecutorService} so there is no {@code setReadOnly(true)} backstop either.
515+
*/
501516
private String quoteIdentifier(String dbType, String identifier) {
502-
if ("postgres".equals(dbType)) {
503-
return "\"" + identifier + "\"";
504-
} else {
505-
return "`" + identifier + "`";
506-
}
517+
return SqlIdentifier.quote(identifier, dbType);
507518
}
508519

509520
private boolean isNumericType(String dataType) {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.dbaagent.util;
2+
3+
import java.util.regex.Pattern;
4+
5+
/**
6+
* Quoting and validation for table and column names interpolated into SQL.
7+
*
8+
* <p>Identifiers cannot be bind parameters, so every dialect's answer is to quote them — and
9+
* quoting is only protection if an embedded quote is <em>doubled</em>. Wrapping without
10+
* doubling is exactly as safe as {@code "'" + value + "'"} is for a string literal, which is
11+
* to say not at all.
12+
*
13+
* <p>Two services had written their own quoter and both omitted the doubling, while the three
14+
* provider classes next to them did it correctly. Verified against a real PostgreSQL rather
15+
* than inferred: a {@code tableName} path variable of
16+
* {@code victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim} reaching
17+
* {@code CardinalityEstimationService} produced
18+
*
19+
* <pre>SELECT COUNT(*) FROM zz_inj."victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim"</pre>
20+
*
21+
* which executed with no errors at all — the count returned, the table was dropped, and the
22+
* trailing select returned its rows. That payload contains no {@code /}, so Spring's
23+
* {@code StrictHttpFirewall} does not block it.
24+
*
25+
* <p>This lives in one place on purpose. A security primitive copied into each caller is a
26+
* primitive that gets fixed in one copy and missed in the others — the drift the SQL guard is
27+
* kept mirrored to avoid, and the reason the two renderers in Agent chat now share one escape.
28+
*/
29+
public final class SqlIdentifier {
30+
31+
/**
32+
* What a real table or column name looks like: letters, digits, underscore, dollar, and a
33+
* dot for a schema-qualified name. Deliberately permissive enough for the schemas this
34+
* product actually meets — a validator that rejected {@code v_daily_revenue} or
35+
* {@code order_items_2026} would be deleted by the next person to hit it.
36+
*/
37+
private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z0-9_$.]+");
38+
39+
private SqlIdentifier() {
40+
}
41+
42+
/**
43+
* Quotes an identifier for the dialect, doubling any embedded quote character.
44+
*
45+
* <p>An unknown or null dialect gets ANSI double quotes. Defaulting to MySQL backticks
46+
* would be the riskier guess: a double quote arriving in a backtick-quoted identifier is
47+
* inert, while a backtick arriving in a double-quoted one is inert too — but ANSI is what
48+
* every non-MySQL dialect here uses, so it is the correct default rather than merely the
49+
* safe one.
50+
*/
51+
public static String quote(String identifier, String dbType) {
52+
String quote = isMysql(dbType) ? "`" : "\"";
53+
return quote + identifier.replace(quote, quote + quote) + quote;
54+
}
55+
56+
/**
57+
* Returns the identifier if it could name a real table or column, and throws otherwise.
58+
*
59+
* <p>{@link #quote} already makes injection impossible; this is the second layer. Escaping
60+
* turns a hostile name into a harmless one, but it still lets a caller address an object
61+
* the feature never meant to touch, and it leaves a confusing error when the "table" was
62+
* never a table. Refusing early says so plainly.
63+
*/
64+
public static String requireSafe(String identifier) {
65+
if (identifier == null || identifier.isBlank()) {
66+
throw new IllegalArgumentException("Identifier is required");
67+
}
68+
if (!SAFE_IDENTIFIER.matcher(identifier).matches()) {
69+
throw new IllegalArgumentException(
70+
"Not a valid table or column name: " + identifier);
71+
}
72+
return identifier;
73+
}
74+
75+
private static boolean isMysql(String dbType) {
76+
return dbType != null && dbType.toLowerCase().contains("mysql");
77+
}
78+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package com.dbaagent.util;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.junit.jupiter.api.Assertions.*;
6+
7+
/**
8+
* Identifier quoting for the SQL that cannot be parameterised.
9+
*
10+
* <p>Table and column names cannot be bind parameters, so they are interpolated into SQL by
11+
* hand. {@code CardinalityEstimationService.quoteIdentifier} wrapped them in quotes and never
12+
* doubled an embedded one, which is no protection at all — the same way
13+
* {@code "'" + value + "'"} is no protection for a string literal.
14+
*
15+
* <p>Verified against a real PostgreSQL, not inferred. A {@code tableName} path variable of
16+
* {@code victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim} produced
17+
*
18+
* <pre>SELECT COUNT(*) FROM zz_inj."victim" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj."victim"</pre>
19+
*
20+
* which ran with no errors: the count returned, the table was dropped (it existed before and
21+
* did not after), and the trailing select returned its rows. The payload carries no {@code /},
22+
* so Spring's {@code StrictHttpFirewall} does not stand in its way.
23+
*/
24+
class SqlIdentifierTest {
25+
26+
// ── the injection that was proven to execute ──────────────────────────────
27+
28+
@Test
29+
void doublesAnEmbeddedDoubleQuoteSoTheIdentifierCannotBeClosed() {
30+
String payload = "victim\" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj.\"victim";
31+
32+
String quoted = SqlIdentifier.quote(payload, "postgres");
33+
34+
assertEquals(
35+
"\"victim\"\" AS t; DROP TABLE zz_inj.probe; SELECT 1 FROM zz_inj.\"\"victim\"",
36+
quoted);
37+
// the whole payload is now one identifier: no unescaped quote can terminate it
38+
assertEquals(1, countUnescapedQuotes(quoted, '"'),
39+
"an unescaped quote inside the body would end the identifier early: " + quoted);
40+
}
41+
42+
@Test
43+
void doublesAnEmbeddedBacktickForMysql() {
44+
String payload = "victim` ; DROP TABLE probe; SELECT 1 FROM `victim";
45+
46+
String quoted = SqlIdentifier.quote(payload, "mysql");
47+
48+
assertEquals("`victim`` ; DROP TABLE probe; SELECT 1 FROM ``victim`", quoted);
49+
assertEquals(1, countUnescapedQuotes(quoted, '`'), quoted);
50+
}
51+
52+
/**
53+
* The sinks build {@code SELECT COUNT(*) FROM %s}, so the property that matters is that the
54+
* whole payload lands inside one identifier rather than becoming a second statement.
55+
*
56+
* <p>Asserted on the parse, not on the text. A first version of this test used the regex
57+
* {@code .*"\s*;.*} and failed on correct output, because {@code "";} is an <em>escaped</em>
58+
* quote followed by a semicolon <em>inside</em> the identifier — textually close to a
59+
* terminator and semantically its opposite. That is the same confusion the vulnerable
60+
* quoter made, so it is worth not repeating in the test.
61+
*
62+
* <p>Confirmed against a real PostgreSQL: this exact SQL answers
63+
* {@code ERROR: relation "t"; DROP TABLE zz_v.probe; --" does not exist} — the server read
64+
* the payload as one table name — and the probe table it names was still there afterwards,
65+
* where the unescaped form had dropped it.
66+
*/
67+
@Test
68+
void theInjectedStatementCollapsesIntoASingleIdentifier() {
69+
String payload = "t\"; DROP TABLE probe; --";
70+
71+
String quoted = SqlIdentifier.quote(payload, "postgres");
72+
String sql = "SELECT COUNT(*) FROM " + quoted;
73+
74+
assertEquals("SELECT COUNT(*) FROM \"t\"\"; DROP TABLE probe; --\"", sql);
75+
assertTrue(sql.endsWith(quoted), "the identifier must be the whole tail of the statement");
76+
assertEquals(1, countUnescapedQuotes(quoted, '"'),
77+
"only the closing quote may be unescaped, or the identifier ends early: " + quoted);
78+
}
79+
80+
// ── ordinary identifiers must keep working ────────────────────────────────
81+
82+
@Test
83+
void leavesAnOrdinaryIdentifierAloneApartFromTheQuotes() {
84+
assertEquals("\"orders\"", SqlIdentifier.quote("orders", "postgres"));
85+
assertEquals("\"total_amount\"", SqlIdentifier.quote("total_amount", "postgres"));
86+
assertEquals("`orders`", SqlIdentifier.quote("orders", "mysql"));
87+
}
88+
89+
@Test
90+
void picksTheQuoteCharacterFromTheDialect() {
91+
assertEquals("`t`", SqlIdentifier.quote("t", "mysql"));
92+
assertEquals("`t`", SqlIdentifier.quote("t", "MySQL"));
93+
assertEquals("\"t\"", SqlIdentifier.quote("t", "postgres"));
94+
assertEquals("\"t\"", SqlIdentifier.quote("t", "postgresql"));
95+
}
96+
97+
/**
98+
* An unknown or null dialect must not fall through to "no quoting". Postgres double quotes
99+
* are the ANSI form and the safe default; guessing MySQL backticks for an unknown dialect
100+
* would be the riskier direction.
101+
*/
102+
@Test
103+
void defaultsToAnsiQuotingForAnUnknownDialect() {
104+
assertEquals("\"t\"", SqlIdentifier.quote("t", null));
105+
assertEquals("\"t\"", SqlIdentifier.quote("t", "oracle"));
106+
assertEquals("\"t\"", SqlIdentifier.quote("t", ""));
107+
}
108+
109+
// ── rejecting what should never reach SQL at all ──────────────────────────
110+
111+
/**
112+
* Escaping alone makes injection impossible but still lets a caller name an identifier the
113+
* feature never meant to touch. {@code requireSafe} is the second layer: the brain's
114+
* statistics paths only ever address real tables and columns, so anything that cannot be
115+
* one is refused before a statement is built.
116+
*/
117+
@Test
118+
void refusesAnIdentifierCarryingSqlSyntax() {
119+
for (String bad : new String[] {
120+
"victim\" AS t; DROP TABLE probe; --",
121+
"t; DROP TABLE probe",
122+
"t--comment",
123+
"t/*x*/",
124+
"t'or'1'='1"
125+
}) {
126+
assertThrows(IllegalArgumentException.class,
127+
() -> SqlIdentifier.requireSafe(bad), "should refuse: " + bad);
128+
}
129+
}
130+
131+
@Test
132+
void refusesBlankAndNull() {
133+
assertThrows(IllegalArgumentException.class, () -> SqlIdentifier.requireSafe(null));
134+
assertThrows(IllegalArgumentException.class, () -> SqlIdentifier.requireSafe(" "));
135+
}
136+
137+
/**
138+
* Real schemas carry all of these. A validator that refused them would break the feature it
139+
* is protecting, which is the usual reason such a check gets deleted later.
140+
*/
141+
@Test
142+
void acceptsTheIdentifiersRealSchemasActuallyUse() {
143+
for (String ok : new String[] {
144+
"orders",
145+
"total_amount",
146+
"Orders",
147+
"order_items_2026",
148+
"public.orders",
149+
"_private",
150+
"v_daily_revenue",
151+
"tableName$"
152+
}) {
153+
assertEquals(ok, SqlIdentifier.requireSafe(ok), "should accept: " + ok);
154+
}
155+
}
156+
157+
@Test
158+
void requireSafeReturnsTheIdentifierSoItComposesWithQuote() {
159+
assertEquals("\"orders\"",
160+
SqlIdentifier.quote(SqlIdentifier.requireSafe("orders"), "postgres"));
161+
}
162+
163+
/** Counts quote characters that are not part of a doubled pair. */
164+
private static int countUnescapedQuotes(String quoted, char q) {
165+
String body = quoted.substring(1, quoted.length() - 1);
166+
int unescaped = 0;
167+
for (int i = 0; i < body.length(); i++) {
168+
if (body.charAt(i) != q) continue;
169+
if (i + 1 < body.length() && body.charAt(i + 1) == q) { i++; continue; }
170+
unescaped++;
171+
}
172+
return unescaped + 1; // the closing quote
173+
}
174+
}

0 commit comments

Comments
 (0)