QueryProxy
Documentation menu

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.
  • LIMIT above the hard cap (10000)? It is clamped down.
  • LIMIT ALL (PostgreSQL) counts as unlimited and is clamped.
  • Offsets are preserved: LIMIT 10, 20000 becomes LIMIT 10, 10000, LIMIT 99999 OFFSET 4 becomes LIMIT 10000 OFFSET 4.
  • FETCH FIRST n ROWS ONLY and T-SQL TOP n are clamped the same way.
  • SQL Server targets get no injected LIMIT (it is not valid T-SQL); the guard works on TOP instead. An existing TOP n / TOP (n) is clamped to the hard cap (10000), and a top-level SELECT without one gets TOP (1000) (the default) after any DISTINCT / ALL. TOP … PERCENT, TOP (@v), expressions and decimals are rejected. For WITH, OFFSET, UNION, EXCEPT and INTERSECT no TOP is added; the worker’s row ceiling (the hard cap) bounds those, with the result marked as truncated.
  • A LIMIT the guard cannot understand as a plain row count (arithmetic like LIMIT 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 SCHEMA
  • GRANT, REVOKE, DENY
  • CREATE USER / ALTER USER / DROP USER (and ROLE / LOGIN variants)
  • CREATE FUNCTION … SONAME — loadable UDFs (and the CREATE AGGREGATE FUNCTION form)
  • SHUTDOWN
  • Server-side file / program IO: SELECT … INTO OUTFILE / INTO DUMPFILE, LOAD DATA, PostgreSQL COPY (including COPY … TO PROGRAM), the LOAD_FILE() function, and PostgreSQL’s file functions pg_read_file(), pg_read_binary_file(), pg_ls_dir(), lo_import() and lo_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* and connectby.

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 plpgsql and sql languages are accepted. A DO block without a LANGUAGE clause runs in PostgreSQL’s default, plpgsql; a CREATE FUNCTION / PROCEDURE needs an explicit LANGUAGE plpgsql or sql unless it has a SQL-standard RETURN body. 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, so LANGUAGE "PLPGSQL" is refused. CREATE LANGUAGE and ALTER LANGUAGE … RENAME are 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. Trigger EXECUTE FUNCTION|PROCEDURE clauses are not affected.
  • BEGIN ATOMIC bodies, and CREATE FUNCTION / CREATE PROCEDURE without a body, are refused.
  • A SET clause 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, PERSIST and PERSIST_ONLY, in both the keyword form (SET GLOBAL x = …) and the variable form (SET @@GLOBAL.x = …). A SESSION write is never guarded, and @name is a user variable, never a server setting. SET STATEMENT … FOR <statement> (MariaDB) is judged twice: its assignments by the rule above, and the statement after FOR as 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. 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_ESCAPES and ANSI_QUOTES change what a backslash and a double quote mean, which the guard cannot follow, so SET sql_mode, SET SESSION sql_mode, SET @@sql_mode and set_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.

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 the mariadb driver the body is read as SQL and nesting deeper than 3 levels is rejected. On the mysql driver /*M! stays a plain comment, so connect a MariaDB server with the mariadb driver. When the target database is unknown, /*M! is rejected outright.
  • Version-conditional comments (/*!NNNNN … */) on the mysql and mariadb drivers. 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_mode on MySQL and MariaDB. Besides refusing writes to it (above), the executor reads @@SESSION.sql_mode before it runs the SQL and fails the request, without running anything, when it contains NO_BACKSLASH_ESCAPES or ANSI_QUOTES. QueryProxy sets a strict sql_mode on 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-formed U& 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); MySQL, MariaDB and SQL Server have not been run against live servers.

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
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)
DENY blocked (;-separated or standalone forms)
Read-only transaction on the database side not covered (planned for v0.3.0)
Statement timeout on the database side not covered (planned for v0.3.0)
TLS: the connection uses sslmode=prefer not covered (setting planned for v0.3.0)

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
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 transaction on the database side not covered (planned for v0.3.0)
Statement timeout on the database side not covered (planned for v0.3.0)

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, REVERT, CHECKPOINT, RECONFIGURE, KILL classified as write
EXECUTE AS USER = 'x' not covered
Read-only transaction on the database side not covered (planned for v0.3.0)
Statement timeout on the database side not covered (planned for v0.3.0)

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 transaction and statement timeout not covered (planned for v0.3.0)

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

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.

Arrow keys to move, Enter to open.