QueryProxy

SQL Server access control and query approval

SQL Server decides which logins and users can reach which objects. It does not decide whether this particular statement, typed by a person at 3 a.m., should run. QueryProxy adds that decision: developers submit T-SQL, the statement is parsed and checked before it can enter the queue, a DBA approves it, and the result is masked before it is stored. The SQL Server password stays in QueryProxy’s vault and is never handed to a developer.

What do you have to install first?

SQL Server is the one engine the default image cannot reach. The Docker image ships pdo_pgsql, pdo_mysql and pdo_sqlite. To proxy SQL Server:

  1. Extend the Dockerfile with Microsoft’s ODBC driver and the sqlsrv and pdo_sqlsrv PECL extensions.
  2. Build your own image and run it in place of queryproxy/queryproxy.
  3. Make sure the extensions are in the PHP build that runs both the web process and the queue worker, because approved queries execute on the worker.

On a manual install the same applies: pdo_sqlsrv has to be present for SQL Server targets. The configuration guide has the details. Until this is done, a SQL Server connection cannot run a query.

What does a login’s permission leave open?

Permissions scope a login to databases, schemas and objects, and you should tighten them first. They still leave gaps:

  • A user who may UPDATE a table can run an UPDATE with no WHERE.
  • A user who may SELECT the users table receives every email address in it, unmasked, on a laptop.
  • Nobody else sees the statement before it runs.

SQL Server also has procedures that leave the database engine. Microsoft’s documentation says xp_cmdshell spawns a Windows command shell, and the command runs with the same rights as the SQL Server service account. It is off by default and enabled with sp_configure (read on 2026-10-13). QueryProxy blocks both the xp_* family and sp_configure at submission, so a request cannot even ask. What database access control is explains why permissions alone do not close these gaps.

What does QueryProxy block on SQL Server?

Every submission is parsed before it reaches the approval queue, and a request that breaks a guard is rejected at submission with an explicit message. The SQL guards reference lists the rules per dialect. The ones that matter most here:

  • UPDATE and DELETE without a WHERE, or with one that is always true (WHERE 1 = 1), are rejected. A top-level SELECT with no TOP gets TOP (1000), and a TOP above 10000 is clamped to TOP 10000. CTE and set-operation queries get no TOP; the 10000-row ceiling bounds them.
  • xp_*, sp_configure, sp_oa*, sp_addlinkedserver, sp_addlinkedsrvlogin, sp_serveroption, sp_addextendedproc and sp_execute_external_script are blocked, whether called as a statement or after EXEC.
  • OPENROWSET, OPENDATASOURCE, OPENQUERY and BULK INSERT are blocked.
  • GRANT, REVOKE, DENY and SHUTDOWN are blocked.
  • Procedures that run text they are handed, such as sp_prepare, sp_execute, sp_sqlexec, sp_msforeachtable and sp_send_dbmail, are blocked whatever the arguments.
  • THROW, RECONFIGURE, KILL and CHECKPOINT are classified as writes, so they go through approval.
DELETE FROM orders;                           -- rejected at submission
DELETE FROM orders WHERE id = 42;             -- allowed, still needs a DBA
EXEC xp_cmdshell 'dir';                       -- rejected at submission
SELECT 'xp_cmdshell';                         -- a plain read

The table names are examples; the verdicts follow the documented rules.

How is dynamic SQL handled?

EXEC('...') and sp_executesql take SQL as text, which is where T-SQL guards usually break. QueryProxy judges what it can read:

  • EXEC('DELETE FROM t') without a WHERE is blocked, and EXEC('DELETE FROM t WHERE id = 1') is classified as a write.
  • sp_executesql N'...' is judged by the usual rules on the text inside.
  • EXEC(@sql), EXEC('a' + 'b'), EXEC(...) AT srv and EXEC ... AS USER are blocked, because the guard cannot know what will run.
  • A nested literal such as EXEC('EXEC xp_cmdshell ''dir''') is blocked.
  • Dynamic SQL nested more than 3 levels deep is blocked.

