fix(security): escape identifiers in the brain statistics SQL - #115
notSumit25 wants to merge 2 commits into
Conversation
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>
Hands-on QA against the live stack — 11/11 PASS, with one correction to my exploit claimQA'd against the real running stack: patched backend (:8098) vs unpatched
|
char in tableName |
HTTP |
|---|---|
ord"ers (quote) |
200 — reaches the app |
ord;ers (semicolon) |
400 — blocked at the edge |
So a reader pasting my exact victim"; DROP TABLE ... payload into the HTTP route sees a 400 even on the unpatched backend, and could wrongly conclude the report was bogus. My direct-psql reproduction was correct for the quoter, but overstated the HTTP exploit path. Multi-statement chaining via ; is not reachable through this endpoint.
The vulnerability is still real — via single-statement quote breakout, which needs no semicolon. Proven with identical input on both backends:
tableName = orders" AS x |
Result |
|---|---|
| UNPATCHED :8080 | HTTP 200, [] — quote broke out, "orders" AS x parsed as an aliased table, query executed |
| PATCHED :8098 | HTTP 400 — quote doubled to "orders"" AS x", requireSafe rejected it |
nonexistent_xyz" AS x also returns 200 unpatched — the attacker controls the entire FROM clause through the breakout, independent of any real table name.
All 11 scenarios
| ID | Scenario | Result |
|---|---|---|
| S1 | Injection refused on patched (probe survives) | PASS |
| S2 | Same breakout executes on unpatched (200 vs 400) | PASS |
| S3 | Valid orders → 200, 19 stats, DB grew to 39 rows |
PASS |
| S4 | v_daily_revenue, order_items_2026, Orders, _private all 200 |
PASS |
| S5 | Rejected name → 400, not 500 | PASS |
| S6 | 'or'1, --x, /*x*/, WHERE 1=1, ;DROP all 400 |
PASS |
| S7 | Rejection logs before any decrypt — WARN Rejected table name 2ms after entry, no JDBC line |
PASS |
| S8 | Adjacent brain endpoints (GET /statistics/{id}, /high-cardinality) still 200 |
PASS |
| S9 | no-auth → 401; analyst on ungranted conn → 403; analyst injection on granted conn → 400 | PASS |
| S10 | GET returns all 39 stat entries consistently | PASS |
| S11 | Collect customers ×2 → row count stable at 15 (idempotent upsert) |
PASS |
One thing I double-checked rather than reported as a bug
analyst initially returned 200 on admin's connection, which looked like a broken-access gap — but connection_access_grant shows analyst holds an explicit CHAT_EDITOR grant on it, so 200 is correct. Against a connection with no grant, analyst gets 403. Authorization is intact.
Environment
- Health via logs (
Started DbaAgentApplication) + real requests (200/401), notdocker ps. - No UI:
collectColumnStatisticsexists inclient.jsbut zero components call it — API-only surface, so verification is curl + psql. - DB restored: both password hashes byte-identical to backup,
zz_qadropped, patched container removed.column_statistics(0→39) left intact — that's the endpoint's legitimate output on real tables, not test pollution.
Verdict: READY — zero blocking issues. The PR body should soften the DROP TABLE/; framing to single-statement quote-breakout, which is what's actually reachable through the endpoint.
…n 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 <noreply@anthropic.com>
The problem
Table and column names can't be bind parameters, so they're interpolated by hand and protected by quoting.
CardinalityEstimationServicequoted like this: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. SixString.formatsinks consume it, none with a bind parameter, fed by an unvalidated@PathVariable tableName.Hands-on QA against the running stack found that the
;-basedDROP TABLEpayload below is not reachable through this HTTP endpoint — Spring'sStrictHttpFirewallrejects a semicolon in a path segment with a 400 before the controller runs (ord;ers→ 400, whileord"ers→ 200). The direct-psqlreproduction is accurate for the quoter, but the HTTP exploit is single-statement quote breakout, which needs no semicolon:tableName=orders" AS x, same input[]— quote broke out,"orders" AS xexecuted as an aliased tablerequireSaferejected itThe vulnerability is real and the fix is correct; only the multi-statement
;framing below is overstated. Treat theDROP TABLEexample as a quoter-level demonstration, not an HTTP-reachable one.Reproduced against a live PostgreSQL
Payload as the
tableNamepath variable (isolatedzz_vschema, created and dropped for the test):Produced and executed:
No errors at all — count returned, table dropped, trailing select returned rows. The payload contains no
/, soStrictHttpFirewalldoesn't block it.No second line of defence on this path: it never reaches
QueryExecutorService, so nosetReadOnly(true), no policy service, no row cap.grep setReadOnlyoversrc/main/javareturns exactly one hit — not here.It was an outlier, not a convention
I swept every quoter rather than trusting the audit's count:
PostgresSamplingProvider:21MySQLSamplingProvider:21PostgresIntrospectionProvider:953SlackDailyDigestService:3028ColumnValueCollectionService:450CardinalityEstimationService:501The three provider classes all escape correctly; the service classes that reimplemented the primitive later both got it wrong — the same "clustered by when it was written" signature as the
BrainControllerauthorization misses.The fix
One shared
SqlIdentifierutility; both broken copies delegate to it. Centralised rather than patched in place — two copies of a security primitive is the defect, since one gets fixed and the other missed (same reason the SQL guard is kept mirrored Java↔JS).Second layer,
requireSafe, runs at the top ofcollectTableStatistics— beforegetDecryptedConnection. 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
[A-Za-z0-9_$.]+is deliberately permissive enough forv_daily_revenue,public.orders,tableName$— a validator that rejects real names is one the next person deletes.BrainControllernow returns 400, not 500: a bad request shouldn't read as an outage.Verification
DROP TABLEran; probe 1 → 0mvn compileThe fixed path's own error is the proof of why it's safe:
PostgreSQL read the whole payload as one table name, not three statements.
zz_vwas dropped; DB back to its prior state.A test I got wrong first
My first assertion was
!sql.matches(".*\"\\s*;.*")— it failed against correct output, because"";is an escaped quote followed by a semicolon inside the identifier: textually close to a terminator, semantically its opposite. That's precisely the confusion the vulnerable quoter made. It now asserts on the parse property, backed by the live result above.Residual work
ColumnValueCollectionServiceis fed catalog-derived names today so it wasn't exploitable — but it was one caller away, which is why it's fixed rather than noted.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.Write-up:
docs/security/2026-09-16-sql-injection-quote-identifier.md🤖 Generated with Claude Code