Repository navigation
security(query): RLS predicate is spliced into rendered SQL by substring match — a crafted alias deletes it (and max_rows with it) #322
Description
Activity
- addedbugSomething isn't workingSomething isn't workingarea/queryStructured query AST, SQL builderStructured query AST, SQL builderarea/policyAccess control policies (Hasura-style)Access control policies (Hasura-style)securitySecurity-sensitive issue or fixSecurity-sensitive issue or fix
on Jun 10, 2026 This is not latent, and the impact is understated. Please re-prioritize off Backlog. Verified end-to-end during the #457 review — policy →
query.Build→InjectPermissionFilters→ executed againstclickhouse local.The premise is false
Latent (identifiers are gated elsewhere on the structured path)
Identifiers are not gated on the structured path. Two caller-controlled inputs reach the emitted SQL with only a
?check:- Aggregation alias —
internal/query/builder.go:237-241checkschsql.BindUnsafe(a.Alias)and nothing else. The comment there reasons "the alias is backtick-quoted byaggregationExpr, so any legal ClickHouse name is safe" — correct for injection, but the quoting happens before the string-splice, andstrings.Replacedoesn't know which bytes are inside quotes. - ORDER BY on a non-schema column —
builder.go:253-262deliberately allows any unrecognized column through as an "alias reference," again with only aBindUnsafecheck.
The impact is a complete row-filter bypass, not a misplacement
a crafted alias or column name containing
WHEREorLIMITshifts the match, misplacing the injected predicateIt doesn't misplace the predicate — it deletes it. The predicate lands inside the backtick-quoted alias, and the query executes with no
WHEREclause:POST /v1/query?table=clicks {"columns":["user_id"],"group_by":["user_id"], "aggregations":[{"fn":"any","column":"email","alias":"e WHERE z"}]}SELECT `user_id`, any(`email`) AS `e WHERE (1 = 0) AND z` FROM `clicks` GROUP BY `user_id` LIMIT 10000
Against a 3-tenant table that returns every tenant's rows; the correctly-formed query returns none.
/v1/queryis not admin-gated (internal/api/router.go:146-148), so any role with aselectentry on the table can reach it. Column allow/deny still applies — this is a pure row bypass.The detail that decides exploitability
Only a predicate containing no backtick survives inside a quoted alias as valid SQL. Every ordinary predicate (
`tenant_id` = ?) injects backticks that terminate the identifier →SYNTAX_ERROR, no bypass. So the attack works exactly when a policy filter fails closed, because1 = 0is the only backtick-freeWhereClausethe engine emits.Control cases:
claim present + evil alias -> AS `x WHERE (`tenant_id` = ?) AND y` -> SYNTAX_ERROR (500). No bypass. claim absent + evil alias -> valid SQL, filter gone. -> full table.That coupling raises the priority beyond this issue's own merits: fail-closed predicates are becoming more common, not less. #358 introduced
1 = 0for_in; #457 extends it to_eq/_neq/_gt/_lton an unresolvable claim. Each such change widens this issue's reachable surface. For_eqspecifically, #457 converts what was a bounded leak (tenant_id = '', empty-tenant rows only) into an unbounded one.Suggested handling
- Short term (proposed in fix(policy): fail closed on unresolvable row-filter claims #457): reject an alias or ORDER BY alias-reference matching
(?i)\s(where|group by|order by|limit)\s, next to the existingBindUnsafecall. ~5 lines, no product decision. - Proper fix (this issue's stated scope): emit the predicate structurally from
Buildinstead of splicing rendered SQL. That also resolves thefindInsertPointvariant atbuilder.go:509-517, which has the same shape for the no-WHEREbranch.
- Aggregation alias —
- changed the title
[-]security(query): InjectPermissionFilters splices the RLS predicate by first-substring match — crafted alias/column misplaces it[/-][+]security(query): RLS predicate is spliced into rendered SQL by substring match — a crafted alias deletes it (and max_rows with it)[/+]on Aug 12, 2026 - moved this from Backlog to In progress in WaveHouse Task Board
on Aug 13, 2026 Severity correction — this issue (and my earlier comment) understates it in one specific way.
The write-up frames
"Total order by region"as a false positive of the interim guard, i.e. a harmless alias getting a needless 400. Onmain, where there is no guard, that same alias is a full leak.findInsertPointuppercases before matching, so an alias containingorder by,group by, orlimitin any casing captures the splice. Verified against current main (ea4fbf6):alias "Total order by region" -> SELECT groupArray(`email`) AS `Total WHERE 1 = 0 order by region` FROM `clicks` LIMIT 10000 alias "monthly count where paid" -> SELECT groupArray(`email`) AS `monthly count where paid` FROM `clicks` WHERE 1 = 0 LIMIT 10000The first has no WHERE clause at all and returns every tenant. The second is fine, because the
" WHERE "splice path uses a byte-exactstrings.Containsand lowercasewheredoes not match it — only thefindInsertPointpath is case-insensitive.Two consequences worth recording:
This is reachable by accident, not only by attack.
"Total order by region"is an ordinary analyst label. Any deployment whose callers alias aggregations in natural language can trip it without anyone trying.It is live on
maintoday, independent of #457.1 = 0is already produced there by_inagainst an absent or empty claim (#224 behaviour), so any deployment using an_inrow filter is exposed now. #457 widens the trigger from_in-only to every operator; it does not create it.That moves this from "brittle by construction, latent" — the original framing — to a live, accidentally-reachable row-security bypass in shipped code. Worth reflecting in whatever priority this carries, and worth a line in the release notes when the fix ships with #457.
One more dimension, and it is the one that matters most for detection.
Both splice branches leak, and the
strings.Replace(" WHERE ", …)branch is the nastier of the two. It replaces the first occurrence, and the SELECT list precedes the real WHERE — so a hostile alias captures the splice even when the caller supplies filters. Verified on main:SELECT groupArray(`email`) AS `e WHERE (1 = 0) AND z` FROM `clicks` WHERE `page` = ? LIMIT 10000 params=[/home]The query still carries a plausible
WHERE \page` = ?. Nothing about it looks wrong in a log, inquery_log`, or to anyone eyeballing slow queries — the row-security predicate is simply gone, hidden inside a column alias. The previously-recorded case at least produced a query with no WHERE clause, which is visibly missing something.Taken with the earlier note that a benign analyst label like
Total order by regiontriggers it, the accurate characterisation is:- reachable without a crafted identifier — an ordinary aggregation alias is enough
- reachable with caller filters present, where the emitted SQL looks correctly scoped
- live on
maintoday for any deployment using an_inrow filter (Policy filter/check:_inaccepted but never enforced;checkhonors only_eq#224 already emits1 = 0there) - silent in both directions — no error, no anomalous SQL shape, no signal
There is also a non-leak variant with no hostile input at all: a legitimately-named column containing a length-changing rune (e.g.
ıı) plus a role withmax_rowsdrifts the byte offset into the table name, producingSYNTAX_ERROR(a caller-triggerable 500) and silently dropping themax_rowscap in the same query.All of it is fixed by the structural change on
row-filter-claim-templates; recording it because the issue body still describes the impact as "misplaces the predicate" and someone writing this up later should not have to re-derive how bad it actually was.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Area: query · policy — security (predicate-injection fragility) · found via pre-launch audit
Expected: the RLS predicate is emitted as part of the query's structure, not spliced into rendered SQL.
Actual:
InjectPermissionFilterssplices the predicate into already-rendered SQL by first-substring match (onWHERE, with the insert point located viaGROUP BY/ORDER BY/LIMIT). A caller-controlled identifier containing one of those keywords captures the splice.Impact — corrected 2026-08-12 (see the comment below): not "misplaces the predicate" and not latent. A crafted aggregation alias deletes the row filter, and the resulting query is valid SQL that returns the whole table. Verified end-to-end against ClickHouse. The original "identifiers are gated elsewhere on the structured path" premise is false — aggregation aliases and non-schema ORDER BY references reach the SQL with only a
?check.Scope
The fix is one refactor with one principle:
Buildalready receivesperms— letBuilduse it, and delete everything that edits its output afterward.internal/api/structured_query.go:117-147currently reads:Both post-processors pull fields off the same
permsthatBuildwas already handed, then text-edit the SQL it just produced. Both then hit the same class of bug.Build. Appendperms.WhereClausetowherePartsin the WHERE assembly atinternal/query/builder.go:95-102. Mind param ordering: the predicate's placeholders must line up relative to SELECT-list placeholders (time bucketing) and the caller's own filters.Build. Foldperms.MaxRowsinto themaxRowscomputation atbuilder.go:130-138before the LIMIT is written. This is behavior-preserving: today you getmin(q.Limit, defaultMaxRows)fromBuildand thenApplyMaxRowslowers it toperms.MaxRowsif smaller — identical tomin(q.Limit, defaultMaxRows, perms.MaxRows).InjectPermissionFilters,findInsertPoint,ApplyMaxRows, andspliceKeywordRe(plus the two guard call sites atbuilder.go:259and:283).What this closes
InjectPermissionFilters/findInsertPointspliceKeywordRevsstrings.ToUpperdisagreement"Total order by region"→ 400)spliceKeywordReover-inclusivevalidateColumn-failure branch)findInsertPoint:534max_rowscap silently no-opsApplyMaxRows:164The
max_rowsdefect, since it's easy to missApplyMaxRowsuppercases the SQL to locate" LIMIT ", then indexes into the original string with that offset.strings.ToUpperis not length-preserving in UTF-8 (31 runes change byte length), so a column namedıımakesstrconv.Atoireceive"T 10000", the parse fails, and the policy's row cap is silently not applied. A policy control failing open, unrelated to the splice — and the one defect here that would survive a fix scoped only toInjectPermissionFilters. That is the reason it is folded into this issue rather than filed separately: splitting them invites a fix that lands the splice refactor and leavesApplyMaxRowsbehind.max_result_rows(internal/clickhouse/ch_settings.go:49) is the surviving backstop, and it does not stop agroupArrayexfiltration, which returns a single row.Related
1 = 0reachable for every unresolvable_eq/_neq/_gt/_lt(previously only_in), which is what turned this from theoretical into exploitable; it also carries the interimspliceKeywordReguard this issue removes.Implementation notes
Everything below was established while reviewing #457. It is here so this issue can be picked up cold.
Param ordering is not a risk —
paramsis empty when the WHERE is assembledThe scope box above says "mind param ordering". It was traced, and the answer is that there is nothing to be careful about:
var params []anyis declared at the top ofBuild, and nothing appends to it beforebuilder.go:99(params = append(params, whereParams...)). Building the row projection and the aggregations only appends toselectParts. Every bound value in the query — including the time-range and bucket params atbuilder.go:348/:358/:366— is produced insidebuildWhere.So
paramsis empty at the point the WHERE clause is assembled. Put the policy predicate first inwherePartsand its params first inparams, which reproduces exactly the orderInjectPermissionFiltersproduces today (result.Params = append(whereParams, result.Params...)). No SELECT-list placeholder can be shifted, because there aren't any.Blast radius
InjectPermissionFiltersandApplyMaxRowshave exactly one call site each, both ininternal/api/structured_query.go(:141and:145).findInsertPointandspliceKeywordReare used only insideinternal/query/builder.go. The deletion is contained to those two files plus tests.Why the interim guard is being deleted rather than fixed
spliceKeywordRe(added in #457 as a stopgap) is bypassable. Go'sregexp(?i)uses simple case folding, which does not foldı(U+0131) toi.findInsertPointuppercases withstrings.ToUpper, which does mapıtoI. So an alias ofe lımıt zpasses the guard and still reads as" LIMIT "to the splice:Executed on
clickhouse localagainst a three-tenant table that returns every tenant's rows, where the correctly-formed query returns none. A differential fuzz over 400k aliases found 227 evading variants, 209 of which executed with a 100% leak rate. Exactly two runes in Unicode uppercase to ASCII (ı→I,ſ→S) and onlyLIMITcontains anI, so it is a single-rune hole — but one is enough.The guard is also over-inclusive: it rejects
"Total order by region", lowercase" where "(which the byte-exactstrings.Containssplice never matched), and keywords padded with tabs. Being wrong in both directions is the evidence it approximates the splice rather than matching it, which is the argument for removing the mechanism instead of tuning the regex.Test design — the existing tests cannot catch this class
This is the part most likely to be got wrong.
TestBuild_RejectsSpliceKeywordAliasasserts thatBuildreturns an error. That shape of assertion can only ever test the guard, never the property the guard exists to protect — which is why the U+0131 case slipped through a green suite.The regression test must assert the outcome: given a hostile alias and a role whose filter fails closed, the final SQL still contains the policy predicate as a real predicate. Include a
ıcase explicitly.Tests to delete with the guard:
TestBuild_RejectsSpliceKeywordAliasTestBuild_RejectsSpliceKeywordOrderRefTests that must still pass unchanged (they assert quoting containment, not rejection):
TestBuild_AggregationAliasQuotedAndContainedTestIntegration_AliasInjectionContainedAlso add a
max_rowscase: a column namedııcurrently makesApplyMaxRowssilently skip the cap. After the refactor the emitted LIMIT should bemin(q.Limit, defaultMaxRows, perms.MaxRows)regardless of identifier content.Verification recipe
All three defects here were confirmed this way, and it is the fastest way to prove the fix:
EvaluateyieldsWhereClause == "1 = 0".policy.Evaluate→query.Build→ (today)query.InjectPermissionFilters— and print the SQL.clickhouse localon a small multi-tenant table.A leak shows as rows from every tenant; a correct result is empty. The control case is worth keeping: with the claim present, the predicate contains backticks, which break the quoted alias and make ClickHouse return
SYNTAX_ERROR— that asymmetry is why only the fail-closed predicate is exploitable.Cache keys are unaffected
queryCacheKey(result.SQL, result.Params)is computed atinternal/api/structured_query.go:149, afterBuildreturns. Moving the predicate insideBuilddoes not change what is hashed or when. (#261 is adjacent but independent:resolveFiltersiterates a map, so a multi-column policy filter produces a non-deterministic clause order and therefore a non-deterministic cache key. That is not made better or worse by this change.)Sequencing
This work sits on top of #457, which is where
spliceKeywordReand the extra1 = 0cases come from. Land #457 first, then this.One conditional cleanup: if #457 ends up documenting the guard's identifier restriction in
docs/src/content/docs/api.md:492anddocs/src/content/docs/architecture.md:153, this issue must revert that — deleting the guard restores the original contract ("any ClickHouse-legal name is accepted; the one exception is a name containing?"). If #457 does not add that caveat, there is nothing to do in those files.From WaveHouse-Stats pre-launch security audit (
WAVEHOUSE-FEEDBACK.mddogfooding), audited dev60fed15(2026-06-10). Filed via /pm-triage. Impact corrected and scope expanded 2026-08-12, implementation notes added 2026-08-13, during the #457 review.