# Threat model

> What QueryProxy protects, from whom, where the trust boundaries lie and which risks stay with you. The first published threat model for operators.

- Documentation: Reference
- Updated: 2026-10-25
- Source: https://queryproxy.com/docs/threat-model/
- Language: en-US
- Author: Muhammet Şafak

---
This is the first version of QueryProxy's threat model. It describes what the
current release (v0.3.0) is built to withstand and, as plainly, what it is not.
Use it to review a deployment; it is not a certification and does not replace a
review of your own environment.

QueryProxy sits between people who need data and databases that hold it. The
product's job is to make sure that every statement a person runs on a production
database was seen by the guards, approved by someone else, run with limited
privileges, masked on the way out and written to a record that is hard to
rewrite.

## Assets

| Asset | Where it lives | Protection | What remains |
| :-- | :-- | :-- | :-- |
| Target database credentials | The application database, encrypted | AES-256 with `APP_KEY`; never shown again after saving; redacted in stored error messages, audit metadata, notifications and the UI | Whoever holds `APP_KEY` and the database can decrypt them. The application log receives the raw driver error message, which can include user names, hosts and row values; protect that log like the database. `php artisan config:cache` writes secrets taken from `*_FILE` into `bootstrap/cache/config.php` in plain text |
| `APP_KEY` | Environment, `APP_KEY_FILE`, or a key file on the data volume | You choose where it lives; `queryproxy:doctor` warns when it shares a volume with the SQLite database | A backup of that volume holds the key next to the data |
| Result files | The result disk | Masked while written (fail closed), private disk only, downloads pass the request policy, pruned after the retention period | Masking is rule-based; data no rule matches is stored as is |
| Audit records | The application database | Hash chain, triggers against `UPDATE` and `DELETE`, optional export to a SIEM | The database owner can rewrite history unless you keep an external copy |
| Chat secrets, masking keys, TLS certificates | The application database, encrypted | Same key as the credentials; the signing secret can be set only by a system administrator | Same as the credentials |
| Submitted SQL | The application database | Shown redacted to Auditors and in chat by default; raw SQL is not in the audit screen, the CSV or the SIEM export | Requesters, the team's DBAs and administrators see the SQL as submitted |

## Actors

| Actor | Starting point | Goal |
| :-- | :-- | :-- |
| Malicious developer | A QueryProxy account with the Developer role | Read data they should not, change data without approval, run something dangerous on the server |
| Compromised DBA account | A stolen or misused account with the DBA role | Approve harmful requests, weaken protections, read unmasked data |
| Attacker on the network | Network position between browser, QueryProxy, chat and target databases | Impersonate a user or a chat approver, read or alter traffic, reach internal services |

An administrator is trusted: administrators can override approvals, set signing
secrets and manage users. A person with root on the host, or with both the
database and `APP_KEY`, is outside the model.

## Trust boundaries

```
Browser ──HTTPS──▶ reverse proxy ──HTTP──▶ QueryProxy (web, worker, scheduler)
Slack / Teams ◀──HTTPS webhook── QueryProxy ◀──signed callbacks── Slack / Teams
QueryProxy ──TLS──▶ target databases
QueryProxy ──▶ application database, result disk
QueryProxy ──▶ SIEM (stdout, syslog, signed webhook), Prometheus (scrape)
```

1. **Browser to QueryProxy.** The container serves plain HTTP on `7432`; the
   TLS terminator is yours. `TRUSTED_PROXIES` and `TRUSTED_HOSTS` decide whose
   `X-Forwarded-*` headers and which host names count.
2. **Chat to QueryProxy.** Callbacks are signed (Slack signature, Teams HMAC),
   expire after five minutes and carry a single-use action token bound to one
   request. The approver is mapped to a QueryProxy user in the integration's
   team.
3. **QueryProxy to target databases.** Network connections created from v0.3.0
   require TLS. Reads run in a read-only session with timeouts. The privileges
   of the connection's database user are the last line.
4. **QueryProxy to the outside.** Webhooks go only to allowlisted hosts and are
   never redirected. Audit sinks and `/metrics` are off until configured.

## What each actor can try

### The malicious developer

- *Run a write without approval.* Writes enter the queue as `pending` and wait
  for a DBA; the requester cannot approve their own request. Reads run in a
  read-only session on the database.
- *Dodge the guards with syntax.* The inspector normalises comments and judges
  statements by their dangerous part, and unclassifiable input is treated as a
  write. It is a denylist, though: a spelling it does not name yet can pass. The
  approval, not the denylist, authorises a statement.
- *Read unmasked data.* Masking is applied while the result is written, so
  unmasked rows never reach the result store. A statement that filters by a
  masked column can still leak the value by inference (a `WHERE` clause that
  tests candidate values). The sensitive-filter badge warns the approver when a
  request filters by a column that has a masking rule; it does not block the
  request. It does not follow column aliases, CTEs or derived tables, and
  ignores regex and validator rules.
