SQL guards
The exact rules the AST inspector enforces on every submission — WHERE requirements, LIMIT injection, transactions and blocked statements.
Updated:
Every submission is parsed and inspected before it can enter the approval queue. A request that violates a guard is rejected at submission with an explicit message — it never reaches a DBA, let alone a database.
The rules
UPDATE / DELETE require a WHERE
DELETE FROM users; -- ✗ rejected
DELETE FROM users WHERE id = 42; -- ✓ allowed (still needs approval)
This applies inside transactions too. A WHERE clause that is statically
always true counts as missing:
DELETE FROM users WHERE 1 = 1; -- ✗ rejected
DELETE FROM users WHERE true; -- ✗ rejected
DELETE FROM users WHERE id = 1 OR 1 = 1; -- ✗ rejected
The check evaluates literals (numbers, plain strings, TRUE, FALSE,
NULL), comparisons, IS [NOT] NULL|TRUE|FALSE, AND, OR, NOT and
parentheses. An operand it cannot evaluate (a column, a function) is never
counted as true: the detection is static and best-effort. A WHERE clause nested too deeply or too large to evaluate is
rejected too (… with a WHERE clause too complex to verify is not allowed).
There is no override for this rule. Because the detection is static, it stops
the common mistakes; it does not prove that a write touches only a few rows.
The rule also applies to the statement inside EXPLAIN ANALYZE and MariaDB
ANALYZE, inside CTE bodies, T-SQL batches and dynamic SQL. PostgreSQL DO
blocks are the exception: they are not inspected for this rule, are classified
as a write, and always go through human approval, where the reviewer sees the
full body. Dynamic SQL inside a DO body (PL/pgSQL EXECUTE) is the other
side of that bargain: the statement it runs is built at run time and cannot be
read, so it is rejected outright (see
Judged by the dangerous part).
SELECT gets a LIMIT
- No
LIMIT? The default (1000) is injected automatically, on its own line so a trailing line comment can never swallow it. LIMITabove the hard cap (10000)? It is clamped down.LIMIT ALL(PostgreSQL) counts as unlimited and is clamped.- Offsets are preserved:
LIMIT 10, 20000becomesLIMIT 10, 10000,LIMIT 99999 OFFSET 4becomesLIMIT 10000 OFFSET 4. FETCH FIRST n ROWS ONLYand T-SQLTOP nare clamped the same way.- SQL Server targets get no injected
LIMIT(it is not valid T-SQL); the guard works onTOPinstead. An existingTOP n/TOP (n)is clamped to the hard cap (10000), and a top-level SELECT without one getsTOP (1000)(the default) after anyDISTINCT/ALL.TOP … PERCENT,TOP (@v), expressions and decimals are rejected. ForWITH,OFFSET,UNION,EXCEPTandINTERSECTnoTOPis added; the worker’s row ceiling (the hard cap) bounds those, with the result marked as truncated. - A
LIMITthe guard cannot understand as a plain row count (arithmetic likeLIMIT 100*100000, expressions, placeholders) is rejected, not left unclamped — a limit that can’t be verified can’t be enforced. - Subquery LIMITs are left alone; only the outer query is guarded. Locking
clauses are respected — the injected LIMIT lands before
FOR UPDATE.
On top of this, the worker enforces an absolute row ceiling (the hard cap)
while streaming results, independent of the SQL — so even a query that dodges
the clause-level guard (dialect quirks, SHOW, EXPLAIN) can never return more
than the cap; the stored result is flagged as truncated.
Both numbers are configurable — see Configuration. The prepared SQL (with the injected LIMIT) is shown to the developer before submission and to the DBA at review; what you approve is exactly what runs.
Multiple statements need an explicit transaction
UPDATE a SET x = 1 WHERE id = 1;
UPDATE b SET y = 2 WHERE id = 2; -- ✗ rejected: two bare statements
BEGIN;
UPDATE a SET x = 1 WHERE id = 1;
UPDATE b SET y = 2 WHERE id = 2;
COMMIT; -- ✓ allowed, runs atomically
The worker wraps the statements in a real transaction: if any statement fails,
everything rolls back. ROLLBACK in a submission is rejected — rollback on
failure is automatic, not something to hand-write. Nested transactions are not
supported.
Always blocked
These statements hand out server-level code execution or file access, and have no everyday equivalent through a query portal. They are refused outright, whatever the role:
DROP DATABASE/DROP SCHEMAGRANT,REVOKE,DENYCREATE USER/ALTER USER/DROP USER(andROLE/LOGINvariants)CREATE FUNCTION … SONAME— loadable UDFs (and theCREATE AGGREGATE FUNCTIONform)SHUTDOWN- Server-side file / program IO:
SELECT … INTO OUTFILE/INTO DUMPFILE,LOAD DATA, PostgreSQLCOPY(includingCOPY … TO PROGRAM), theLOAD_FILE()function, and PostgreSQL’s file functionspg_read_file(),pg_read_binary_file(),pg_ls_dir(),lo_import()andlo_export()— schema-qualified calls (pg_catalog.pg_read_file(…)) included. Only the call form is caught; a column or alias with the same name is not blocked. - PostgreSQL functions that run SQL handed to them as a string, which the guard
cannot read:
query_to_xml,query_to_xmlschema,query_to_xml_and_xmlschema,xpath_table,ts_stat,ts_rewrite,crosstab*andconnectby.
These checks run against a comment-normalized form of the statement, so
tricks like DROP/**/DATABASE or a leading /* … */ comment cannot smuggle a
blocked statement past them.
DROP TABLE, TRUNCATE and ALTER are not blocked outright — they run as
approval-gated writes, but a request containing DDL is flagged at submission: a
DDL badge shows in the approval queue and on the request detail page, and
the Slack and Teams submission notifications carry a DDL marker (the later
approved/rejected notifications do not). A request without DDL shows no flag.
Database administration belongs in your infrastructure tooling, not in a query portal.
Judged by the dangerous part, not the keyword
Three statements are refused only for what is inside them, because their syntax also covers ordinary DBA work that the guard must not take away.
CREATE EXTENSION is judged by the extension name. Untrusted procedural
languages run as the database superuser by definition and are refused:
plpythonu, plpython2u, plpython3u, plperlu, pltclu, plsh, plr,
plluau, plrubyu, plphpu, pljavau — plus anything matching PostgreSQL’s
pl…u naming convention for an untrusted language, so a binding nobody has
listed yet (plpython4u) is refused on sight. Everything else installs
normally: pg_stat_statements, pgcrypto, uuid-ossp, postgis, plv8,
plpgsql. A CREATE EXTENSION whose name cannot be read is refused.
DO blocks and routine bodies. A PostgreSQL DO block, CREATE FUNCTION
or CREATE PROCEDURE carries a body in another language, so the guard reads
the body with PostgreSQL’s own lexical rules (comments, '…', E'…', U&'…',
quoted identifiers and nested dollar-quoted strings), not MySQL’s, and applies
the same blocked-function and guarded-setting rules as to top-level SQL. So
DO $$ BEGIN DROP DATABASE prod; END $$ and a pg_read_file(…) call hidden
behind a backslash or a comment do not get through. On top of that:
- Only the
plpgsqlandsqllanguages are accepted. ADOblock without aLANGUAGEclause runs in PostgreSQL’s default,plpgsql; aCREATE FUNCTION/PROCEDUREneeds an explicitLANGUAGE plpgsqlorsqlunless it has a SQL-standardRETURNbody. Any other language is refused, trusted or not, and so is one the guard cannot read as a plain name; a quoted name keeps its case, soLANGUAGE "PLPGSQL"is refused.CREATE LANGUAGEandALTER LANGUAGE … RENAMEare refused as well. - A body that uses dynamic SQL (
EXECUTE) is refused, trigger functions included, because the statement it runs is built at run time. TriggerEXECUTE FUNCTION|PROCEDUREclauses are not affected. BEGIN ATOMICbodies, andCREATE FUNCTION/CREATE PROCEDUREwithout a body, are refused.- A
SETclause on a routine (CREATE/ALTER FUNCTION|PROCEDURE … SET), a database (ALTER DATABASE … SET) or a role (ALTER ROLE|USER … SET) goes through the guarded-setting rule below. If the setting cannot be resolved, the statement is refused.
SET is judged by the variable name. Refused are the variables that name a
file path, load code into the server process or switch a protection off.
- MySQL and MariaDB:
general_log,general_log_file,slow_query_log,slow_query_log_file,log_error,init_file,init_connect,plugin_dir,plugin_load,plugin_load_add,secure_file_priv,local_infile. Only the persistent scopes are judged —GLOBAL,PERSISTandPERSIST_ONLY, in both the keyword form (SET GLOBAL x = …) and the variable form (SET @@GLOBAL.x = …). ASESSIONwrite is not guarded, exceptsql_modeand the read-only switch (see below), and@nameis a user variable, never a server setting.SET STATEMENT … FOR <statement>(MariaDB) is judged twice: its assignments by the rule above, and the statement afterFORas if it had been submitted on its own. - PostgreSQL:
session_preload_libraries,local_preload_libraries,dynamic_library_path,default_transaction_read_only,transaction_read_only,session_replication_role,allow_system_table_mods,lo_compat_privileges,zero_damaged_pages,ignore_checksum_failure,role,session_authorization. These are refused in every scope —SET x,SET LOCAL x,SET SESSION x,SET x TO …— because a session-level change is exactly what they are abused for. The same applies when the target database is unknown. - Every driver:
set_config('<name>', …)is refused for any listed name, with either value of the third argument. - Every driver, every scope:
sql_mode.NO_BACKSLASH_ESCAPESandANSI_QUOTESchange what a backslash and a double quote mean, which the guard cannot follow, soSET sql_mode,SET SESSION sql_mode,SET @@sql_modeandset_config('sql_mode', …)are refused. Reading it passes. See the next section for the matching check at execution time.
Names are matched exactly (case-insensitively, no wildcards), so
max_connections, wait_timeout, time_zone and, on PostgreSQL, work_mem
and search_path pass.
These two lists can be extended for your installation with
QUERYPROXY_EXTRA_UNTRUSTED_LANGUAGES and
QUERYPROXY_EXTRA_DANGEROUS_VARIABLES. The function, procedure and SQLite
pragma lists have their own QUERYPROXY_EXTRA_* settings. See
Configuration. They can only add: the built-in entries
are the floor, and configuration cannot shorten them.
Identity, catalog and read-only protections
A session that can change who it is, rewrite the server’s own tables or lift
its read-only state can undo every other rule here. These are refused in every
position the guard reads SQL: top-level, inside DO and routine bodies,
PREPARE and EXECUTE IMMEDIATE text, CTE bodies, EXPLAIN ANALYZE, MariaDB
ANALYZE and SET STATEMENT … FOR. Known limits lists the
statements this does not cover. The last item below is the exception: it is
checked when the request runs, not when it is submitted.
- Role and user switches.
SET [SESSION|LOCAL] ROLE,RESET ROLE,SET … SESSION AUTHORIZATION,RESET SESSION AUTHORIZATION(quoted names such asRESET "role"included), MySQL and MariaDBSET ROLE/SET DEFAULT ROLE, andset_config('role', …)/set_config('session_authorization', …).roleandsession_authorizationare on the built-in list of guarded settings. ALTER SYSTEMon PostgreSQL, whatever it sets.- Writes to the system catalog.
INSERT,UPDATE,DELETEandMERGEagainstpg_catalog,pg_toastandpg_…tables on PostgreSQL; the same statements andREPLACEagainst themysqlschema on MySQL and MariaDB. Qualified and quoted spellings are covered. The rule judges the write target, soUPDATE t SET … FROM pg_procandINSERT INTO t SELECT … FROM pg_procpass. On MySQL and MariaDB a multi-tableUPDATE/DELETEthat only reads amysql.*table is refused as well, because the guard cannot tell which table is written. Reading the catalog (SELECT * FROM pg_proc,SELECT rolname FROM pg_roles) is unaffected. On MySQL and MariaDBUSE mysqlis refused too (also insidePREPARE,EXECUTE IMMEDIATEandSET STATEMENT … FOR), so that a name without a schema cannot be resolved against the catalog. READ WRITEinSET TRANSACTION,SET SESSION CHARACTERISTICS,BEGINandSTART TRANSACTION.READ ONLYpasses.- The MySQL / MariaDB read-only switch. Writing
transaction_read_onlyortx_read_onlyis refused in every scope and spelling (SESSION,GLOBAL,@@,@@session., backtick, inside a list,SET STATEMENT).transaction_isolationandSET TRANSACTION READ ONLYpass. Some statements are not inspected for it; see Known limits. - PostgreSQL
ALTER DATABASE|ROLE|USER … RESETof a guarded setting orALL, and the action of aCREATE [OR REPLACE] RULE … DO [ALSO|INSTEAD] <action>, which is judged by the same rules as a statement submitted on its own. A rule whoseDOaction the guard cannot find is refused. - SQL Server impersonation.
EXECUTE AS,EXEC AS,SETUSERandREVERT, also insideEXEC('…')andsp_executesql, andWITH EXECUTE ASin object definitions. A column namedexecute_asand the same words inside a string literal are not affected. - A MySQL / MariaDB write on a connection whose default database is
mysql. A table name without a schema resolves against the session’s default database, so withmysqlas the default anUPDATE user SET …would edit the account table. The executor readsDATABASE()on the connection before the first statement of a write and fails the request, without running anything, when it ismysql. Point the connection at the application database. A single-statement read and a connection with no default database are not checked.
Text the guard reads differently from the server
Some text is read differently by the guard’s lexer and by the database. Rather than guess, the guard rejects it or inspects it under both readings:
- SQL that is not valid UTF-8 (
SQL must be valid UTF-8.), or in which the lexer finds no statement (QueryProxy could not read this SQL: …). - A byte-order mark or Unicode whitespace (U+FEFF, U+0085, U+00A0, U+1680, U+180E, U+2000–U+200B, U+2028, U+2029, U+202F, U+205F, U+3000) outside string literals, quoted identifiers and comments, on every driver and inside dynamic SQL. The lexer glues such a character to the neighbouring word, while a server may treat it as a separator.
- MariaDB executable comments (
/*M!100000 … */). On themariadbdriver the body is read as SQL and nesting deeper than 3 levels is rejected. On themysqldriver/*M!stays a plain comment, so connect a MariaDB server with themariadbdriver. When the target database is unknown,/*M!is rejected outright. - Version-conditional comments (
/*!NNNNN … */) on themysqlandmariadbdrivers. A comment like this is checked twice, once as if the server runs the body and once as if it skips it; the statement is rejected if either reading is refused or if the two readings differ in statement count, transaction state, type or prepared text. Also rejected (a change from earlier releases): different versions in one request (SELECT /*!40101 1 */, /*!50503 2 */is one), a version of other than 5 digits (5 or 6 on MariaDB), a versioned comment nested in or holding another comment, and one with*/inside a string or line comment in its body. sql_modeon MySQL and MariaDB. Besides refusing writes to it (above), the executor reads@@SESSION.sql_modebefore it runs the SQL and fails the request, without running anything, when it containsNO_BACKSLASH_ESCAPESorANSI_QUOTES. QueryProxy sets a strictsql_modeon every connection it opens, which replaces the server’s default, so today the check only fires if that setting changes or something in between alters the mode; a connection that does end up with either flag is refused until the flag is removed. Other drivers have no such check.- PostgreSQL backslashes and
#. A backslash is allowed only in comments, dollar-quoted bodies,E'…'strings and well-formedU&strings; anywhere else (including a plain string such as'a\') it is rejected, because PostgreSQL and MySQL disagree on what it means.#is rejected outside literals, quoted identifiers and comments. The same rules apply when the target database is unknown; MySQL and MariaDB are unaffected.
What the denylist is not
This list is structurally incomplete, and always will be. It enumerates dangerous syntax that is known today across several dialects; a spelling it does not name — a vendor extension, a function alias, a future server feature — passes it. Every entry above exists because someone found a way around an earlier rule, and that process does not end.
So treat it as one layer of defence in depth, never as the authorization boundary. What actually authorizes a statement is the DBA approval: a human who can see the prepared SQL and decide whether it should run at all. The denylist only removes the most obviously abusive requests before they ever reach that person. (A new gap is still a valid security report; see the security policy.)
That goes double for the DO body scan. It reads SQL written out in plain text;
it cannot read SQL the block assembles at run time, which is why dynamic SQL
(EXECUTE) in a DO or routine body is refused rather than scanned. What stays
outside its reach is the rest of what a block can do: a DO block in plpgsql
is still arbitrary server-side code, and a plainly written DELETE without a
WHERE in its body passes, because blocks are exempt from that rule and decided
at approval. What the scan buys is the removal of the direct
superuser-code-execution paths and of the plainly written forbidden statement;
it is the last net under an approval that should not have been given, not a
bound on what an approved block may do.
Coverage
What the guard does with each construct, per dialect. Blocked means the
request is rejected. Classified as write means it is not rejected but runs
as an approval-gated write (a DELETE / UPDATE still needs its WHERE).
Read is a plain read. Not covered means the guard does nothing about it
today. Behaviour is verified against the guard’s unit tests. For PostgreSQL, the
verdict and its effect on the server are also checked against a live server
(the live-pgsql test group). The repository’s Integration workflow runs
checks of the read-only session, timeouts, TLS and result streaming against real
PostgreSQL, MySQL, MariaDB and SQL Server servers. Rows about the database side
describe what the executor sets; they do not make the guard a boundary on its
own.
PostgreSQL
| Construct | Status |
|---|---|
EXPLAIN ANALYZE DELETE FROM t / UPDATE without WHERE |
blocked |
EXPLAIN (ANALYZE, BUFFERS) UPDATE t SET a=1 WHERE id=1 (also ANALYSE, ANALYZE true/on/1) |
classified as write |
EXPLAIN ANALYZE SELECT 1 |
read |
EXPLAIN DELETE FROM t WHERE id=1, EXPLAIN (ANALYZE false) DELETE FROM t |
read |
EXPLAIN ANALYZE DROP DATABASE prod |
blocked |
EXPLAIN (ANALYZE (unclosed), ANALYZE maybe, empty option, quoted or unknown option |
blocked |
EXPLAIN ANALYZE nested past 3 levels |
blocked |
SELECT * INTO t2 FROM t (also SELECT*INTO, in parentheses, inside a CTE) |
classified as write (DDL), no LIMIT added |
SELECT * INTO TEMP t2 FROM t |
classified as write (DDL) |
nextval, setval, pg_advisory_lock*, pg_advisory_xact_lock*, pg_try_advisory_*, pg_advisory_unlock* |
classified as write |
pg_sleep, pg_sleep_for, pg_sleep_until |
classified as write |
pg_terminate_backend, pg_cancel_backend, pg_reload_conf, pg_rotate_logfile, pg_promote, pg_switch_wal |
blocked |
dblink* (prefix: a user function that starts with dblink is blocked too) |
blocked |
pg_read_file, pg_read_binary_file, pg_ls_dir, lo_import, lo_export (quoted and U& spellings included) |
blocked |
set_config('literal', …) |
classified as write |
set_config(<not a literal>, …) |
blocked |
set_config('session_preload_libraries', …) and the other listed PostgreSQL settings |
blocked |
SET / SET LOCAL / SET SESSION of a listed PostgreSQL setting |
blocked in every scope |
SET sql_mode, set_config('sql_mode', …) |
blocked |
SET work_mem, set_config('search_path', …) and other unlisted settings |
classified as write |
ALTER SYSTEM … |
blocked |
SET [SESSION|LOCAL] ROLE, RESET ROLE, RESET "role", SET SESSION AUTHORIZATION, RESET SESSION AUTHORIZATION, set_config('role', …) |
blocked |
INSERT / UPDATE / DELETE / MERGE on pg_catalog, pg_toast or a pg_… table (also in a CTE, DO body, PREPARE) |
blocked |
SELECT * FROM pg_proc, SELECT rolname FROM pg_roles |
read |
UPDATE t SET … FROM pg_proc WHERE …, INSERT INTO t SELECT … FROM pg_proc |
classified as write; the write target decides |
SET TRANSACTION READ WRITE, SET SESSION CHARACTERISTICS … READ WRITE, BEGIN READ WRITE, START TRANSACTION READ WRITE |
blocked |
SET TRANSACTION READ ONLY |
passes |
ALTER DATABASE|ROLE|USER … RESET <listed setting> or RESET ALL |
blocked |
CREATE RULE … DO [ALSO|INSTEAD] <action> |
the action is judged by the usual rules; a rule whose DO cannot be found is blocked |
DELETE FROM t WHERE 1 = 1, WHERE true, WHERE id = 1 OR 1 = 1 |
blocked (always-true WHERE) |
DO block with DELETE FROM t and no WHERE |
classified as write; exempt from the WHERE rule, decided at approval |
PREPARE n AS <statement> |
classified as write; the statement is judged by its own rules |
WITH x AS (DELETE FROM t) SELECT … (no WHERE) |
blocked |
WITH x AS (UPDATE t SET a=1 WHERE id=1 RETURNING …) SELECT … |
classified as write |
WITH … DELETE FROM t (statement after the WITH) |
WHERE rule applies |
WITH nested more than 3 levels, or unreadable |
blocked |
INSERT / MERGE body inside a CTE |
classified as write; the body gets no separate WHERE check (not covered) |
DO block, CREATE FUNCTION / CREATE PROCEDURE containing EXECUTE (dynamic SQL), including trigger functions |
blocked |
DO / CREATE FUNCTION / CREATE PROCEDURE in a language other than plpgsql or sql; BEGIN ATOMIC body; CREATE FUNCTION without a body; CREATE LANGUAGE |
blocked |
DO / routine body calling a blocked function or setting a guarded setting (PERFORM pg_read_file(…)) |
blocked; the body is read with PostgreSQL’s lexical rules |
CREATE/ALTER FUNCTION … SET, ALTER DATABASE … SET, ALTER ROLE … SET of a listed setting |
blocked |
query_to_xml, query_to_xmlschema, query_to_xml_and_xmlschema, xpath_table, ts_stat, ts_rewrite, crosstab*, connectby |
blocked |
Plain plpgsql text inside a DO / routine body other than the above |
scanned as written (not covered beyond that) |
# outside a literal, quoted identifier or comment |
blocked |
Backslash outside a comment, dollar-quoted body, E'…' or well-formed U& string |
blocked |
SELECT … FOR UPDATE |
read (locks rows); at run time the read-only session refuses it |
DENY |
blocked (;-separated or standalone forms) |
| Read-only session on the database side | covered by the executor, not by the guard: a read runs in SET TRANSACTION READ ONLY, and the server refuses a write in it |
| Statement and lock timeout on the database side | covered by the executor: statement_timeout (execution timeout minus 5 seconds), lock_timeout, idle_in_transaction_session_timeout |
| TLS | new connections use sslmode=require; connections from before v0.3.0 keep sslmode=prefer until you edit them |
MySQL / MariaDB
| Construct | Status |
|---|---|
EXPLAIN ANALYZE UPDATE t SET a=1 WHERE id=1 |
classified as write |
EXPLAIN ANALYZE DELETE FROM t (no WHERE) |
blocked |
EXPLAIN without ANALYZE |
read |
MariaDB ANALYZE UPDATE t SET a=1 WHERE id=1 (also ANALYZE FORMAT=JSON …) |
classified as write |
MariaDB ANALYZE DELETE FROM t (no WHERE) |
blocked |
MariaDB ANALYZE SELECT … |
judged as the SELECT inside it |
MariaDB ANALYZE DROP TABLE t, ANALYZE TRUNCATE …, ANALYZE SET …, (ANALYZE …) |
blocked |
ANALYZE TABLE t (and NO_WRITE_TO_BINLOG / LOCAL forms) |
classified as write, unchanged |
/*M!100000 DELETE FROM t */ (mariadb driver) |
the body is inspected; plain comment on the mysql driver |
Versioned /*!NNNNN … */ comments |
the body is inspected under both readings; mixed versions and odd digit counts blocked (see above) |
SET SESSION sql_mode = …, SET GLOBAL sql_mode = …, SET @@sql_mode = …, SET @@SESSION.sql_mode = … |
blocked in every scope |
SELECT @@sql_mode, SHOW VARIABLES LIKE 'sql_mode' |
read |
SET [SESSION|GLOBAL] transaction_read_only = …, tx_read_only, SET @@session.transaction_read_only = …, SET STATEMENT transaction_read_only=0 FOR … |
blocked in every scope |
SET TRANSACTION READ WRITE, START TRANSACTION READ WRITE, BEGIN READ WRITE |
blocked |
SET ROLE …, SET DEFAULT ROLE … |
blocked |
USE mysql (also inside PREPARE, EXECUTE IMMEDIATE, SET STATEMENT … FOR) |
blocked |
INSERT / UPDATE / DELETE / REPLACE on a mysql.… table (a multi-table UPDATE / DELETE that only reads one is blocked too) |
blocked |
A write on a connection whose default database is mysql |
the request fails before the SQL runs; single-statement reads pass |
Session sql_mode containing NO_BACKSLASH_ESCAPES or ANSI_QUOTES at execution |
the request fails before the SQL runs |
MariaDB SET STATEMENT … FOR <statement> |
the statement after FOR is judged by its own rules (WHERE, denylist, …); without one, blocked |
SET GLOBAL general_log := 1 (space before :=) |
blocked |
SELECT … INTO @var |
read |
SELECT … INTO OUTFILE / INTO DUMPFILE |
blocked |
get_lock, release_lock |
classified as write |
sleep, benchmark |
classified as write |
load_file |
blocked |
PREPARE s FROM '<DML without WHERE>' |
blocked |
PREPARE s FROM '<DML with WHERE>' |
classified as write |
PREPARE s FROM @q (variable) |
blocked |
WITH x AS (DELETE …) without WHERE |
blocked |
WITH … DELETE FROM t without WHERE |
blocked |
SELECT 1 DENY … |
read (DENY is not a MySQL statement; the ;-separated and standalone forms are blocked) |
| Read-only session on the database side | covered by the executor: SET SESSION TRANSACTION READ ONLY, set for the whole session so a statement that commits implicitly (TRUNCATE, ALTER) cannot leave read-only mode |
| Statement and lock timeout on the database side | reads: MAX_EXECUTION_TIME (MySQL, SELECT only) or max_statement_time (MariaDB), innodb_lock_wait_timeout. A MySQL write is not cut by the database; the job ends at the execution timeout |
| TLS | new connections require TLS; the server certificate is checked only if you tick “Verify the server certificate” |
SQL Server
| Construct | Status |
|---|---|
xp_*, sp_configure, sp_oa*, sp_addlinkedserver, sp_addlinkedsrvlogin, sp_serveroption, sp_addextendedproc, sp_execute_external_script (called as a statement or after EXEC) |
blocked |
OPENROWSET, OPENDATASOURCE, OPENQUERY |
blocked |
BULK INSERT |
blocked |
sp_prepare, sp_prepexec, sp_prepexecrpc, sp_execute, sp_cursoropen, sp_cursorprepare, sp_cursorprepexec, sp_msforeachtable, sp_msforeachdb, sp_msforeach_worker, sp_sqlexec, sp_send_dbmail, sp_add_jobstep, sp_update_jobstep |
blocked whatever the arguments |
EXEC('DELETE FROM t') / EXEC(N'…') without WHERE |
blocked |
EXEC('DELETE FROM t WHERE id = 1') |
classified as write |
EXEC(@sql), EXEC('a' + 'b'), EXEC(…) AT srv, EXEC … AS USER |
blocked |
sp_executesql N'…' (with or without EXEC, or @stmt =) |
the SQL text is judged by the usual rules; a variable is blocked |
EXEC('') |
harmless, passes |
EXEC('EXEC xp_cmdshell ''dir''') (nested literal) |
blocked |
SELECT 'xp_cmdshell' (the name inside a string literal) |
read |
EXEC dbo.my_proc 1 |
classified as write |
EXEC "quoted_name", WHERE exec = 1 |
blocked (the guard errs on the strict side) |
BEGIN / COMMIT / ROLLBACK inside dynamic SQL |
blocked |
| Dynamic SQL nested more than 3 levels | blocked |
SELECT … INTO t2 FROM t |
classified as write (DDL), no TOP added |
SELECT * FROM t |
read; gets TOP (1000) |
SELECT TOP 99999 … / TOP (99999) |
read, clamped to TOP 10000 |
SELECT TOP 10 PERCENT …, TOP (@v), TOP (5 + 5), TOP (1.5) |
blocked |
SELECT * FROM #t WHERE id = 1 (temp tables) |
read |
SELECT 1 DELETE FROM t (batch without ;) |
blocked |
SELECT 1 DROP DATABASE x |
blocked |
DENY |
blocked |
SHUTDOWN |
blocked |
THROW, CHECKPOINT, RECONFIGURE, KILL |
classified as write |
EXECUTE AS …, EXEC AS …, SETUSER, REVERT, EXEC('SETUSER …'), sp_executesql N'EXECUTE AS …' |
blocked |
WITH EXECUTE AS OWNER|CALLER|SELF|'user' in a definition |
blocked |
A column named execute_as, or EXECUTE AS inside a string literal |
read |
| Read-only session on the database side | not covered: SQL Server has no engine-level read-only session. Give the connection a separate read credential with SELECT privileges only |
| Statement and lock timeout on the database side | covered: PDO query timeout, SET LOCK_TIMEOUT |
| TLS | new connections use Encrypt=true and TrustServerCertificate=false; enforcement rests on the ODBC driver |
KILL through Stop query |
needs VIEW SERVER STATE and ALTER ANY CONNECTION on the connection’s login; Stop ends the session |
SQLite
| Construct | Status |
|---|---|
ATTACH, DETACH |
blocked |
VACUUM … INTO |
blocked |
VACUUM (alone) |
classified as write |
load_extension( |
blocked |
PRAGMA table_info(t) and the other allowed read-only pragmas (table_xinfo, index_list, index_info, index_xinfo, foreign_key_list, database_list, table_list, collation_list, function_list, compile_options) |
read |
PRAGMA x = …, PRAGMA x(v) for a setting, any pragma not on the list |
blocked |
A pragma added with QUERYPROXY_EXTRA_SQLITE_PRAGMAS |
allowed in the bare form only |
EXPLAIN VACUUM INTO '/tmp/x' |
blocked |
Files outside QUERYPROXY_SQLITE_ALLOWED_DIR |
setting filled: rejected. Setting empty: any path outside the application directory is allowed and the connection form shows a warning |
| Files inside the application directory (QueryProxy’s default SQLite database lives there) | always rejected, whatever the setting |
| Read-only session and timeout on the database side | PRAGMA query_only for reads and a busy timeout; there is no statement timeout |
All dialects
| Construct | Status |
|---|---|
A listed function or procedure name inside a string literal, a comment or a dollar-quoted body (SELECT 'pg_sleep(1)', SELECT 1 -- dblink() |
not affected |
DESC analyze (a table named analyze) |
blocked (over-blocking) |
| Code inside functions and procedures defined on the server | not covered |
EXECUTE IMMEDIATE '<DML without WHERE>' |
blocked |
EXECUTE IMMEDIATE '<DML with WHERE>' |
classified as write |
EXECUTE IMMEDIATE <variable, expression or concatenation> |
blocked |
| A statement that begins with an unknown keyword | classified as write |
Known limits
The guard does not cover everything. These cases are known and left as they are. The guard is no substitute for approval or for database privileges.
- PostgreSQL catalog names. Only
pg_catalog,pg_toastand an unqualifiedpg_…name are judged as catalog tables. A table inpg_tempor in another schema (public.pg_x) is treated as a user table. - SQLite and SQL Server. Writes to their catalog tables (
sqlite_master,sys.…) are not covered by the catalog rule. - Catalog DDL. The catalog rule judges
INSERT,UPDATE,DELETE,MERGEandREPLACE.TRUNCATE,DROPandALTERon a catalog table are outside it. - MySQL and MariaDB stored code. The
SETstatements inside the body of aCREATE PROCEDURE,CREATE TRIGGERorCREATE EVENT … DOare not inspected. - MySQL and MariaDB
SET PASSWORDlists. None of theSETrules above (guarded variables,sql_mode, the read-only switch) is applied to the further assignments in aSET PASSWORDstatement. The statement is not checked as aSET.
Because of these limits, the guard is not a security boundary on its own. Give the database user QueryProxy connects with only the privileges it needs (least privilege).
Dialects and the conservative fallback
The parser is MySQL-dialect-first. Statements it cannot fully parse (PostgreSQL
casts like ::jsonb, driver-specific operators) are still guarded
best-effort by keyword: an unparseable UPDATE without WHERE is still
rejected, an unparseable SELECT still gets a LIMIT appended — and anything
unclassifiable is treated as a write, which means full approval scrutiny.
When in doubt, QueryProxy errs on the strict side.