Skip to content

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:

  1. Comment stripping: SQL comments are removed before LLM submission
  2. Input sanitization: Control characters and known injection patterns are normalized
  3. Server-side score override (action-based, threshold-linked): Any query classified as destructive (UPDATE / DELETE / DDL such as DROP / TRUNCATE / ALTER) whose AI score falls below the auto-approve threshold is forced to max(threshold, 50) and routed to human approval. The classification uses the deterministic pkg/sqlparse classifier (which is comment-, string- literal-, and stacked-statement-aware), not a raw substring scan — so it covers UPDATE/ALTER and is not fooled by keyword text inside string literals. (Prior to the H3 fix, the override only fired on a raw bytes.Contains of DELETE/DROP/TRUNCATE and only when the score was < 10; destructive queries scoring in [10, threshold) — and all UPDATE / ALTER — could slip through to auto-approval.)
  4. System prompt hardening: Anti-injection instructions in the system prompt
  5. 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.


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 RowDescription message but not the table it belongs to (PostgreSQL sends a TableOID, but resolving it to a table name would require a pg_class lookup, which is not implemented). Therefore a table-scoped rule such as {"table": "users", "column": "ssn"} is applied whenever a column named ssn appears, 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 startup WARN log records this. (Future enhancement: resolve TableOID → table name via pg_class to 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.


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

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_QUERY whose 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

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/request requester is bound to the authenticated principal (not a client-supplied body field), and /access/approve rejects requester == approver. This is only meaningful when each person has a distinct identity — i.e. OIDC SSO (per-user sub). 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) requires senior_dev / admin. read_only and junior_dev receive 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 for GATEKEEPER_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) and queryguard_approve / queryguard_reject (legacy) in action_id values. 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’s nameOverride value keeps in-place helm upgrade of pre-rename releases safe (--set nameOverride=queryguard); see docs/configuration.md §9.4 for the migration steps.
  • Environment variables prefixed GATEKEEPER_* and binary name gatekeeper — 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, so SELECT '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 MySQL LOAD DATA / HANDLER / FLUSH / KILL / SHUTDOWN / INSTALL / RENAME. Plain SET timezone / SET NAMES, transaction control (BEGIN/COMMIT/…), SHOW, and EXPLAIN [ANALYZE] SELECT remain 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/EXECUTE verb 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 common EXPLAIN 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.

DateChange
2026-04-01Initial known limitations document
2026-04-04Added masking and extended query limitations
2026-04-04Added MySQL and distributed approval limitations
2026-04-21Documented QueryGuard → Querycop rename deprecations
2026-06-18Added SQL parsing / classification limitations (string-literal blanking, nested-comment cross-protocol fail-safe, tagged dollar quotes)
2026-06-19Documented proxy-layer masking column-name matching (fail-safe over-mask), action-based AI score override, and per-query DataGuard reset semantics
2026-06-20Classifier 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-20OIDC 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-20OIDC authorization-code flow: nonce binding (ID token replay/injection defense) + PKCE (S256) added to login/callback