Read only sql via regex validator
Skill Ed3Design/ed3design-skill-bundles/schema-discipline/skills/read-only-sql-via-regex-validator
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.From its SKILL.md
npx -y skills add Ed3Design/ed3design-skill-bundles --skill read-only-sql-via-regex-validatorAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
11.3 KB, ~2.7k tokens by cl100k_base, as published. Nobody here has run it
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
/sqlcall 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\nDELETEand regex seesSELECTat line-start — passes naively. ALWAYS strip comments first. - ❌ Allow
EXECUTEin allowlist —EXECUTEruns 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(...)andpg_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_tablebypassed allowlist:SELECT * INTO new_table FROM v3_tradesstarts with SELECT but creates a table. Skill-Step 4 must explicitly check for\bINTO\bkeyword after the prefix-match. Distinction:INSERT INTOis already denied;SELECT INTOneeds separate handling. - ❌
SELECT … FOR UPDATErow-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\bdenylist for safety because RETURNING outside CTE shouldn't appear in pure read queries anyway. - ❌ Multi-statement reject missing:
SELECT 1; DELETE FROM v3_tradescould 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 xbecomes broken SQL after stripping. False-positive: legitimate query rejected. Edge-case acceptable for v1; production-grade needssqlparseAST-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_sqlwithsqlparse.parse(sql)AST walk that recursively confirms every statement-token is in {SELECT, WITH, EXPLAIN, ANALYZE} - Add
pglastfor 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_backendwith concrete code snippet as function denylist - Polish-2:
SELECT INTObypass as own anti-pattern documented (skill-step 4 must explicitly check\bINTO\b) - Polish-3:
SELECT … FOR UPDATEas row-lock side-effect pattern added - Polish-4: Multi-statement defense as own anti-pattern with code snippet
- Polish-5:
RETURNINGglobal 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"
- Polish-1:
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-safetyenum-known-values-via-insert-grep(GA): cross-table constants-set discoverysuperpowers:writing-skills: creating production-grade alternatives requires AST-walker pattern
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.