fix(security): honor the SQL permission denial processAST computes - #2202
fix(security): honor the SQL permission denial processAST computes#2202dawsontoth wants to merge 1 commit into
Conversation
`processAST` guarded its permission check with `permissionsCheck && permissionsCheck.length > 0`, but `checkASTPermissions` returns either null or a `PermissionResponseObject` — a class with no `length`. So the test evaluated `undefined > 0`, was always false, and every denial reaching that branch was computed correctly and then discarded. This survived because the branch is normally the second check rather than the first: a direct `sql` call arrives with `permissions_checked` already true from `chooseOperation`, whose own guard (`if (astPermCheck)`) is correct. The branch only executes when something re-parses a statement — a job dispatching its nested `search_operation` — which is exactly the path with no outer gate behind it. Now a bare truthiness test, matching the identical consumer in serverUtilities.ts. Tests drive processAST directly, including the already-checked and allowed paths so this cannot start denying statements that were always permitted; the two negative cases were confirmed to fail against the old guard. Found while reviewing #2173, which does not depend on this: its own gate refuses the export/write combination at the front door. Split out because the defect is pre-existing and affects all SQL authorization, not just that feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reviewer pointed out the dead `permissionsCheck.length > 0` guard is pre-existing and affects all SQL authorization, not just this feature, and asked for it as its own change with its own coverage rather than bolted onto an auth PR. Agreed — it is now #2202, with tests that drive processAST directly and cover the allowed and already-checked paths too, so it cannot start denying statements that were always permitted. This PR does not depend on it. The outer gate in serverUtilities refuses an out-of-scope job operation and sqlWriteScopeDenial refuses write SQL, both through correct truthiness tests, so export_local + DELETE is already refused at the front door. Left a note at the call site pointing at #2202 so the next reader does not re-derive it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request fixes a critical authorization bypass bug in sqlTranslator/index.ts where permission denials were being discarded because the code checked for a .length property on a PermissionResponseObject (which does not exist, causing the check to always evaluate to false). The fix replaces this check with a simple truthiness check on permissionsCheck. Additionally, a comprehensive suite of unit tests has been added in unitTests/sqlTranslator/processASTPermissions.test.js to verify the correct behavior of processAST under various permission scenarios. There are no review comments, and we have no additional feedback to provide as the changes are correct and well-tested.
|
Reviewed; no blockers found. |
…body
`checkASTPermissions` resolved the token-scope operation as
`jsonMessage.api_operation ?? jsonMessage.operation`. On the direct-SQL path
`jsonMessage` IS the client's request body, and that check is the ONLY gate
there — the `sql` branch of chooseOperation is mutually exclusive with its
verifyPerms call. So a caller could send
`{operation: 'sql', sql: '...', api_operation: '<whatever their scope allows>'}`
and run arbitrary SQL under it. Reproduced against this branch; the regression
test was confirmed to fail before the fix.
I introduced this in 11f2280, carrying a job's real operation to the nested
check on a request property. That is reverted. The operation now comes from an
explicit argument or the dispatched `json.operation`, never from a field on the
message — chooseOperation passes the operation it already resolved.
Stripping `api_operation` at the ingress points was the first fix I tried, and
it is the wrong shape: it leaves the check trusting a body property and makes
safety depend on every current and future entry point remembering to strip. The
property is gone instead.
The trade is that a job's SQL is checked as `sql` rather than as `export_local`.
That changes no outcome today, because the branch in processAST that would act
on the denial is dead — PermissionResponseObject has no `length`, so its guard
never fires (#2202). When #2202 makes that branch live it needs a carrier for
the job's operation that a client cannot forge; a request property is not one,
however carefully it is stripped. Recorded at both sites so the next reader does
not re-derive it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Moving this to draft. It is a real pre-existing defect and the fix stands, but it touches shared SQL authorization for every caller, not just the OIDC feature, so it needs the full harper-engineering-guidelines treatment on its own — cross-model reviews with the coverage reported in the description — rather than inheriting #2173's. That will be picked up separately. Nothing in #2173 depends on this landing: its own gate refuses the export/write combination at the front door, and it no longer attempts to carry a job's operation into the branch this fixes. One thing for whoever picks this up: making the 🤖 Generated with Claude Code |
Reverting the processAST guard to #2202 removed the one test that asserted this invariant is ENFORCED rather than merely computed, and the safety argument now rests entirely on chooseOperation's front-door gate — which had no enforcement test of its own. The rest of the scope suite only checks that verifyPermsAST returns a denial object, which is exactly how a dead consumer goes unnoticed. Three cases on the real dispatch path: an export job carrying nested write SQL outside the scope throws 403, an export whose own operation is outside the scope throws 403, and an in-scope export still runs — the last so this cannot pass by refusing everything. Confirmed they fail when the front-door gate is given the same dead-guard shape (`astPermCheck && astPermCheck.length > 0`) that made the inner branch a no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g in a comment The interaction between this PR and #2202 was documented only in prose, and the two can merge in either order. Removing the forgeable operation carrier leaves checkASTPermissions falling back to jsonMessage.operation, which at the processAST call site is the nested search_operation's own `sql` — so once #2202 makes that branch live, an export_local-scoped token 403s on its own export job. Added a tripwire that drives evaluateSQL with the exact shape export.ts:363 dispatches and asserts an in-scope export is not refused by the permission gate. It passes today and was confirmed to fail with #2202's one-line change applied on top, so whichever PR lands second turns CI red rather than shipping a silently broken feature. The comment on it says what to do when it fires — supply the job's real operation through a carrier a client cannot set, rather than relaxing the scope check. Preferred this over making apiOperation a required parameter: that turns the missing carrier into a compile error the next author satisfies by passing jsonMessage.operation, which is the wrong value and compiles clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Ordering note for whoever picks this up — #2173 now carries a test that will go red when this lands, deliberately. Making the The failing test is The two PRs can merge in either order; whichever is second goes red rather than silently shipping an export-scoped token that 403s on its own export. 🤖 Generated with Claude Code |
The tripwire compared against 403, which is UNAUTHORIZED_RESPONSE in the very file it exists to watch — so changing that constant would leave it green while the refusal it guards against still happened. A tripwire must not depend on a constant its own target owns. Now asserted by shape: the permission path is the only one that calls back with a bare numeric status, while every other failure forwards an Error. evaluateSQL drops the second callback argument on error, so the denial object never reaches the test and the number is the whole signal — which also rules out asserting on the PermissionResponseObject shape directly. Verified across four states: passes today, fails with #2202's guard applied, still fails with #2202 applied AND the status changed to 401 (the case the old assertion missed), and passes again restored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reviewer pointed out the dead `permissionsCheck.length > 0` guard is pre-existing and affects all SQL authorization, not just this feature, and asked for it as its own change with its own coverage rather than bolted onto an auth PR. Agreed — it is now #2202, with tests that drive processAST directly and cover the allowed and already-checked paths too, so it cannot start denying statements that were always permitted. This PR does not depend on it. The outer gate in serverUtilities refuses an out-of-scope job operation and sqlWriteScopeDenial refuses write SQL, both through correct truthiness tests, so export_local + DELETE is already refused at the front door. Left a note at the call site pointing at #2202 so the next reader does not re-derive it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…body
`checkASTPermissions` resolved the token-scope operation as
`jsonMessage.api_operation ?? jsonMessage.operation`. On the direct-SQL path
`jsonMessage` IS the client's request body, and that check is the ONLY gate
there — the `sql` branch of chooseOperation is mutually exclusive with its
verifyPerms call. So a caller could send
`{operation: 'sql', sql: '...', api_operation: '<whatever their scope allows>'}`
and run arbitrary SQL under it. Reproduced against this branch; the regression
test was confirmed to fail before the fix.
I introduced this in 11f2280, carrying a job's real operation to the nested
check on a request property. That is reverted. The operation now comes from an
explicit argument or the dispatched `json.operation`, never from a field on the
message — chooseOperation passes the operation it already resolved.
Stripping `api_operation` at the ingress points was the first fix I tried, and
it is the wrong shape: it leaves the check trusting a body property and makes
safety depend on every current and future entry point remembering to strip. The
property is gone instead.
The trade is that a job's SQL is checked as `sql` rather than as `export_local`.
That changes no outcome today, because the branch in processAST that would act
on the denial is dead — PermissionResponseObject has no `length`, so its guard
never fires (#2202). When #2202 makes that branch live it needs a carrier for
the job's operation that a client cannot forge; a request property is not one,
however carefully it is stripped. Recorded at both sites so the next reader does
not re-derive it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverting the processAST guard to #2202 removed the one test that asserted this invariant is ENFORCED rather than merely computed, and the safety argument now rests entirely on chooseOperation's front-door gate — which had no enforcement test of its own. The rest of the scope suite only checks that verifyPermsAST returns a denial object, which is exactly how a dead consumer goes unnoticed. Three cases on the real dispatch path: an export job carrying nested write SQL outside the scope throws 403, an export whose own operation is outside the scope throws 403, and an in-scope export still runs — the last so this cannot pass by refusing everything. Confirmed they fail when the front-door gate is given the same dead-guard shape (`astPermCheck && astPermCheck.length > 0`) that made the inner branch a no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g in a comment The interaction between this PR and #2202 was documented only in prose, and the two can merge in either order. Removing the forgeable operation carrier leaves checkASTPermissions falling back to jsonMessage.operation, which at the processAST call site is the nested search_operation's own `sql` — so once #2202 makes that branch live, an export_local-scoped token 403s on its own export job. Added a tripwire that drives evaluateSQL with the exact shape export.ts:363 dispatches and asserts an in-scope export is not refused by the permission gate. It passes today and was confirmed to fail with #2202's one-line change applied on top, so whichever PR lands second turns CI red rather than shipping a silently broken feature. The comment on it says what to do when it fires — supply the job's real operation through a carrier a client cannot set, rather than relaxing the scope check. Preferred this over making apiOperation a required parameter: that turns the missing carrier into a compile error the next author satisfies by passing jsonMessage.operation, which is the wrong value and compiles clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tripwire compared against 403, which is UNAUTHORIZED_RESPONSE in the very file it exists to watch — so changing that constant would leave it green while the refusal it guards against still happened. A tripwire must not depend on a constant its own target owns. Now asserted by shape: the permission path is the only one that calls back with a bare numeric status, while every other failure forwards an Error. evaluateSQL drops the second callback argument on error, so the denial object never reaches the test and the number is the whole signal — which also rules out asserting on the PermissionResponseObject shape directly. Verified across four states: passes today, fails with #2202's guard applied, still fails with #2202 applied AND the status changed to 401 (the case the old assertion missed), and passes again restored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split out of #2173 at a reviewer's request — the defect is pre-existing and affects all SQL authorization, not just that feature.
The bug
sqlTranslator/index.tsguarded its permission check with:checkASTPermissionsreturns eithernullor aPermissionResponseObject. That class haserror,unauthorized_access, andinvalid_schema_items— nolength. So the condition evaluatedundefined > 0, was always false, and the denial was computed correctly and then dropped on the floor.Why nobody noticed
This branch is normally the second permission check, not the first. A direct
sqlcall reachesprocessASTwithpermissions_checkedalready true, set bychooseOperation— whose own consumer of the same function uses a correct bare truthiness test (server/serverHelpers/serverUtilities.ts). So in the common path the branch never runs.It runs when something re-parses a statement: a job dispatches its nested
search_operation, which carries noparsed_sql_object, soevaluateSQLre-parses withpermissions_checked === false. That is precisely the path with no outer gate behind it.The fix
A bare truthiness test, matching the identical consumer in
serverUtilities.ts.Tests
unitTests/sqlTranslator/processASTPermissions.test.jsdrivesprocessASTdirectly rather than asserting that a denial is merely computed — which is how a dead consumer went unnoticed in the first place. Four cases:403with the permission responseThe two negative cases were confirmed to fail against the old guard before being kept (2 passing / 2 failing when reverted, 4 passing with the fix).
Scope
No behavior change for any statement that was correctly authorized. The change is that a denial computed on the re-parse path is now acted upon instead of ignored.
🤖 Generated with Claude Code