The guard errs on the strict side: EXEC "quoted_name" and WHERE exec = 1 are blocked as well.

How do you connect a SQL Server database?

After the image change above, a DBA registers the database under Connections with the SQL Server driver (sqlsrv). The default port is 1433 (DbDriver.php, v0.2.5). Credentials are encrypted at rest with AES-256, and a developer sees a connection only after a DBA grants it. The post on production access without shared passwords explains why the password should stay in QueryProxy.

For read-only teams, register a separate connection with a login that has SELECT permission only, and point it at a replica where you can. The production hardening guide recommends this.

What happens to the result?

Rows are masked while they stream from the cursor to the result file, so an unmasked copy never exists on disk. Masking rules match a column name (such as *email*) or the content of a value, and a value that no rule matches comes back unchanged. Dynamic data masking explains the idea, and the data masking documentation lists the rule types and strategies. Every step, from submission to download, is written to an immutable audit log.

What are the limits on SQL Server?

  • The driver is yours to add. The default image has no SQL Server support, as described above.
  • The denylist is incomplete. It names dangerous syntax known today, and a spelling it does not name passes. The SQL guards reference says so itself: what authorizes a statement is the DBA approval, not the guard.
  • EXECUTE AS USER = 'x' is not covered. The guard does nothing about it.
  • Not yet run against live servers. The reference states that the SQL Server behaviour is verified against the guard’s unit tests, and that this dialect has not been run against a live server.
  • No read-only transaction or statement timeout on the database side yet. Both are listed as planned for v0.3.0. Until then, use a SELECT-only login for read connections.
  • Code stored on the server is not covered. A stored procedure that already exists in the database runs what it contains. EXEC dbo.my_proc 1 is classified as a write and goes to approval.
  • Masking is rule-based. A value that no rule matches is returned as it is, so QueryProxy does not claim to mask all personal data.

When do you not need QueryProxy?

If one person owns the database and is the only one who connects, there is no second person to approve anything, and permissions plus a read-only login are enough. The same holds for a development database that holds no real data. If your team only needs a better SQL client, see QueryProxy compared with DBeaver and DataGrip. QueryProxy earns its place when people need ad-hoc access to data that matters, and you want a second pair of eyes and a record of what ran.

Read the approval workflow for how a request moves, or compare QueryProxy with a bastion host. PostgreSQL teams can start from PostgreSQL access control.

Frequently asked

Does QueryProxy support SQL Server out of the box?
No. The default Docker image ships pdo_pgsql, pdo_mysql and pdo_sqlite. To proxy SQL Server you extend the Dockerfile with Microsoft's ODBC driver and the sqlsrv and pdo_sqlsrv PECL extensions, then run your own image in place of queryproxy/queryproxy. The extensions must be present in the PHP build that runs both the web process and the queue worker.
Why does the driver have to be on the worker as well as the web process?
Approved queries execute on the queue worker, not in the web process. If the worker's PHP build lacks the SQL Server extensions, a request can be approved and then fail when it runs.
Does QueryProxy block xp_cmdshell?
Yes. Any xp_* procedure, called as a statement or after EXEC, is blocked at submission, together with sp_configure and sp_oa*. A name that only appears inside a string literal, such as SELECT 'xp_cmdshell', is a plain read.
Can a developer run EXEC with a SQL string built at run time?
Not when the text cannot be read. EXEC(@sql) and EXEC('a' + 'b') are blocked. EXEC with a literal string is judged by the usual rules, so a DELETE without WHERE inside it is blocked and one with a WHERE is classified as a write.
How many rows does a SQL Server SELECT return?
A top-level SELECT without TOP gets TOP (1000); CTE and set-operation queries are bounded by the 10000-row ceiling instead. A TOP above 10000 is clamped to TOP 10000. Forms the guard cannot evaluate, such as TOP (@v) or TOP 10 PERCENT, are blocked.

Arrow keys to move, Enter to open.