Known Limitations Community
This document describes accepted limitations in the current release. Items marked [Resolved] have been fixed but are kept for historical reference.
1. Dashboard Content Security Policy allows inline scripts [Resolved]
Section titled “1. Dashboard Content Security Policy allows inline scripts [Resolved]”Status: Fixed
Inline JavaScript has been extracted to static/dashboard.js. CSP is now
script-src 'self' with no unsafe-inline or unsafe-eval. This was
completed as part of the frontend auth hardening epic.
2. Dashboard authentication uses browser sessionStorage [Resolved]
Section titled “2. Dashboard authentication uses browser sessionStorage [Resolved]”Status: Fixed
Dashboard now uses HttpOnly + SameSite=Strict session cookies via POST /auth/login.
The admin API key is no longer stored in browser storage. WebSocket auth uses
cookie-based session validation as primary method.
3. DataGuard violation terminates the connection permanently
Section titled “3. DataGuard violation terminates the connection permanently”Status: Accepted risk (intentional security design)
When a single query response or the rolling window transfer exceeds the configured DataGuard limit, the connection is flagged as violated. All subsequent queries on that connection will be rejected with a PostgreSQL ErrorResponse:
FATAL: Querycop: data guard: response size NNN bytes exceeds limit NNN bytes (reconnect to continue)Rationale: This prevents data exfiltration via repeated queries within a single connection. An attacker who triggers one large response cannot continue to query on the same connection.
Per-query counter reset (H4 fix): The per-query byte counter is reset only on
an actual query-execution message — PostgreSQL Simple Query ('Q') and Execute
('E'), or a MySQL COM_QUERY. Control/preparation messages that do not execute a
query (Sync 'S', Flush 'H', Bind 'B', Parse 'P', Describe 'D', Close
'C', Terminate 'X') do not reset it. Prior to this fix the counter was reset
on every client→server message, which let an attacker interleave cheap empty
messages (e.g. Sync) into a large result stream to zero out the per-query counter
and effectively bypass MaxResponseBytes. The reset now tracks genuine query
boundaries so the per-query limit is enforced across multi-message responses.
Impact: Legitimate large query results (e.g., analytics exports) that exceed the per-query limit will terminate the connection. The client application must reconnect.
Configuration:
GATEKEEPER_MAX_RESPONSE_MB: Per-query response limit (default: 100 MB)GATEKEEPER_MAX_WINDOW_MB: Rolling 60-second transfer limit (default: 500 MB)
Workaround: Increase limits for trusted users/applications. Use pagination for large result sets. Monitor DataGuard violation events via audit log or WebSocket.
4. AI risk scoring is advisory and subject to prompt injection
Section titled “4. AI risk scoring is advisory and subject to prompt injection”Status: Accepted risk (inherent limitation of LLM-based analysis)
SQL queries are sent to an LLM for risk scoring. The system includes multiple layers of defense:
- Comment stripping: SQL comments are removed before LLM submission
- Input sanitization: Control characters and known injection patterns are normalized
- Server-side score override (action-based, threshold-linked): Any query
classified as destructive (
UPDATE/DELETE/ DDL such asDROP/TRUNCATE/ALTER) whose AI score falls below the auto-approve threshold is forced tomax(threshold, 50)and routed to human approval. The classification uses the deterministicpkg/sqlparseclassifier (which is comment-, string- literal-, and stacked-statement-aware), not a raw substring scan — so it coversUPDATE/ALTERand is not fooled by keyword text inside string literals. (Prior to the H3 fix, the override only fired on a rawbytes.ContainsofDELETE/DROP/TRUNCATEand only when the score was< 10; destructive queries scoring in[10, threshold)— and allUPDATE/ALTER— could slip through to auto-approval.) - System prompt hardening: Anti-injection instructions in the system prompt
- Threshold-based approval: Auto-approval decisions are based on numeric score thresholds, not on AI text recommendations
Impact: A crafted SQL query may be able to influence the AI’s reason text
(displayed in Slack/dashboard), but cannot bypass server-side score enforcement.
The reason field should be treated as untrusted advisory text.
Workaround: Set conservative auto-approval thresholds. Require human
approval for all destructive queries (auto_approve_threshold: 0). Monitor AI
score distributions for anomalies.
5. Data masking limitations
Section titled “5. Data masking limitations”Status: Known limitation
-
Binary format columns: Columns returned in binary format (FormatCode=1) are not masked. Text format (FormatCode=0) is the default for most PostgreSQL clients.
-
Proxy-layer masking matches by column name only (table scope is best-effort, fail-safe over-mask): The proxy wire layer sees the column name from the
RowDescriptionmessage but not the table it belongs to (PostgreSQL sends aTableOID, but resolving it to a table name would require apg_classlookup, which is not implemented). Therefore a table-scoped rule such as{"table": "users", "column": "ssn"}is applied whenever a column namedssnappears, regardless of which table it came from. This is an intentional fail-safe over-mask: the firewall biases toward masking (never leaking PII in plaintext), accepting that a same-named column in a different table may also be masked. A startupWARNlog records this. (Future enhancement: resolveTableOID→ table name viapg_classto honor table scope exactly; not implemented in this release.)History: prior to the H1 fix, the proxy adapter passed
table=""to the engine, which caused every table-scoped rule to be silently skipped — a masking rule configured by an operator had no effect at the proxy and PII (e.g. SSN) was returned in plaintext. The fix matches by column name with the fail-safe over-mask described above. -
Extended query protocol: Parse/Bind/Execute messages are tracked for semantic state, but masking applies only to RowDescription/DataRow messages which are the same regardless of simple vs extended query path.
6. Extended query protocol coverage
Section titled “6. Extended query protocol coverage”Status: Known limitation
- Parse, Bind, Execute, Describe, Close, and Sync messages are tracked
- Statement name → SQL text mapping is maintained per connection
- Portal → statement resolution is supported
- Not covered: COPY protocol, function calls, cursors with FETCH
- Not covered: Full SQL semantic analysis of parameterized queries
7. MySQL protocol coverage
Section titled “7. MySQL protocol coverage”Status: Known limitation
- MySQL text protocol (COM_QUERY) is supported with handshake, user extraction, and query classification/approval
- Not covered: MySQL binary protocol (COM_STMT_PREPARE, COM_STMT_EXECUTE)
- Not covered: MySQL SSL/TLS upgrade
- Not covered: MySQL data masking (RowDescription/DataRow is PostgreSQL-specific)
- Not covered: COM_CHANGE_USER, COM_RESET_CONNECTION
- Multi-packet queries (>16MB) are rejected: a
COM_QUERYwhose first packet is a full 16MB-1 payload (i.e. it continues into subsequent packets) is rejected and the connection is closed (fail-closed), not forwarded. The proxy reads one physical packet at a time and cannot inspect a query split across continuation packets, so forwarding it would bypass policy/approval/AI/ masking. Single-packet queries (the overwhelming majority) are unaffected. - MySQL auth is relayed to backend; proxy does not perform auth itself
8. Distributed approval limitations
Section titled “8. Distributed approval limitations”Status: Known limitation
- Cross-node approval uses Redis Pub/Sub for completion signaling
- The origin node (where the query is blocked) must remain connected to Redis for the duration of the approval wait
- If Redis goes down during the wait, the pending request will time out
- WebSocket events are not broadcast across nodes
8.5. JIT two-person control & sensitive-read authorization
Section titled “8.5. JIT two-person control & sensitive-read authorization”Status: By design (SEC audit hardening)
- JIT two-person control requires distinct per-user identities: the JIT
/access/requestrequester is bound to the authenticated principal (not a client-supplied body field), and/access/approverejects requester == approver. This is only meaningful when each person has a distinct identity — i.e. OIDC SSO (per-usersub). Under the shared admin session cookie or a single API key, all admins map to one principal (session:admin/api-key), so self-approval is always detected and JIT approval via that path is blocked (fail-safe). Deployments that need JIT two-person control should use OIDC SSO with per-user identities. - Sensitive reads require senior_dev or above: GET access to audit logs
(
/audit), recorded SQL sessions (/sessions*), JIT info (/access/*), and pending queries (/requests) requiressenior_dev/admin.read_onlyandjunior_devreceive 403 (these surfaces expose full SQL, client IPs, and PII-bearing recorded queries). Non-sensitive dashboard reads (policies, status, stats) remain available to any authenticated principal.
8. Deprecated rename aliases (Querycop transition)
Section titled “8. Deprecated rename aliases (Querycop transition)”Status: Accepted (deprecation period)
The product was renamed from QueryGuard to Querycop in Phase B3. The Go module path, user-facing docs, and LP have been fully migrated, but several wire-level identifiers still accept the old name for one release cycle:
GATEKEEPER_REDIS_KEY_PREFIX(env) — deprecated alias forGATEKEEPER_CLUSTER_KEY_PREFIX. Still read at startup, scheduled for removal in the next major version.- Slack interaction handler — accepts both
querycop_approve/querycop_reject(new) andqueryguard_approve/queryguard_reject(legacy) inaction_idvalues. Messages sent by the notifier now use the new IDs; the legacy IDs are only kept to support in-flight messages posted before the upgrade. - Helm chart directory — renamed
charts/queryguard/→charts/querycop/. A tombstone README remains at the old path. The chart’snameOverridevalue keeps in-placehelm upgradeof pre-rename releases safe (--set nameOverride=queryguard); seedocs/configuration.md§9.4 for the migration steps. - Environment variables prefixed
GATEKEEPER_*and binary namegatekeeper— intentionally retained (see CLAUDE.md). No deprecation planned.
See docs/configuration.md section 9 for full migration guidance.
9. SQL parsing / classification limitations
Section titled “9. SQL parsing / classification limitations”Status: Known limitation (intentional fail-safe design)
Querycop classifies query intent with a lightweight, protocol-independent text
parser (pkg/sqlparse), not a full SQL grammar. It strips comments, splits
stacked statements on ; (respecting single-quote strings, dollar-quote bodies,
and comments), and matches keywords on word boundaries. The classifier is a
firewall input, so every approximation is biased toward over-classify
(treat as more destructive) and never under-classify (downgrade a
destructive statement to a lighter action).
- Single-quote string literals: keyword text inside
'...'literals is data and is blanked before classification, soSELECT 'please DROP everything'classifies as a read, not DDL. An unterminated literal is not blanked — its keywords stay visible (fail-safe over-classify). - Dollar-quote bodies are not data-blanked: a
DO $$ ... $$/$tag$ ... $tag$body can be executed server-side, so keywords inside it still drive classification (DO $$ ... DELETE ... $$classifies as a delete). - Nested block comments are a cross-protocol trade-off: PostgreSQL nests
/* ... */; MySQL/MariaDB do not (they close the comment at the first*/). The stripper deliberately stops at the first*/. This keeps it fail-safe for MySQL (a nesting-aware stripper would hide MySQL-executable code that appears after the first*/= bypass). The documented consequence: a keyword positioned between an inner*/and an outer*/(e.g./* outer /* inner */ DROP TABLE t */ SELECT 1) is treated as live code and over-classifies on PostgreSQL (where it is really still commented out), while being classified correctly on MySQL (where it really executes). A keyword that is before the first*/is genuinely inside the comment on both engines and is stripped normally. - MySQL executable comments:
/*! ... */and/*!NNNNN ... */bodies are executed by MySQL, so they are preserved verbatim (not stripped) and their keywords drive classification. - Privileged / destructive statement verbs: beyond the obvious DML/DDL,
statement-level verbs that execute OS commands, change privileges, run opaque
bodies, or affect availability are classified as DDL (so non-trusted roles need
approval):
COPY(incl.... FROM/TO PROGRAM),GRANT/REVOKE/REASSIGN,CALL,DO,EXECUTE,LOCK,VACUUM/REINDEX/CLUSTER/REFRESH,MERGE,IMPORT,SELECT ... INTO,SET ROLE/SET SESSION AUTHORIZATION, and MySQLLOAD DATA/HANDLER/FLUSH/KILL/SHUTDOWN/INSTALL/RENAME. PlainSET timezone/SET NAMES, transaction control (BEGIN/COMMIT/…),SHOW, andEXPLAIN [ANALYZE] SELECTremain reads. - No semantic analysis (residual fail-open): keyword matching does not
understand full SQL semantics. The classifier is a denylist, so a mechanism
with no recognizable keyword in the query text — e.g. a destructive operation
wrapped entirely inside a stored procedure body (the
CALL/DO/EXECUTEverb is flagged, but the proxy cannot see what the callee does), or a hypothetical future top-level verb not yet in the keyword set — can still fall through to a read. Low-risk maintenance verbs (ANALYZE,CHECKPOINT) are intentionally not flagged, to avoid over-classifying the commonEXPLAIN ANALYZE SELECT. Combine the classifier with RBAC policy and AI risk scoring rather than relying on it alone. A planned hardening direction is to invert the polarity (allowlist known read verbs; treat everything else as requiring approval); this requires first extracting SQL text from extended-protocol (Parse) messages, since the classifier currently runs over raw protocol bytes.
Change History
Section titled “Change History”| Date | Change |
|---|---|
| 2026-04-01 | Initial known limitations document |
| 2026-04-04 | Added masking and extended query limitations |
| 2026-04-04 | Added MySQL and distributed approval limitations |
| 2026-04-21 | Documented QueryGuard → Querycop rename deprecations |
| 2026-06-18 | Added SQL parsing / classification limitations (string-literal blanking, nested-comment cross-protocol fail-safe, tagged dollar quotes) |
| 2026-06-19 | Documented proxy-layer masking column-name matching (fail-safe over-mask), action-based AI score override, and per-query DataGuard reset semantics |
| 2026-06-20 | Classifier now flags privileged/destructive statement verbs (COPY/GRANT/CALL/DO/EXECUTE/LOCK/SET ROLE/SELECT INTO/…) as DDL; documented residual denylist fail-open; case-insensitive masking column/table matching; MySQL >16MB multi-packet COM_QUERY fail-closed |
| 2026-06-20 | OIDC flow hardening (state↔cookie binding, JWT exp-required+nbf, URL-encode); audit limit cap; JIT requester bound to authenticated identity (two-person control needs per-user OIDC); sensitive reads (audit/recorded SQL/JIT) require senior_dev+ |
| 2026-06-20 | OIDC authorization-code flow: nonce binding (ID token replay/injection defense) + PKCE (S256) added to login/callback |