- *Exhaust resources.* A hard `LIMIT` ceiling, a statement timeout, a lock
  timeout and a per-connection slot limit apply. On MySQL a long write is not
  cut by the database timeout.
- *Reach internal hosts through connections or webhooks.* Connection hosts can be
  denied (`QUERYPROXY_CONNECTION_HOST_DENYLIST`), SQLite files are pinned
  (`QUERYPROXY_SQLITE_ALLOWED_DIR`) and outbound webhooks are SSRF-guarded.

### The compromised DBA account

- *Approve their own request.* Refused. Another DBA, or an administrator who
  overrides the rule with a written reason that lands in the audit log, has to
  decide.
- *Forge a chat approval.* The HMAC alone proves only that the caller knows the
  secret, so the secret can be set only by an administrator, the callback needs a
  live single-use token, and the approver must be a mapped user of the team.
- *Weaken the protections.* A DBA manages masking rules and connections and can
  turn a rule off or grant access. Those changes are audited. Review them in the
  audit log, and keep Auditors who can read it; a DBA cannot.
- *Recover pseudonymised values.* `hmac` masking replaces a value with a keyed
  token that is stable inside a team. Low-entropy identifiers such as national ids
  or phone numbers can be tested against the token by anyone who can get a
  candidate through the same rule (the masking playground, or an approved query
  that returns candidates). Treat `hmac` as pseudonymisation inside the team, not
  anonymisation. Rotating the key makes old and new tokens stop matching.
- *Delete evidence.* Audit records cannot be updated or deleted through the
  application, and the database refuses it through triggers (on MySQL and MariaDB
  the triggers cannot block `TRUNCATE`). Whoever can
  alter the database outside the application is the next actor up.

### The attacker on the network

- *Read or change traffic to QueryProxy.* Put TLS in front, set
  `SESSION_SECURE_COOKIE=true`, and set `TRUSTED_PROXIES` to your proxy only.
- *Forge client addresses.* Not possible as long as `TRUSTED_PROXIES` names only
  your proxies. A broad range such as `10.0.0.0/8` lets every host in it set
  `X-Forwarded-For`; `queryproxy:doctor` fails on `*` and on catch-all ranges
  only.
- *Replay a chat callback.* Refused after five minutes and after the first
  decision.
- *Sit between QueryProxy and a target database.* New connections require TLS,
  so a downgrade to plain text fails. Encryption alone does not prove who is on
  the other end: for PostgreSQL choose `verify-ca` or `verify-full`, for MySQL and
  MariaDB tick "Verify the server certificate", and for SQL Server leave
  `TrustServerCertificate` off. Existing connections from before v0.3.0 keep their
  old behaviour until you edit them. For SQL Server, refusing a server that offers
  no encryption is the ODBC driver's documented behaviour, not something QueryProxy
  tests.
- *Scrape `/metrics`.* Off by default; needs a token or an allowed address.

## What the guard does not cover

The guard is a defence-in-depth layer, not a security boundary. The full list is
in [SQL guards](/docs/sql-guards/#known-limits). The ones that matter most when
you assess risk:

- Code that already lives on the server (functions, procedures, triggers) is not
  inspected when a request calls it.
- MySQL and MariaDB stored-code bodies and `SET PASSWORD` lists are not inspected
  as `SET` statements.
- Writes to the catalog tables of SQLite and SQL Server, and `TRUNCATE`, `DROP`
  and `ALTER` on catalog tables, are outside the catalog rule.
- SQL Server has no engine-level read-only session. Without a separate read
  credential, a read relies on the guard alone.
- Redaction for chat and audit views leaves a few forms visible, such as a `$12.50`
  money literal outside SQL Server and a value typed in double quotes on
  PostgreSQL or SQL Server (it reads as an identifier and fails when run).

Give each connection a database user with the least privileges that fit the
team, and a separate read-only credential where the database offers one.

## Risks that stay with you

- Keeping `APP_KEY` safe, and apart from the backups.
- TLS in front of QueryProxy and the correct `TRUSTED_PROXIES`.
- The privileges of the database users QueryProxy connects as.
- An external copy of the audit log (a SIEM sink) if you need to prove the log
  was not rewritten by someone with database access.
- Who is an administrator, and who is a DBA of which team.
- The host, the container runtime and the image you run. Release images are
  signed; see [Upgrading and rolling back](/docs/upgrading/#verifying-the-image).

## Next steps

- [Production hardening](/docs/production-hardening/)
- [Audit chain and SIEM export](/docs/audit-export/)
- [Backup and restore](/docs/backup-restore/)
