Read-Only SQL Endpoint via Regex Validator
For low-stakes internal SQL escape hatches (Dashboard, MCP-tool, Companion-Service), a regex-based pre-check is a 95% solution that takes 5 minutes to implement. For high-stakes write-control, use a proper SQL parser.
When to use
- Building a
/sql?q=<base64-SELECT> endpoint for internal tools
- MCP-server tool that takes user-supplied SQL
- Dashboard query box that lets analysts run ad-hoc SELECTs
- Companion service that wants to expose raw query capability without full DB-credentials to the caller
When NOT to use
- Write-allowed endpoints — never expose mutation via raw SQL; parameterize specific updates instead
- Public/Internet-facing endpoints — use a real SQL parser (sqlparse, pglast) and probably an SQL-permissions-restricted DB role
- Production systems where a malicious SELECT could affect performance (long table scan,
pg_sleep, ...) — add timeout + query-cost budget on top
- Stored-procedure invocation that has side-effects despite being syntactically read-only
The validator (Python, 30 lines)
import re
def _is_readonly_sql(sql: str) -> bool:
"""Validate that SQL is read-only (SELECT/WITH/EXPLAIN only).
Denies: INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, CALL.
Allows: SELECT, WITH (CTE), EXPLAIN, ANALYZE.
Naive — catches obvious violations but not all corner cases.
For high-stakes use, replace with sqlparse-based AST walk.
"""
# Strip comments BEFORE matching — prevents comment-smuggle attacks
sql_clean = re.sub(r"--.*?$", "", sql, flags=re.MULTILINE)
sql_clean = re.sub(r"/\*.*?\*/", "", sql_clean, flags=re.DOTALL)
# Normalize whitespace + uppercase
sql_clean = re.sub(r"\s+", " ", sql_clean).strip().upper()
denied = [
r"^\s*INSERT\s", r"^\s*UPDATE\s", r"^\s*DELETE\s",
r"^\s*DROP\s", r"^\s*CREATE\s", r"^\s*ALTER\s",
r"^\s*TRUNCATE\s", r"^\s*CALL\s",
]
if any(re.search(p, sql_clean) for p in denied):
return False
allowed = [r"^SELECT\s", r"^WITH\s", r"^EXPLAIN\s", r"^ANALYZE\s"]
return any(re.search(p, sql_clean) for p in allowed)
Endpoint wrapper (FastAPI)
@app.get("/sql")
async def sql_endpoint(q: str = Query(..., description="Base64-encoded read-only SQL")):
try:
sql = base64.b64decode(q).decode("utf-8")
except Exception as e:
return JSONResponse({"_error": "invalid_query_encoding", "detail": str(e)}, status_code=400)
if not _is_readonly_sql(sql):
return JSONResponse(
{"_error": "forbidden_query", "detail": "Only SELECT/WITH/EXPLAIN allowed"},
status_code=403,
)
pool = get_pool()
if pool is None:
return JSONResponse({"_error": "db_not_ready"}, status_code=503)
try:
rows = await pool.fetch(sql)
return JSONResponse({
"query": sql,
"rows": [{k: _serialize_value(v) for k, v in dict(r).items()} for r in rows],
"count": len(rows),
})
except asyncpg.PostgresError as e:
return JSONResponse({"_error": "query_failed", "detail": str(e)}, status_code=400)
Why base64-encode the SQL?
URL-safe transport for query-string params with special chars (quotes, semicolons, newlines). Caller does echo "SELECT ..." | base64, server decodes. Alternative: POST body — but GET is more cache-friendly and easier from curl.
Defense layers (beyond the validator)
- DB-side: dedicated read-only user/role with
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user. Even if the regex misses, the DB rejects.
- Network: endpoint reachable only via private TLS cert → already gates external attackers
- Audit-log: every
/sql call logged JSONL (timestamp + query-hash + caller-IP + outcome). Forensic trail for misuse.
- Timeout:
pool.fetch(sql, timeout=10.0) so a runaway query can't DoS the service.
Test cases (5 cases all passed)
# 1. Valid SELECT → 200
SQL=$(echo -n "SELECT count(*) FROM v3_trades" | base64)
curl /sql?q=$SQL # → {"rows":[{"count":22}], ...}
# 2. Mutating UPDATE → 403
SQL=$(echo -n "UPDATE v3_trades SET closed_at=now()" | base64)
curl /sql?q=$SQL # → 403 {"_error":"forbidden_query"}
# 3. Comment-smuggle attempt → 403
SQL=$(echo -n "--SELECT 1\nDELETE FROM v3_trades" | base64)
curl /sql?q=$SQL # → 403 (comment stripped before matching)
# 4. WITH/CTE (allowed) → 200
SQL=$(echo -n "WITH x AS (SELECT 1 a) SELECT * FROM x" | base64)
curl /sql?q=$SQL # → 200
# 5. Bad base64 → 400
curl /sql?q=INVALID # → 400 {"_error":"invalid_query_encoding"}
Anti-patterns
- ❌ Skip comment-strip pre-pass: caller smuggles
-- SELECT 1\nDELETE and regex sees SELECT at line-start — passes naively. ALWAYS strip comments first.
- ❌ Allow
EXECUTE in allowlist — EXECUTE runs prepared statements that can mutate. Deny it explicitly.
- ❌ Trust the regex alone: pair with a read-only DB-role. Defense-in-depth.
- ❌ Forget
pg_sleep(...) and pg_terminate_backend(...): technically SELECT but DoS-vector. For high-stakes endpoints, add SELECT-function-name denylist:_DENY_FUNCTIONS = (r"pg_sleep", r"pg_terminate_backend", r"pg_cancel_backend",
r"pg_read_file", r"pg_ls_dir", r"dblink")
for fn in _DENY_FUNCTIONS:
if re.search(rf"\b{fn}\s*\(", sql_clean, re.IGNORECASE):
return False
- ❌
SELECT … INTO new_table bypassed allowlist: SELECT * INTO new_table FROM v3_trades starts with SELECT but creates a table. Skill-Step 4 must explicitly check for \bINTO\b keyword after the prefix-match. Distinction: INSERT INTO is already denied; SELECT INTO needs separate handling.
- ❌
SELECT … FOR UPDATE row-lock side-effect: syntactically SELECT, semantically locks rows + can block other sessions. For high-stakes production DB: deny \bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b.
- ❌ CTE-mutation via
RETURNING: WITH x AS (INSERT INTO t VALUES (1) RETURNING id) SELECT * FROM x — starts with WITH (allowed prefix), contains INSERT (denylist catches it). But also add global \bRETURNING\b denylist for safety because RETURNING outside CTE shouldn't appear in pure read queries anyway.
- ❌ Multi-statement reject missing:
SELECT 1; DELETE FROM v3_trades could bypass if validator only checks first statement. Strip trailing ; then reject if any ; remains in body:sql_trimmed = sql_clean.rstrip(";").strip()
if ";" in sql_trimmed:
return False # multiple statements
- ❌ Naive
-- comment-strip breaks string literals: SELECT 'a--b' AS x becomes broken SQL after stripping. False-positive: legitimate query rejected. Edge-case acceptable for v1; production-grade needs sqlparse AST-walk.
- ❌ Dollar-quoting
$$...$$ and $tag$...$tag$ not handled: PostgreSQL string literals using dollar-quoting can hide mutating keywords inside what looks like a "string". Regex won't see through. Production-grade needs AST.
- ❌ Pass SQL via shell-interpolation in a subprocess (e.g.
subprocess.run(["psql", "-c", sql])): the shell can mis-quote and turn SQL into injection. Always use parameterized libraries (asyncpg, psycopg).
- ❌ Audit-log the BASE64-encoded form only: when forensics-time comes, you'll have to decode 500 lines manually. Log the decoded SQL (or both — encoded for round-trip, decoded for grep).
Production-grade upgrade path
When the naive regex isn't enough (e.g. moving from internal dashboard to external API):
- Replace
_is_readonly_sql with sqlparse.parse(sql) AST walk that recursively confirms every statement-token is in {SELECT, WITH, EXPLAIN, ANALYZE}
- Add
pglast for full Postgres-AST awareness (handles every Postgres-specific edge case)
- Move to a query-budget approach: estimate-via-EXPLAIN, reject if cost > threshold
- Per-caller rate-limit + concurrent-query-quota
Background: TDD progress (Bulletproofing Log)
Cycle 1 — PASS with Polish
RED subagent (without skill, Flask + psycopg2 for POST /sql): Wrote extensive validator with multi-layer defense (regex + set_session(readonly=True) + statement_timeout + read-only PG role). Found 7 gaps in its own code: SELECT INTO bypass, dollar-quoting not handled, string-literal-with--- false-positive, SELECT FOR UPDATE row-lock side-effect, CTE-RETURNING, identifier collisions ("drop" column), unicode whitespace. Very honest self-assessment.
GREEN subagent (with skill via Read tool, same task): Applied 4-step pattern + additionally multi-statement defense (semicolon detection) + RETURNING denylist + audit log with decoded SQL. Identified skill adaptation from FastAPI/asyncpg to Flask/psycopg2 as trivial. Found the EXECUTE anti-pattern particularly valuable ("sounds like 'execute query', but is prepared-statement mutation").
Refactor applied before PROMOTE (based on RED self-findings):
- Polish-1:
pg_sleep/pg_terminate_backend with concrete code snippet as function denylist
- Polish-2:
SELECT INTO bypass as own anti-pattern documented (skill-step 4 must explicitly check \bINTO\b)
- Polish-3:
SELECT … FOR UPDATE as row-lock side-effect pattern added
- Polish-4: Multi-statement defense as own anti-pattern with code snippet
- Polish-5:
RETURNING global denylist (not just in CTE context)
- Polish-6: Naive
-- comment-strip false-positive on string literals as known edge case
- Polish-7: Dollar-quoting limitation marked as "needs AST"
Cycle-2 Backlog (Polish, non-blocking)
- Real sqlparse-AST-Walker as production-grade alternative documented with code example
- Identifier collisions: with DB columns named after reserved words (
"drop", "delete") — mention quoted-identifier detection
- GROUP BY/ORDER BY edge cases with function calls (
ORDER BY pg_sleep(1)) — function denylist in full SQL not just prefix
- Example DB-role setup with concrete SQL (
CREATE ROLE readonly_user WITH LOGIN PASSWORD '...'; GRANT pg_read_all_data TO readonly_user;)
- Real-use-case test: apply in next MCP server or dashboard ad-hoc query
Cross-skill connections
subprocess-ssh-arg-quoting-via-shlex (GA): sibling skill on argument-safety
enum-known-values-via-insert-grep (GA): cross-table constants-set discovery
superpowers:writing-skills: creating production-grade alternatives requires AST-walker pattern
1---2name: read-only-sql-via-regex-validator3description: Use when exposing a read-only SQL endpoint to a less-trusted caller (browser via HTTP, MCP-Tool to LLM, internal dashboard with copy-paste query box, future API consumer). The endpoint accepts a SQL string and must reject all mutating statements before executing. Encodes the regex-based denylist+allowlist pattern: (1) strip SQL comments (`--` and `/* */`) BEFORE matching to prevent comment-smuggle bypass, (2) normalize whitespace + uppercase, (3) denylist regex for INSERT/UPDATE/DELETE/DROP/CREATE/ALTER/TRUNCATE/CALL, (4) allowlist regex for SELECT/WITH/EXPLAIN/ANALYZE start-tokens. Trigger phrases like "read-only SQL endpoint", "SELECT-only API", "SQL escape hatch", "DB query as HTTP endpoint", "MCP tool exposes raw SQL", "Postgres read API". Do NOT load when a real SQL parser is available (sqlparse, pglast), for write-allowed endpoints, stored-procedure invocation, or DBs where SELECT itself has side-effects.4---56# Read-Only SQL Endpoint via Regex Validator78For low-stakes internal SQL escape hatches (Dashboard, MCP-tool, Companion-Service), a regex-based pre-check is a 95% solution that takes 5 minutes to implement. For high-stakes write-control, use a proper SQL parser.910## When to use1112- Building a `/sql?q=<base64-SELECT>` endpoint for internal tools13- MCP-server tool that takes user-supplied SQL14- Dashboard query box that lets analysts run ad-hoc SELECTs15- Companion service that wants to expose raw query capability without full DB-credentials to the caller1617## When NOT to use1819- Write-allowed endpoints — never expose mutation via raw SQL; parameterize specific updates instead20- Public/Internet-facing endpoints — use a real SQL parser (sqlparse, pglast) and probably an SQL-permissions-restricted DB role21- Production systems where a malicious SELECT could affect performance (long table scan, `pg_sleep`, ...) — add timeout + query-cost budget on top22- Stored-procedure invocation that has side-effects despite being syntactically read-only2324## The validator (Python, 30 lines)2526```python27import re2829def _is_readonly_sql(sql: str) -> bool:30 """Validate that SQL is read-only (SELECT/WITH/EXPLAIN only).3132 Denies: INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, CALL.33 Allows: SELECT, WITH (CTE), EXPLAIN, ANALYZE.3435 Naive — catches obvious violations but not all corner cases.36 For high-stakes use, replace with sqlparse-based AST walk.37 """38 # Strip comments BEFORE matching — prevents comment-smuggle attacks39 sql_clean = re.sub(r"--.*?$", "", sql, flags=re.MULTILINE)40 sql_clean = re.sub(r"/\*.*?\*/", "", sql_clean, flags=re.DOTALL)41 # Normalize whitespace + uppercase42 sql_clean = re.sub(r"\s+", " ", sql_clean).strip().upper()4344 denied = [45 r"^\s*INSERT\s", r"^\s*UPDATE\s", r"^\s*DELETE\s",46 r"^\s*DROP\s", r"^\s*CREATE\s", r"^\s*ALTER\s",47 r"^\s*TRUNCATE\s", r"^\s*CALL\s",48 ]49 if any(re.search(p, sql_clean) for p in denied):50 return False51 allowed = [r"^SELECT\s", r"^WITH\s", r"^EXPLAIN\s", r"^ANALYZE\s"]52 return any(re.search(p, sql_clean) for p in allowed)53```5455## Endpoint wrapper (FastAPI)5657```python58@app.get("/sql")59async def sql_endpoint(q: str = Query(..., description="Base64-encoded read-only SQL")):60 try:61 sql = base64.b64decode(q).decode("utf-8")62 except Exception as e:63 return JSONResponse({"_error": "invalid_query_encoding", "detail": str(e)}, status_code=400)6465 if not _is_readonly_sql(sql):66 return JSONResponse(67 {"_error": "forbidden_query", "detail": "Only SELECT/WITH/EXPLAIN allowed"},68 status_code=403,69 )7071 pool = get_pool()72 if pool is None:73 return JSONResponse({"_error": "db_not_ready"}, status_code=503)74 try:75 rows = await pool.fetch(sql)76 return JSONResponse({77 "query": sql,78 "rows": [{k: _serialize_value(v) for k, v in dict(r).items()} for r in rows],79 "count": len(rows),80 })81 except asyncpg.PostgresError as e:82 return JSONResponse({"_error": "query_failed", "detail": str(e)}, status_code=400)83```8485## Why base64-encode the SQL?8687URL-safe transport for query-string params with special chars (quotes, semicolons, newlines). Caller does `echo "SELECT ..." | base64`, server decodes. Alternative: POST body — but GET is more cache-friendly and easier from `curl`.8889## Defense layers (beyond the validator)90911. **DB-side**: dedicated read-only user/role with `GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user`. Even if the regex misses, the DB rejects.922. **Network**: endpoint reachable only via private TLS cert → already gates external attackers933. **Audit-log**: every `/sql` call logged JSONL (timestamp + query-hash + caller-IP + outcome). Forensic trail for misuse.944. **Timeout**: `pool.fetch(sql, timeout=10.0)` so a runaway query can't DoS the service.9596## Test cases (5 cases all passed)9798```bash99# 1. Valid SELECT → 200100SQL=$(echo -n "SELECT count(*) FROM v3_trades" | base64)101curl /sql?q=$SQL # → {"rows":[{"count":22}], ...}102103# 2. Mutating UPDATE → 403104SQL=$(echo -n "UPDATE v3_trades SET closed_at=now()" | base64)105curl /sql?q=$SQL # → 403 {"_error":"forbidden_query"}106107# 3. Comment-smuggle attempt → 403108SQL=$(echo -n "--SELECT 1\nDELETE FROM v3_trades" | base64)109curl /sql?q=$SQL # → 403 (comment stripped before matching)110111# 4. WITH/CTE (allowed) → 200112SQL=$(echo -n "WITH x AS (SELECT 1 a) SELECT * FROM x" | base64)113curl /sql?q=$SQL # → 200114115# 5. Bad base64 → 400116curl /sql?q=INVALID # → 400 {"_error":"invalid_query_encoding"}117```118119## Anti-patterns120121- ❌ **Skip comment-strip pre-pass**: caller smuggles `-- SELECT 1\nDELETE` and regex sees `SELECT` at line-start — passes naively. ALWAYS strip comments first.122- ❌ **Allow `EXECUTE`** in allowlist — `EXECUTE` runs prepared statements that can mutate. Deny it explicitly.123- ❌ **Trust the regex alone**: pair with a read-only DB-role. Defense-in-depth.124- ❌ **Forget `pg_sleep(...)` and `pg_terminate_backend(...)`**: technically SELECT but DoS-vector. For high-stakes endpoints, add SELECT-function-name denylist:125 ```python126 _DENY_FUNCTIONS = (r"pg_sleep", r"pg_terminate_backend", r"pg_cancel_backend",127 r"pg_read_file", r"pg_ls_dir", r"dblink")128 for fn in _DENY_FUNCTIONS:129 if re.search(rf"\b{fn}\s*\(", sql_clean, re.IGNORECASE):130 return False131 ```132- ❌ **`SELECT … INTO new_table` bypassed allowlist**: `SELECT * INTO new_table FROM v3_trades` starts with SELECT but creates a table. Skill-Step 4 must explicitly check for `\bINTO\b` keyword after the prefix-match. Distinction: `INSERT INTO` is already denied; `SELECT INTO` needs separate handling.133- ❌ **`SELECT … FOR UPDATE` row-lock side-effect**: syntactically SELECT, semantically locks rows + can block other sessions. For high-stakes production DB: deny `\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b`.134- ❌ **CTE-mutation via `RETURNING`**: `WITH x AS (INSERT INTO t VALUES (1) RETURNING id) SELECT * FROM x` — starts with WITH (allowed prefix), contains INSERT (denylist catches it). But also add **global `\bRETURNING\b` denylist** for safety because RETURNING outside CTE shouldn't appear in pure read queries anyway.135- ❌ **Multi-statement reject missing**: `SELECT 1; DELETE FROM v3_trades` could bypass if validator only checks first statement. Strip trailing `;` then reject if any `;` remains in body:136 ```python137 sql_trimmed = sql_clean.rstrip(";").strip()138 if ";" in sql_trimmed:139 return False # multiple statements140 ```141- ❌ **Naive `--` comment-strip breaks string literals**: `SELECT 'a--b' AS x` becomes broken SQL after stripping. False-positive: legitimate query rejected. Edge-case acceptable for v1; production-grade needs `sqlparse` AST-walk.142- ❌ **Dollar-quoting `$$...$$` and `$tag$...$tag$` not handled**: PostgreSQL string literals using dollar-quoting can hide mutating keywords inside what looks like a "string". Regex won't see through. Production-grade needs AST.143- ❌ **Pass SQL via shell-interpolation in a subprocess** (e.g. `subprocess.run(["psql", "-c", sql])`): the shell can mis-quote and turn SQL into injection. Always use parameterized libraries (asyncpg, psycopg).144- ❌ **Audit-log the BASE64-encoded form only**: when forensics-time comes, you'll have to decode 500 lines manually. Log the decoded SQL (or both — encoded for round-trip, decoded for grep).145146## Production-grade upgrade path147148When the naive regex isn't enough (e.g. moving from internal dashboard to external API):1491. Replace `_is_readonly_sql` with `sqlparse.parse(sql)` AST walk that recursively confirms every statement-token is in {SELECT, WITH, EXPLAIN, ANALYZE}1502. Add `pglast` for full Postgres-AST awareness (handles every Postgres-specific edge case)1513. Move to a query-budget approach: estimate-via-EXPLAIN, reject if cost > threshold1524. Per-caller rate-limit + concurrent-query-quota153154## Background: TDD progress (Bulletproofing Log)155156### Cycle 1 — PASS with Polish157158- **RED subagent** (without skill, Flask + psycopg2 for `POST /sql`): Wrote extensive validator with multi-layer defense (regex + `set_session(readonly=True)` + statement_timeout + read-only PG role). **Found 7 gaps in its own code**: SELECT INTO bypass, dollar-quoting not handled, string-literal-with-`--` false-positive, SELECT FOR UPDATE row-lock side-effect, CTE-RETURNING, identifier collisions (`"drop"` column), unicode whitespace. Very honest self-assessment.159160- **GREEN subagent** (with skill via Read tool, same task): Applied 4-step pattern + additionally multi-statement defense (semicolon detection) + RETURNING denylist + audit log with decoded SQL. Identified skill adaptation from FastAPI/asyncpg to Flask/psycopg2 as trivial. Found the EXECUTE anti-pattern particularly valuable ("sounds like 'execute query', but is prepared-statement mutation").161162- **Refactor applied before PROMOTE** (based on RED self-findings):163 - **Polish-1**: `pg_sleep/pg_terminate_backend` with concrete code snippet as function denylist164 - **Polish-2**: `SELECT INTO` bypass as own anti-pattern documented (skill-step 4 must explicitly check `\bINTO\b`)165 - **Polish-3**: `SELECT … FOR UPDATE` as row-lock side-effect pattern added166 - **Polish-4**: Multi-statement defense as own anti-pattern with code snippet167 - **Polish-5**: `RETURNING` global denylist (not just in CTE context)168 - **Polish-6**: Naive `--` comment-strip false-positive on string literals as known edge case169 - **Polish-7**: Dollar-quoting limitation marked as "needs AST"170171### Cycle-2 Backlog (Polish, non-blocking)1721731. **Real sqlparse-AST-Walker** as production-grade alternative documented with code example1742. **Identifier collisions**: with DB columns named after reserved words (`"drop"`, `"delete"`) — mention quoted-identifier detection1753. **GROUP BY/ORDER BY edge cases** with function calls (`ORDER BY pg_sleep(1)`) — function denylist in full SQL not just prefix1764. **Example DB-role setup** with concrete SQL (`CREATE ROLE readonly_user WITH LOGIN PASSWORD '...'; GRANT pg_read_all_data TO readonly_user;`)1775. **Real-use-case test**: apply in next MCP server or dashboard ad-hoc query178179## Cross-skill connections180181- `subprocess-ssh-arg-quoting-via-shlex` (GA): sibling skill on argument-safety182- `enum-known-values-via-insert-grep` (GA): cross-table constants-set discovery183- `superpowers:writing-skills`: creating production-grade alternatives requires AST-walker pattern