fix(security): enforce the chat schema allowlist over the whole statement - #70
fix(security): enforce the chat schema allowlist over the whole statement#70geekypunk wants to merge 3 commits into
Conversation
…ment
The schema allowlist was implemented as a partial walk. enforceAllowedSchemas
visited only getFromItem() and getJoins() of the outermost PlainSelect, so a
table reached through any other syntax position was never enumerated -- and an
allowlist that does not enumerate a reference implicitly permits it.
Worse, the dispatch gated every check on
if (parsed instanceof Select select && select.getPlainSelect() != null)
A UNION parses as a Select whose body is a SetOperationList, so getPlainSelect()
returns null, the whole block is skipped, and control falls through to
QueryGuardDecision.allow(). That failed open on statement shape: it dropped the
protected-column and wildcard inspection too, not just the schema check.
Four reachable bypasses, each now covered by a test that failed before this
change with "Expected UserDataAccessPolicyException to be thrown, but nothing
was thrown" -- i.e. the query was allowed:
SELECT id FROM marts.orders WHERE total = (SELECT MAX(salary) FROM hr.salaries)
SELECT id FROM marts.orders UNION ALL SELECT ssn FROM hr.salaries
WITH leaked AS (SELECT ssn FROM hr.salaries) SELECT * FROM leaked
SELECT 1 AS x UNION ALL SELECT email FROM customer_profiles
Schema enforcement now runs JSqlParser's TablesNamesFinder over the parsed
statement, which is exhaustive by construction rather than by remembering to
handle each syntax form, and column inspection runs over every PlainSelect in
the statement -- set-operation branches, parenthesised selects and CTE bodies
included. The old partial walker is deleted rather than left in place, so it
cannot be wired back up.
Unqualified names stay unchecked exactly as before: they resolve through the
session search_path, and CTE names are not tables.
Note the pre-existing catch(Exception) fallback is not a safety net for this.
It only fires when parsing throws, and every payload above parses cleanly.
Verified: 13/13 in UserDataAccessPolicyServiceTest (4 new, 9 pre-existing),
80/80 across the policy and guard suites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on cannot see
The first commit fixed schema scoping completely but left column protection a
partial walk, and the PR text claimed otherwise. collectPlainSelects descends
into CTE bodies, set-operation branches and parenthesised selects, but never
into a select nested inside a PlainSelect's own FROM/JOIN/WHERE/HAVING. Two
payloads still reached a protected column:
SELECT t.email FROM (SELECT email FROM customer_profiles) t
SELECT id FROM orders WHERE id IN (SELECT email FROM customer_profiles)
Both were allowed. Same category error the first commit set out to remove: an
allowlist enforced by a walk is only as complete as the walk.
Rather than attempt an exhaustive traversal of arbitrary expression trees --
getting that wrong is what caused this in the first place -- the two views are
compared. TablesNamesFinder enumerates every table in the statement and is
exhaustive by construction; collectDirectTables reports what the column
inspection actually examined. When a protected table appears in the first set
and not the second, the query is refused.
That inverts the failure direction. A syntax form nobody enumerated now costs a
conservative block instead of a silent permit, and any future parser feature
inherits the safe default without anyone remembering to handle it.
The tradeoff is deliberate: some safe nested aggregates over a protected table
are now blocked. Refusing costs a rejected query; allowing costs the data.
Verified: 15/15 in UserDataAccessPolicyServiceTest (2 new, both watched failing
first). Regression baseline on the same suite selection is unchanged --
8 failures / 16 errors before and after, all pre-existing, with the 2 added
tests passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-review of my own PR found it incomplete — pushed a second commitCorrection to the original description. It said column inspection "runs over every SELECT t.email FROM (SELECT email FROM customer_profiles) t
SELECT id FROM orders WHERE id IN (SELECT email FROM customer_profiles)That is the same category error this PR set out to remove. The first commit replaced a partial walk with a less partial walk and described it as total. Both payloads also work against Second commit (
When a protected table appears in the first set and not the second, the query is refused. A syntax form nobody enumerated now costs a conservative block instead of a silent permit, and future parser features inherit the safe default automatically. Deliberate tradeoff: some safe nested aggregates over a protected table are now blocked. Refusing costs a rejected query; allowing costs the data. Verification
The out-of-scope note from the original description still stands: non- |
…s across schemas The previous commit matched a protected table against a query's tables by adding both the qualified name and its bare part to each side, then intersecting. That collapsed public.customer_profiles and marts.customer_profiles to the same key, so protecting one refused queries against the other -- with a message naming a table the user never referenced. This product added multi-schema support in #55 and an acme_erp fixture with crm/sales/finance/hr/inventory in #65, so same-named tables across schemas are the expected shape here, not a corner case. Over-blocking is the safe direction, which is exactly why it would have survived review and surfaced later as unexplained refusals. Matching is now asymmetric, because the two sides carry different information. ConnectionChatAccessPolicyService.qualifyTable() drops the schema when it is "public", so a bare PROTECTED name means public.<table> -- it is not unknown. A bare REFERENCE in a query is genuinely unknown: it resolves through the session search_path and could be any schema. reference unqualified -> match on bare name (ambiguous, so block) protected public -> a qualified reference must actually say public both qualified -> exact match Every bypass stays closed: an unqualified reference to a protected table is still refused, and hr.salaries still matches a bare "salaries". Also replaces a characterization test asserting the opposite. It was written before qualifyTable's public-collapsing was discovered and encoded the wrong belief that a bare protected name is ambiguous; the case genuinely worth pinning is a bare reference, which it now covers. Verified: 17/17 in UserDataAccessPolicyServiceTest. Regression baseline on the same suite selection unchanged at 8 failures / 16 errors (414 run vs 412 before, the delta being these two tests passing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third review pass — found a false positive in my own fail-closed check, fixed in
|
Authorization bypass in the per-user chat access policy. Live on
mainsince #65 (0aa2b77).The flaw
The schema allowlist was a partial walk.
enforceAllowedSchemasvisited onlygetFromItem()andgetJoins()of the outermostPlainSelect— so a table reached through any other syntax position was never enumerated. An allowlist that doesn't enumerate a reference implicitly permits it.Worse, the dispatch gated everything on:
A
UNIONparses as aSelectwhose body is aSetOperationList, sogetPlainSelect()returns null, the entire block is skipped, and control falls through toQueryGuardDecision.allow(). That fails open on statement shape — dropping the protected-column and wildcard inspection too, not just the schema check.Four reachable bypasses
Each has a test that failed before this change with
Expected UserDataAccessPolicyException to be thrown, but nothing was thrown— meaning the query was allowed:SELECT id FROM marts.orders WHERE total = (SELECT MAX(salary) FROM hr.salaries)SELECT id FROM marts.orders UNION ALL SELECT ssn FROM hr.salariesWITH leaked AS (SELECT ssn FROM hr.salaries) SELECT * FROM leakedSELECT 1 AS x UNION ALL SELECT email FROM customer_profilesActor: any non-admin with a restricted schema scope who can submit SQL through chat. Read-only enforcement gives no cover — reading another schema is the harm.
Fix
TablesNamesFinderover the parsed statement, which is exhaustive by construction rather than by remembering to handle each syntax form.PlainSelectin the statement — set-operation branches, parenthesised selects, and CTE bodies.Unqualified names stay unchecked exactly as before — they resolve through the session
search_path, and CTE names aren't tables. That keeps the change scoped to the bypass rather than tightening unrelated behaviour.Verification
UserDataAccessPolicyServiceTest13/13 (4 new + 9 pre-existing)McpSqlGuardServiceTest,QueryExecutionPolicyServiceTest,ConnectionChatAccessPolicyServiceTest,ExplainControllerPolicyTest,ChatScopeGuardServiceTestand others, no regressionsNotes for the reviewer
catch (Exception)fallback (containsDangerousProtectedReference) is not a safety net here: it only fires when parsing throws, and every payload above parses cleanly. It guards malformed SQL, not well-formed attacks.SELECTstatements still fall through toallow()in this method. Mutation is blocked upstream byMcpSqlGuardService+QueryExecutionContext, so this isn't currently exploitable — but the policy engine failing open on any shape it can't analyse is the same pattern that produced this bug, and inverting it to fail closed deserves its own change with its own tests.🤖 Generated with Claude Code