Skip to content

fix(security): replace naive SQL validator with sqlglot AST check - #23

Merged
gordonmurray merged 1 commit into
mainfrom
fix/sqlglot-validator
Apr 15, 2026
Merged

gordonmurray merged 1 commit into
mainfrom
fix/sqlglot-validator

Conversation

@gordonmurray

Copy link
Copy Markdown
Owner

Summary

The previous validator in /api/query uppercased the whole query and blocked it if any of DELETE / DROP / INSERT / UPDATE / CREATE / ALTER appeared as a substring. That:

  • over-blocked legitimate queries — SELECT * FROM update_log, WHERE status = 'create'
  • was bypassable — comments, unusual whitespace, or string literals could slip through

This PR replaces it with a sqlglot-based AST validator.

How it works

  • Parses the SQL with sqlglot.parse(..., dialect="duckdb") and rejects parse errors, empty input, and multi-statement inputs
  • Accepts only SELECT / WITH / UNION / INTERSECT / EXCEPT / VALUES at the top level
  • Walks the entire tree and rejects any Insert / Update / Delete / Create / Drop / Alter / TruncateTable / Commit / Rollback / Transaction / Use / Attach / Detach / Merge / Copy / Command node, even nested in subqueries or CTEs
  • Injects an outer LIMIT via AST manipulation if the user didn't supply one — replaces the old _limit_query string-append

Also cleans up the /api/query error flow so 400s raised by the validator are re-raised unchanged rather than re-wrapped as "Query execution failed: 400: ...".

Adds sqlglot==30.4.3 to backend/requirements.txt.

Verification (done locally against the demo stack)

Scenario Before After
Happy path query 37,537 rows 37,537 rows
WITH update_log AS (...) SELECT * FROM update_log rejected (false positive) accepted
SELECT 1 WHERE 'create' = 'create' rejected (false positive) accepted
DROP TABLE x 400 400
INSERT INTO x VALUES (1) 400 400
UPDATE x SET a=1 400 400
DELETE FROM x 400 400
CREATE TABLE y AS SELECT 1 400 400
ALTER TABLE x ... 400 400
ATTACH 'evil.db' accepted (old bypass) 400
COPY x TO 's3://evil' accepted (old bypass) 400
SELECT 1; DROP TABLE x rejected (substring match) 400 (multi-statement)
/*DROP*/ SELECT 1 rejected (substring match) accepted (comment stripped)
SELECT FROM (malformed) accepted → DuckDB error 400 (parse error)
Error message format Query execution failed: 400: Only SELECT... Only SELECT queries are allowed (got DROP)
rowLimit=3, no explicit LIMIT works works (3 rows, truncated=true)
User LIMIT 2, rowLimit=10 works works (2 rows, user LIMIT preserved)

Test plan for reviewer

  • docker compose down -v && docker compose up --build
  • Click through the five Sample Queries — all succeed
  • Paste DROP TABLE x and run — expect 400 Only SELECT queries are allowed (got DROP)
  • Paste SELECT * FROM update_log (with a real CTE) — expect success
  • CI goes green (lint + two docker builds)

Closes #6

The previous validator uppercased the whole query and blocked it if any
of DELETE/DROP/INSERT/UPDATE/CREATE/ALTER appeared as a substring. That
rejected legitimate queries (SELECT * FROM update_log, WHERE x = 'create')
and could be bypassed with comments or unusual whitespace.

This PR replaces it with a sqlglot-based AST validator that:

- parses with the DuckDB dialect and rejects parse errors, empty input,
  and multi-statement inputs
- accepts only SELECT / WITH / UNION / INTERSECT / EXCEPT / VALUES at
  the top level
- walks the tree and rejects any Insert / Update / Delete / Create /
  Drop / Alter / TruncateTable / Commit / Rollback / Transaction / Use
  / Attach / Detach / Merge / Copy / Command nested anywhere
- injects an outer LIMIT via AST manipulation if the user didn't supply
  one, replacing the old _limit_query string append

Also cleans up the /api/query error flow so 400s raised by the
validator are re-raised unchanged rather than re-wrapped inside a
'Query execution failed: 400: ...' string.

Adds sqlglot==30.4.3 to backend/requirements.txt.

Verified against the demo stack:
  - happy path: 37,537 rows
  - update_log / literal 'create' queries: accepted (false-positive fix)
  - DROP / INSERT / UPDATE / DELETE / CREATE / ALTER / ATTACH / COPY: 400
  - SELECT 1; DROP TABLE x: 400 (multi-statement)
  - /*DROP*/ SELECT 1: accepted (comment stripped at parse)
  - SELECT FROM: 400 (parse error)
  - rowLimit honoured when no explicit LIMIT; user LIMIT preserved

Closes #6
@gordonmurray
gordonmurray merged commit cab3ba3 into main Apr 15, 2026
3 checks passed
@gordonmurray
gordonmurray deleted the fix/sqlglot-validator branch April 15, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace substring-based destructive keyword check with AST validation

1 participant