QueryProxy changelog
The current QueryProxy version is 0.2.7 (October 18, 2026). This page lists the notes of all 12 published releases, newest first.
Release notes are mirrored unchanged from the QueryProxy repository.
Version 0.2.7
View on GitHub (0.2.7) (external site)
Security (0.2.7)
EXPLAIN,KILLandWITHbodies are parsed with bounded memory. The inner statement behind these keywords was parsed by a parser that kept every error with a full backtrace, so a 63 KB dotted-name chain behindEXPLAINpeaked at about 300 MB and could exhaust a 256 MBmemory_limit. Inner bodies now keep only the first error and stay well under 64 MB on the same input.UPDATEandDELETEstatements the guard cannot parse are refused. When such a statement could not be parsed, the guard fell back to a text search forWHERE, which also matched theWHEREof a subquery and let some statements without their ownWHEREclause through. They are now refused as too complex to verify.
Fixed (0.2.7)
- Nested
DOblocks no longer repeat the same refusal for one language literal; independent violations keep their own messages. Which statements are accepted or refused is unchanged. - The test suite no longer needs a generated
APP_KEY:phpunit.xmlsets a fixed, non-secret test key.
Changed (0.2.7)
QUERYPROXY_EXECUTION_TIMEOUTis capped at 86400 seconds. A valid value above 86400 is clamped to 86400 instead of saturating in the application or overflowing the image entrypoint's arithmetic. The entrypoint prints a warning at container start when it clamps; the application clamps silently.- Inspecting a large
DObody is faster (a 63 KB body takes about 29% less time), because lexing results are shared instead of repeated.
Version 0.2.6
View on GitHub (0.2.6) (external site)
Fixed (0.2.6)
QUERYPROXY_EXECUTION_TIMEOUTis read by one rule everywhere. The application config, the queue'sretry_afterand the image entrypoint now all accept only a whole number of seconds greater than zero and fall back to 300 for anything else (text, empty,0,000, negative, decimal, a value with a unit,true). Before, the entrypoint took0and000literally and derived a 10-second worker timeout from them, and a value such astruewas read as 1 second by the config.retry_afterfor an invalid value is 330, as for the default. The config falls back silently; the entrypoint still warns at container start.- The
sql_moderefusal pointed at the wrong cause. The message told you to removeNO_BACKSLASH_ESCAPES/ANSI_QUOTESfrom the server's or the user's defaultsql_mode, but QueryProxy overrides the session's mode when it connects, so a default cannot be the source. It now says the flag was set after connecting (for example by a proxy or connection pooler that rewrites session variables) and to look there. DO LANGUAGE E'SQL' …was refused with two messages for the same problem; it now gets one. The refusal itself is unchanged.
Security (0.2.6)
Tightening of the SQL guard. Unless noted, every item below is a refusal at submission, with the same explicit message as the existing rules. The guard remains a layer of defence in depth; the DBA approval is still what authorizes a statement.
- Role and user switches are refused.
SET [SESSION|LOCAL] ROLE,RESET ROLE(also writtenRESET "role"),SET … SESSION AUTHORIZATION,RESET SESSION AUTHORIZATION, MySQL and MariaDBSET ROLE/SET DEFAULT ROLE, andset_config('role', …)/set_config('session_authorization', …).roleandsession_authorizationjoin the built-in list of guarded settings, whichQUERYPROXY_EXTRA_DANGEROUS_VARIABLEScan only extend. ALTER SYSTEMis refused on PostgreSQL, whatever it sets.- Writes to the system catalog are refused. PostgreSQL:
INSERT,UPDATE,DELETEandMERGEintopg_catalog,pg_toastandpg_…tables. MySQL and MariaDB: the same statements andREPLACEagainst themysqlschema. Qualified, quoted and CTE-wrapped spellings are covered. On MySQL and MariaDBUSE mysqlis refused as well (also insidePREPARE,EXECUTE IMMEDIATEandSET STATEMENT … FOR), so that a name without a schema cannot be resolved against the catalog. Reading the catalog (SELECT * FROM pg_proc,SELECT … FROM mysql.user) still passes. READ WRITEis refused inSET TRANSACTION,SET SESSION CHARACTERISTICS,BEGINandSTART TRANSACTION, so a submission cannot lift a read-only transaction.READ ONLYstill passes.- Writes to the MySQL / MariaDB read-only switch are refused in every
scope and spelling (
transaction_read_only,tx_read_only,SESSION,GLOBAL,@@…,SET STATEMENT … FOR). - A MySQL / MariaDB write whose default database is
mysqlis refused at execution. A table name without a schema would otherwise resolve inside the system schema. The executor readsDATABASE()on the connection before the first statement; a single-statement read and a connection with no default database are not affected. ALTER DATABASE|ROLE|USER … RESETof a guarded setting (orALL) is refused on PostgreSQL, and the action of aCREATE [OR REPLACE] RULE(DO [ALSO|INSTEAD] …) is now inspected by the guard's own rules instead of being skipped.- SQL Server impersonation is refused:
EXECUTE AS,EXEC AS,SETUSERandREVERT, including insideEXEC('…')andsp_executesql, andWITH EXECUTE ASin object definitions. A column namedexecute_asand the text inside a string literal are not affected. - A very long dotted-name chain could stall a worker. The guard re-walked the rest of the chain at every part, so the cost grew with the square of the chain length and a statement near the editor's size limit could keep a PHP process busy for a long time. It now scans in linear time. No refusal was added or relaxed by this change.
- The new rules apply inside
DOand routine bodies,PREPAREandEXECUTE IMMEDIATEtext, CTE bodies,EXPLAIN ANALYZE, MariaDBANALYZEandSET STATEMENT … FOR.
Version 0.2.5
View on GitHub (0.2.5) (external site)
Added (0.2.5)
php artisan queryproxy:mask-error-messages [--dry-run]masks the error messages already stored on query requests and the copy of them inside in-app "request failed" notifications, using each connection's current rules (a request or connection that no longer exists gets the fixed message). Safe to run repeatedly; the immutable audit log is left untouched and the run is recorded as oneerror_messages.maskedentry (scanned, changed, replaced by the fixed message, skipped; and scanned, changed, replaced by the fixed message and skipped for notifications) even when it stops halfway. A record whose connection rules cannot be read is skipped, left unchanged and reported, and the command exits non-zero.- DDL flag on requests. A request that contains DDL now carries a
DDLbadge in the approvals queue and on the request page, and the Slack and Teams "new request" messages say so, so the approving DBA sees it before deciding. The flag was already computed and stored; it was never shown.
Fixed (0.2.5)
- A request failed by the job's
failed()handler left no trace. It now writes therequest.execution_failedaudit entry (withsource: job_failed) and notifies the requester, as a failure inside the executor already did. A request that has already completed is still never flipped. - The Teams HMAC docblock described the key as
base64_decoded_secret; it now states what the code does: the secret base64-decoded in strict mode, or the raw secret when it is not valid base64. - Auditors could not reach the Requests list from the menu. The sidebar hid the link for the auditor role although the page, the route and the policy already allowed it. Auditors now see the team's requests from the menu; result data stays closed to them.
- The Slack and Teams "account not linked" replies, the chat-ops setup steps and the add-member error pointed to "Admin → Users"; they now name the actual screen, Teams & Users → Manage users.
Security (0.2.5)
- Error messages of failed queries are masked like result data. Driver
errors echo the offending values (
Duplicate entry '[email protected]',Key (phone)=(...), the SQL with its bindings), and the message was stored and shown unmasked in the request, the audit log and the in-app notification. It now goes through the connection's masking rules first: content rules mask matches in place, and when the message names a column covered by a column rule every literal value in it is masked. The raw message stays in the application log only. This closes the gap in ADR-014 for new failures. It does not rewrite history: audit entries written by earlier versions (request.execution_failed,execution.late_completion) keep the raw text, because the audit log is immutable. Runphp artisan queryproxy:mask-error-messagesto mask the messages stored on query requests and in the in-app "request failed" notifications by earlier versions. - The job
failed()safety net now redacts and masks too. It stored the raw exception message; it now uses the same redact-and-mask path as the executor.
Version 0.2.4
View on GitHub (0.2.4) (external site)
Fixed (0.2.4)
- The live PostgreSQL test that lifts a read-only transaction failed when
pdo_pgsqlwas built against libpq older than 17: the driver frees prepared statements withDEALLOCATE, which takes a snapshot and makes PostgreSQL refuse the switch back to read-write. The test's setup statements now run unprepared. The guard itself is unchanged.
Security (0.2.4)
league/commonmarkupdated to 2.10.3. 2.10.0 is affected by GHSA-3q6v-r5mr-hxv8 (high) and GHSA-97jj-33gv-5xf9 (medium). The release image scan failed on them, so the container images for 0.2.1, 0.2.2 and 0.2.3 were never published; 0.2.4 is the first image after 0.2.0 and carries all of their changes.
Version 0.2.3
View on GitHub (0.2.3) (external site)
Changed (0.2.3)
- CI and the live test setup.
ci.ymlruns with a read-onlycontentstoken and checks out without persisting credentials, aslive-pgsql.ymlalready did. PostgreSQL for thelive-pgsqlgroup (the workflow's service anddocker-compose.test.yml) is initialised with--auth-host=scram-sha-256, because the image trusts loopback otherwise and thedblink_exectest never sent the password it is meant to prove it passes. Thedblink_exectest now passeshost,portandpasswordin an escaped connection string (QUERYPROXY_LIVE_PGSQL_SERVER_HOST/_SERVER_PORToverride the address the server sees itself at). The compose file listens on55432inside the container as well, so the documented command no longer needsQUERYPROXY_LIVE_PGSQL_SERVER_PORT, and keeps its data directory on tmpfs so every container starts from a freshinitdb. If you run the compose example locally, recreate the container once (docker compose -f docker-compose.test.yml down, thenup -d --wait).
Fixed (0.2.3)
- A write refused by a read-only session was silently run again. Laravel
counts PostgreSQL SQLSTATE
25006and MySQL/MariaDB's--read-onlyerror as a lost connection, so outside a transaction it reconnected and re-ran the write on a fresh, writable session. The lost-connection detector now reports read-only errors as not lost and defers everything else to the framework's detector. Trade-off: after a MySQL/Aurora failover that leaves a connection on a read-only node, or on a PostgreSQL hot standby, the request fails with the database's error instead of being retried on a new connection; a queue worker that lands there no longer stops itself on25006either, so restart workers after such a failover. - Large SQL took seconds and hundreds of megabytes to inspect. The guard
lexed the same SQL several times per inspection, kept lexer errors with their
backtraces and re-scanned its lexical ranges linearly. Each SQL text is now
lexed once per inspection, dialect ranges are kept sorted and binary-searched,
and the parser no longer leaves a cycle of garbage behind each request. On
the worst 64 KiB SQLite input that was tried (
[a],repeated) inspection went from 15.9 s and 589M of peak memory to 0.69 s and 21.5M, measured on a development machine under load; the verdicts do not change. New tests pin the lex count, the memory peak and a time bound relative to a bare lexer pass.
Security (0.2.3)
- PostgreSQL
DObodies were read with the MySQL lexer. The guard scanned aDObody for blocked functions with MySQL's string rules, so a body such asPERFORM 'a\', pg_read_file(…) --hid the call inside what the lexer took for a string, while PostgreSQL ran it. Block and routine bodies (DO,CREATE FUNCTION,CREATE PROCEDURE) are now read with PostgreSQL's lexical rules (comments,'…',E'…',U&'…', quoted identifiers, nested dollar-quoted strings) and go through the same blocked-function and guarded-setting rules as top-level SQL. Behaviour change: the following are now rejected.- Dynamic SQL (
EXECUTE) inside aDObody or a function or procedure body, trigger functions included: the statement it runs is built at run time and cannot be inspected. TriggerEXECUTE FUNCTION|PROCEDUREclauses are not affected. - Any language other than
plpgsqlandsqlforDO,CREATE FUNCTIONandCREATE PROCEDURE. A quoted language name keeps its case, soLANGUAGE "PLPGSQL"is refused.CREATE LANGUAGEandALTER LANGUAGE … RENAMEare refused too. BEGIN ATOMICbodies, andCREATE FUNCTION/CREATE PROCEDUREwithout a body.query_to_xml*,ts_stat,ts_rewrite,crosstab*andconnectbyjoin the blocked functions: they run SQL passed to them as a string.- A
SETclause on a routine (CREATE/ALTER FUNCTION|PROCEDURE … SET), a database (ALTER DATABASE … SET) or a role (ALTER ROLE|USER … SET) now goes through the guarded-setting rule; one whose setting cannot be resolved is rejected.
- Dynamic SQL (
sql_modecould change how the guard's lexer and the server read quotes.NO_BACKSLASH_ESCAPESandANSI_QUOTESchange what a backslash and a double quote mean, so the guard could approve SQL the server then reads differently.sql_modeis now a guarded variable in every scope and on every driver:SET sql_mode,SET SESSION sql_mode,SET @@sql_mode, the@@SESSION./@@GLOBAL.forms, a version comment around them andset_config('sql_mode', …)are refused. Reading it (SELECT @@sql_mode) is unaffected. Onmysqlandmariadbthe executor also reads@@SESSION.sql_modebefore running the SQL and fails the request, without running anything, when it containsNO_BACKSLASH_ESCAPESorANSI_QUOTES. Today QueryProxy sets a strictsql_modeon every connection it opens (Laravel'sstrictoption), which replaces the server's default, so 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. The/*M!behaviour on themysqldriver is unchanged and now pinned by a regression test.- Three
SETspellings were not judged.SET GLOBAL general_log := 1(a space before:=) read as a label and passed the dangerous-variable list;SET @@general_log = 1(no scope) was not judged at all; and in MariaDBSET STATEMENT … FOR <statement>the statement afterFORskipped every rule, soSET STATEMENT max_statement_time=1 FOR DELETE FROM tran without aWHEREcheck. All three are now judged. The statement afterFORgoes through the same rules as a top-level one (type, denylist,WHERE,LIMIT, nesting depth);SET STATEMENTwithout a statement afterFORis rejected. Behaviour change:SET @@<name>is read as a session variable on every driver, so on PostgreSQL (and when the target database is unknown)SET @@general_log = 1is now refused.
Notes (0.2.3)
- PostgreSQL
DOblocks remain exempt from theWHERErule: they are classified as a write and always go through human approval. Dynamic SQL inside them is now refused, but a plainly writtenDELETEwithoutWHEREin aDObody still passes the guard. - Not covered yet: DML against system catalogs (
UPDATE pg_language,UPDATE pg_proc SET prosrc …; these need a superuser) andALTER SYSTEM SET. Multibyte character sets that change backslash handling (SET NAMES big5,sjis,gbk) are not checked either.
Version 0.2.2
View on GitHub (0.2.2) (external site)
Added (0.2.2)
- Live PostgreSQL guard tests. A
live-pgsqlPest group intests/Liveruns the guard against a real PostgreSQL server and checks both the verdict and the effect on the server (settings,dblink_exec,pg_terminate_backend,pg_cancel_backend,EXPLAIN ANALYZE DELETE,DOblocks and more). It is skipped unlessQUERYPROXY_LIVE_PGSQL_HOSTis set.docker-compose.test.ymlstarts a throwaway PostgreSQL 17 for local runs, and the newlive-pgsqlGitHub Actions workflow runs the group in CI.
Fixed (0.2.2)
- The queue timing warning missed
queue:listenandqueue:work --once. The warning thatretry_afteris at or below the execution timeout was logged only when a daemonqueue:workstarted. It is now also logged whenqueue:listenorqueue:work --oncestarts, once per process; the short-livedqueue:work --oncechildren thatqueue:listenspawns do not repeat it. - The Compose worker example hard-coded
--timeout=310.docker-compose.ymlnow passes the timeout the image entrypoint derives (QUERYPROXY_WORKER_TIMEOUT,QUERYPROXY_EXECUTION_TIMEOUT+ 10), so changingQUERYPROXY_EXECUTION_TIMEOUTin the example also moves the worker timeout. The example needs the image's default entrypoint.
Security (0.2.2)
- PostgreSQL settings that load code or lift protections were not guarded.
The dangerous-variable list now also names
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_pagesandignore_checksum_failure. Behaviour change: on PostgreSQL (and when the target database is unknown) these are refused in every scope:SET <name>,SET LOCAL <name>,SET SESSION <name>, theTOspelling andset_config('<name>', …). Before this release no PostgreSQL name was on the list. On MySQL, MariaDB, SQLite and SQL Server theSETrule is unchanged (only the persistent scopesGLOBAL,PERSISTandPERSIST_ONLYare judged), whileset_config('<name>', …)with one of the new names is refused on every driver. Harmless settings such aswork_memorsearch_pathstill pass.QUERYPROXY_EXTRA_DANGEROUS_VARIABLESstill extends the list. - MariaDB
ANALYZE <statement>ran its statement unchecked. LikeEXPLAIN ANALYZE, MariaDB'sANALYZE [FORMAT = x] <statement>executes the statement. It is now judged as the statement inside it (type, denylist,WHERErule,LIMIT, nesting depth). OnlySELECT,WITH,INSERT,REPLACE,UPDATE,DELETEor a parenthesized query may follow; anything else (DROP,TRUNCATE,SET, a nestedANALYZE, …), and a parenthesized(ANALYZE <statement>), is rejected. The mysql driver and an unknown driver are judged the same way.ANALYZE TABLE …(maintenance) is unchanged. - Multibyte comments could hide the real statement from
EXPLAIN ANALYZEandANALYZE. The guard cut the inner statement at a character offset where a byte offset was needed, so a comment with non-ASCII text in front of it shifted the cut and let aDELETEwithoutWHEREthrough. Offsets are now converted to bytes. - MariaDB executable comments were not inspected.
/*M!100000 … */was treated as a plain comment, so its body ran on MariaDB without being checked. On themariadbdriver its body is now read as SQL, whatever the version condition. On themysqldriver/*M!is still a plain comment, so connect a MariaDB server with themariadbdriver. Nesting deeper than 3 levels is rejected, and when the target database is unknown a/*M!comment is rejected outright. - Version-conditional comments (
/*!NNNNN … */) could hide a clause from the guard. A server skips the body when its version is lower than the one written, so aWHEREorLIMITinside it may or may not exist. On themysqlandmariadbdrivers the statement is now checked twice, once as if the bodies run and once as if the server skips them, and is rejected when either reading is refused or when the two readings differ in statement count, transaction state, type or prepared text. Behaviour change: these are now rejected onmysqlandmariadb, which 0.2.1 accepted: 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. A request with a single version and a plain body is unaffected. - SQL that was not valid UTF-8 slipped past the lexer. A stray byte inside
a comment (
DROP DATABASE prod /* é\xFF */) made the guard read an empty statement. SQL that is not valid UTF-8 is now rejected (SQL must be valid UTF-8.), and so is text in which the lexer finds no statement (QueryProxy could not read this SQL: …). UPDATE/DELETEwith an always-trueWHEREpassed.WHERE 1 = 1,WHERE true,WHERE 'a' = 'a'or anORwith such an operand counted as aWHEREclause. Behaviour change: these are now rejected like a missingWHERE, in every statement the guard looks into (CTE bodies,EXPLAIN ANALYZE, MariaDBANALYZE, T-SQL batches, dynamic SQL). Detection is static: literals, comparisons,IS [NOT] NULL|TRUE|FALSE,AND,OR,NOTand parentheses are evaluated, and an operand it cannot evaluate never counts as true. AWHEREclause nested too deeply or too large to evaluate is rejected as well (… too complex to verify).- A byte-order mark or Unicode whitespace could hide keywords. The lexer
glues characters such as U+FEFF, U+00A0, U+2028 or U+3000 to the neighbouring
word while a server may read them as separators, so
\u{FEFF}DROP DATABASE prodshowed noDROP. Behaviour change: these characters are rejected outside string literals, quoted identifiers and comments, on every driver and inside dynamic SQL. - PostgreSQL reads
\and#differently from the lexer. On PostgreSQL (and when the target database is unknown) a backslash is now rejected anywhere except in comments, dollar-quoted bodies,E'…'strings and well-formedU&strings, and#is rejected outside literals, quoted identifiers and comments. A plain string such as'a\'is therefore rejected on PostgreSQL even though MySQL and MariaDB behave as before.
Notes (0.2.2)
- PostgreSQL
DOblocks remain exempt from theWHERErule: they are classified as a write and always go through human approval, where the reviewer sees the whole body. ADELETEwithoutWHEREinside aDOblock therefore passes the guard.
Version 0.2.1
View on GitHub (0.2.1) (external site)
Fixed (0.2.1)
retry_afterfor the database, Beanstalkd and Redis queues no longer falls back to Laravel's 90 seconds, which is below the worker's 310-second timeout and let a slow query be re-reserved while it was still running and then marked failed (notably withQUEUE_CONNECTION=redis). It is now derived fromQUERYPROXY_EXECUTION_TIMEOUT(timeout + 30 seconds, 330 by default) instead of a fixed number. An explicit*_QUEUE_RETRY_AFTERstill wins; a value at or below the timeout logs a warning when aqueue:workworker starts. The Docker entrypoint derives the worker--timeout(timeout + 10) andstopwaitsecs(timeout + 20) the same way.- A query that finished late could turn a Failed request into Completed. The
executor now moves a request to Completed only if it is still Running. A late
result is discarded, the result file is removed, and an
execution.late_completionaudit entry is written; for a write it records that the write was committed.
Security (0.2.1)
EXPLAIN ANALYZE <DML>was classified as a read and ran the DML.EXPLAIN ANALYZEexecutes the statement it explains, but the guard treated everyEXPLAINas a read, soEXPLAIN ANALYZE DELETE FROM tskipped theWHERErule, the write classification and the approval flow. AnEXPLAINthat analyzes is now judged as the statement inside it (type, denylist andWHERErule);EXPLAINwithoutANALYZEstays a read. The option list is checked against PostgreSQL's known options, and an option block the guard cannot read (quoted or unknown options, an unclosed list, an expression value) is rejected.SELECT … INTO <table>created a table but was classified as a read. It is now a write and DDL, and noLIMITis added.SELECT … INTO @varstays a read;INTO OUTFILE/INTO DUMPFILEwere already denied.- Functions with side effects passed as reads. Calling
nextval,setval, the advisory-lock family,get_lock,release_lock,pg_sleep*,sleeporbenchmarknow makes a statement a write.pg_terminate_backend,pg_cancel_backend,pg_reload_conf,pg_rotate_logfile,pg_promote,pg_switch_wal,dblink*and the server-side file functions (pg_read_file,pg_read_binary_file,pg_ls_dir,lo_import,lo_export,load_file) are denied.set_configis a write, and is denied when its first argument is not a literal. Names are matched after quoting andU&escapes are resolved.dblink*is a prefix match, so a user-defined function that starts withdblinkis denied too. The newQUERYPROXY_EXTRA_BLOCKED_FUNCTIONS,QUERYPROXY_EXTRA_STATE_CHANGING_FUNCTIONSandQUERYPROXY_EXTRA_RESOURCE_CONSUMING_FUNCTIONSsettings extend these lists, andQUERYPROXY_EXTRA_BLOCKED_PROCEDURES(SQL Server, below) andQUERYPROXY_EXTRA_SQLITE_PRAGMAS(SQLite, below) do the same for theirs; they can only add to the built-in entries. - SQL Server and SQLite constructs that reach outside the database were not
covered. On SQL Server the guard now denies
xp_*,sp_configure,sp_oa*,sp_addlinkedserver,sp_addlinkedsrvlogin,sp_serveroption,sp_addextendedproc,sp_execute_external_script,OPENROWSET,OPENDATASOURCE,OPENQUERYandBULK INSERT, and also deniesDENYstatements. A batch without semicolons is split at T-SQL statement boundaries and every part is checked, soSELECT 1 DELETE FROM tis rejected. On SQLite it deniesATTACH,DETACH,VACUUM … INTOandload_extension(, and only allows a built-in set of read-onlyPRAGMAs (table_info,table_xinfo,index_list,index_info,index_xinfo,foreign_key_list,database_list,table_list,collation_list,function_list,compile_options), whichQUERYPROXY_EXTRA_SQLITE_PRAGMAScan extend (bare form only); anyPRAGMAthat sets a value, and any not on the list, is rejected. The connection form warns whenQUERYPROXY_SQLITE_ALLOWED_DIRis empty. - Dynamic SQL was not inspected.
EXEC('…'),sp_executesql,EXECUTE IMMEDIATE '…', MySQLPREPARE … FROM '…'and PostgreSQLPREPARE … AS <statement>are now checked together with the SQL they carry. When the SQL is a literal it is judged by the same rules as a top-level statement; when it is a variable, an expression, a concatenation or anEXEC(…) AT <server>, the request is rejected. This can reject legitimate use: code that builds SQL at runtime and passes it through these forms no longer runs through QueryProxy. Behaviour change: on SQL Server these 14 system procedures are rejected whatever their arguments are: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_jobstepandsp_update_jobstep.sp_executesqlis not on the list; its SQL text is inspected. Dynamic SQL nested more than 3 levels deep is rejected. UPDATE/DELETEinside a CTE skipped theWHERErule. The bodies ofWITH … AS (…)and the statement that follows theWITHare now checked, including nestedWITHs up to 3 levels; aWITHthe guard cannot read is rejected.INSERTandMERGEbodies are not checked separately; the outer statement is a write.- SQL Server
TOPwas only half enforced. ASELECTwithoutTOPwas not bounded by the guard, andTOPvalues withPERCENT, decimals or expressions were read as plain numbers. An existingTOP n/TOP (n)stays capped at 10000; now a top-levelSELECTwithoutTOPgetsTOP (<default row limit>), andTOP … PERCENT, expressions and decimals are rejected. ForWITH,OFFSET,UNION,EXCEPTandINTERSECTnoTOPis added; the executor's row cap applies. - New teams started with no masking rules. A team created from now on starts with the default masking rules. Existing teams are not changed (see Upgrading).
Upgrading (0.2.1)
- Remove the fixed
retry_afterlines from your.env. The 0.2.0.env.exampleshippedDB_QUEUE_RETRY_AFTER=330as an active setting, and an explicit value always wins over the derived one. If that line is still in your.env(orREDIS_QUEUE_RETRY_AFTER=330,BEANSTALKD_QUEUE_RETRY_AFTER), delete it unless you set it on purpose; otherwise raisingQUERYPROXY_EXECUTION_TIMEOUTleavesretry_afterat 330, a long query is re-reserved while it still runs, and a write can commit while its request is marked Failed. A--timeoutset by hand on a separately run worker (the Docker image derives it itself) should be the execution timeout + 10 seconds. - Existing teams do not get masking rules automatically. A DBA opens the team's Masking page and runs Add default rules. Query Studio and the Masking page now show a banner for a team that has no masking rules, or none enabled.
- Some statements now need approval.
SELECTstatements that callnextval,setval, an advisory-lock function,get_lock,release_lock,pg_sleep*,sleep,benchmarkorset_configare classified as writes, so they go through the approval flow. - Some statements that used to pass are now rejected. In particular:
EXPLAIN ANALYZEon aDELETE/UPDATEwithoutWHERE, dynamic SQL built from a variable or an expression, the 14 SQL Server procedures listed under Security, SQL ServerTOP … PERCENT, anyPRAGMAoutside the allowed set on SQLite, and, on PostgreSQL, anyUESCAPEclause and anyEXPLAINoption block the guard cannot read.SELECT … INTO <table>is now a write. - SQL Server
SELECTwithoutTOPnow returns 1000 rows by default. The previous ceiling was 10000. - The full list is in the SQL guards wiki page.
Version 0.2.0
View on GitHub (0.2.0) (external site)
Changed (0.2.0)
- Breaking — set
TRUSTED_PROXIESwhen running behind a reverse proxy. It is empty by default. Until it names your proxy,X-Forwarded-Protois ignored, so HTTPS is not detected and audit entries record the proxy's address rather than the client's. Never use*. - Breaking — a system administrator must set the ChatOps signing secret. DBAs keep control of the webhook URL and the enabled flag, but can no longer enter or rotate the secret. Existing installations should rotate it once after upgrading: any DBA who configured ChatOps already knows the current value.
- Breaking — Approve / Reject buttons in chat messages posted before this
version stop working, because they carry no action token. Decide those
requests in the web UI. Teams automations must start sending the
tokenfield from the card'squeryproxyenvelope. - A TOTP code is now single-use, so signing in on a second device within the same 30-second window requires waiting for the next code.
- Removing a ChatOps integration is administrator-only, matching the bar for setting its signing secret — a DBA could otherwise delete an integration and then be unable to put it back. DBAs still untick Enabled to switch one off.
TRUSTED_HOSTSaccepts extra hostnames for installations served under more than one name, sinceX-Forwarded-Hostis no longer honoured.- New content masking rules apply to new teams only. Run Masking → Add defaults per team to pick them up; it is idempotent and leaves existing rules untouched.
Security (0.2.0)
- ChatOps approvals could be made in someone else's name. The webhook HMAC proves only that the caller knows the signing secret, but the approver's identity was read straight out of the callback body — and any team DBA could overwrite the signing secret without knowing the old one. Together that let a DBA approve their own request as a colleague (or as an admin), defeating the four-eyes rule and writing that colleague's name into the audit trail. Signing secrets are now administrator-only and rotating one requires the current secret; every Approve / Reject control carries a single-use action token bound to one request; and the resolved approver must belong to the integration's team.
- Every proxy was trusted (
trustProxies(at: '*')). Any client could forgeX-Forwarded-Forand get unlimited login and 2FA attempts (both throttles are keyed per IP) and write a false client IP into the audit log, while a forgedX-Forwarded-Hostrewrote the link in password-reset mail. Trust is now opt-in throughTRUSTED_PROXIES,X-Forwarded-Hostis no longer honoured, andtrustHostspins the request host toAPP_URL. - Content masking rules silently skipped non-string values. PDO returns
native integers for
BIGINT/DECIMAL, so a numeric column that matched no column pattern — a card number or a national id stored asBIGINT— reached the result store unmasked. Content rules now see every scalar. - The SQL guard's denylist had bypasses.
SET @@GLOBAL.…walked straight past theSET GLOBALrule (arbitrary file write through the MySQL general log), andCREATE FUNCTION … SONAME,pg_read_file()and friends were not listed at all. The guard now judges the dangerous part rather than the keyword:CREATE EXTENSIONandDOare refused for untrusted procedural languages,SET GLOBAL/SET PERSISTfor file-path, logging and plugin variables, and aDObody is scanned for the unconditionally blocked statements — soDO $$ BEGIN DROP DATABASE prod; END $$does not get through. That scan is best-effort by nature: SQL a block assembles at run time (EXECUTE format(…)) cannot be read statically, and the DBA approval remains the real boundary. - TOTP codes could be replayed for the ~90 seconds their window stayed open;
an accepted code is now burned via
two_factor_last_used_timestamp. - Chat webhook URLs leaked into the logs. A connection error carried the full
URL — and for Slack the secret is the URL path — into
laravel.log. - CSV exports did not guard the header row against spreadsheet formula injection, although the data rows did; column names come from the target schema or the requester's own aliases.
- Query results could be published without authorization if the operator
pointed
QUERYPROXY_RESULT_DISKat a public disk: result paths are guessable, sostorage:linkexposed every team's rows. Such a disk is now refused. - Default masking rules gained content patterns for vendor API keys, JWTs,
PEM private keys and high-entropy secrets, so a secret hidden behind a column
alias (
SELECT api_key AS k) is still masked. The credit-card content rule was narrowed to real IIN ranges so it no longer fires on epoch timestamps. - Third-party GitHub Actions are pinned to commit SHAs, and the release workflow scans the image before any registry credential is on the runner.
Version 0.1.3
View on GitHub (0.1.3) (external site)
Changed (0.1.3)
- Image bases moved to
php:8.5-fpm-alpineandnode:26-alpine. OPcache is no longer installed explicitly: PHP 8.5 links it statically and enables it by default, anddocker-php-ext-install opcachefails there. - Release workflow actions updated to
docker/login-action@v4,setup-buildx-action@v4,setup-qemu-action@v4andmetadata-action@v6.
Version 0.1.2
View on GitHub (0.1.2) (external site)
Added (0.1.2)
php artisan queryproxy:create-admincreates the first administrator, andQUERYPROXY_ADMIN_EMAIL/QUERYPROXY_ADMIN_PASSWORD/QUERYPROXY_ADMIN_NAMEcreate it on first boot — a fresh instance no longer needs demo seeding to have an account to log in with.- Container
HEALTHCHECKagainst/up, sodocker runreports health without a compose file.
Changed (0.1.2)
- One-container install. The image now runs the queue worker and the
scheduler alongside the web tier, so
docker run -p 7432:7432 -v queryproxy-data:/var/www/html/storage/app queryproxy/queryproxyis a complete instance.QUERYPROXY_RUN_WORKER/QUERYPROXY_RUN_SCHEDULERturn them off for split deployments, andQUERYPROXY_WORKER_PROCESSESruns more than one worker.docker-compose.ymlcollapsed to that single service. - One volume. The SQLite database and the generated
APP_KEYmoved next to the result files under/var/www/html/storage/app. Volumes from earlier versions keep working: an existing/var/www/html/database/database.sqlite(or.app_key) is still used when present. - Releases publish to Docker Hub (
queryproxy/queryproxy) as well as GHCR.
Version 0.1.1
View on GitHub (0.1.1) (external site)
Added (0.1.1)
.dockerignore, so localdocker builduses the same clean context as CI instead of copying hostvendor/,node_modules/and.envinto the image.
Fixed (0.1.1)
- Demo seeding (
QUERYPROXY_SEED_DEMO=true) crashed the production container on first boot: the seeder relied on Faker, which is a dev-only dependency and absent from--no-devinstalls. The seeder no longer uses factories.
Version 0.1.0
View on GitHub (0.1.0) (external site)
The first public release.
Added (0.1.0)
- Teams & RBAC — Admin / DBA / Developer / Auditor roles, hard team isolation, per-developer connection grants.
- Connection vault — PostgreSQL, MySQL, MariaDB, SQL Server and SQLite targets with AES-256-encrypted credentials and a connection tester.
- Query Studio — CodeMirror editor with server-side AST guards:
WHERE-less
UPDATE/DELETErejected,LIMITinjection (default 1000) and clamping (cap 10000), explicit-transaction rule for multi-statement submissions, administrative statements blocked. - Approval workflow — pending queue, mandatory rejection reasons, self-approval prevention with audited admin override, requester cancellation, in-app notifications.
- ChatOps — interactive Slack approvals (HMAC-SHA256
v0signatures, ±5-minute replay window, member-ID identity mapping) and a Teams HMAC-verified action endpoint plus channel announcements. - Async execution — database-backed queue worker, cursor streaming to NDJSON result files with constant memory, atomic multi-statement transactions with rollback on failure.
- Dynamic data masking — column-pattern and content-regex rules with full / partial / hash strategies, applied at write time; default rule set and a test playground.
- Results — paginated viewer, streamed CSV export, configurable retention with automatic pruning.
- Immutable audit log — every security-relevant event, filterable auditor UI, CSV export.
- Password management — profile page (name + password change), enumeration-safe e-mail reset flow, admin password reset.
- Deployment — zero-config
docker compose up(app + worker + scheduler, SQLite default), published container imageghcr.io/queryproxy/queryproxy.
Frequently asked
- What is the current version of QueryProxy?
- The current version is 0.2.7, released on October 18, 2026. The list below carries the notes of every release and a link to its tag on GitHub.
- What format do the release notes follow?
- The notes follow the Keep a Changelog format, and versions are numbered with SemVer. Under each version, changes are grouped under headings such as Security, Added, Changed, Fixed and Upgrading.
- Where do I find upgrade notes and migration steps?
- A release that changes behaviour marks it as “Behaviour change” in its own notes, and upgrade steps are written into that release’s notes, for example under an “Upgrading” heading. For running QueryProxy in production, see the Production hardening guide.
- Is it safe to upgrade from one version to another?
- This page makes no compatibility promise. Read the notes of every release between your current version and the target, and look closely at items marked “Behaviour change”.