Skip to content

fix: harden query-database — block sensitive columns + length cap (closes #166, #129) - #199

Merged
ivdimova merged 1 commit into
devfrom
fix/v0-11-query-database-security
May 12, 2026
Merged

ivdimova merged 1 commit into
devfrom
fix/v0-11-query-database-security

Conversation

@pluginslab

Copy link
Copy Markdown
Owner

Summary

First of three security PRs for v0.11. Two real-world attack vectors closed.

Net: +99 / -16 lines, 1 file touched.

Closes

#166 — Email/password reconnaissance via WHERE clauses

The query-database ability runs LLM-authored SQL. Previously, sensitive column values like `user_pass` and `user_email` were redacted in the response display, but a user could still send:

```sql
SELECT * FROM wp_users WHERE user_email = '[email protected]'
```

An empty result vs. one-row result leaks whether that email exists — confirming users one address at a time.

Fix: Reject any query that mentions a sensitive column anywhere in the string, before execution. The blocklist lives in a shared `wp_agentic_admin_sensitive_columns()` helper:

```php
function wp_agentic_admin_sensitive_columns(): array {
return [ 'user_pass', 'user_email', 'user_activation_key', 'session_tokens' ];
}
```

Plus a belt-and-suspenders `wp_agentic_admin_redact_sensitive_row()` that scrubs those columns from result rows, so `SELECT *` doesn't smuggle them out.

#129 — Unescaped parameter + missing safety rationale

`$wpdb->prepare()` can't be used here because the LLM authors the whole query (no separable placeholders to bind). The phpcs warnings (`PreparedSQL.NotPrepared`, `DirectDatabaseQuery.DirectQuery`, `DirectDatabaseQuery.NoCaching`) are intentional, but were inadequately documented. Now a single `phpcs:ignore` covers all three with a rationale comment listing every defense layer.

Also added a 2000-char length cap to prevent pathological queries that try to bypass the keyword blocklist via obfuscation.

What changed

Change Why
Rename `is_safe_query()` → `check_query_safety()` Returns `string|true` instead of `bool` so the caller can surface the reason a query was rejected. Helps the LLM self-correct on next turn.
New `wp_agentic_admin_sensitive_columns()` helper Single source of truth for the column blocklist.
New `wp_agentic_admin_redact_sensitive_row()` helper Result-row redactor.
New `WP_AGENTIC_ADMIN_QUERY_MAX_LENGTH = 2000` constant Length cap.
Consolidated phpcs:ignore with rationale comment Documents all defense layers in one place.

Verification

Manual smoke test via `wp_cli eval` in the running Playground:

Query Expected Got
`SELECT * FROM wp_users WHERE user_email = '…'` blocked ✅ blocked
`SELECT user_pass FROM wp_users` blocked ✅ blocked
`SELECT ID, user_login FROM wp_users` passed ✅ passed
`DELETE FROM wp_users` blocked ✅ blocked
2500-char query blocked ✅ blocked
`INSERT INTO wp_users VALUES (1)` blocked ✅ blocked
  • `composer lint` — clean
  • `npm test` — 96 passing, no JS changed

What's next

Two more security PRs to land before WP.org submission:

Test plan

  • Build check passes
  • PHP lint passes
  • JS lint passes
  • Unit tests pass
  • Manual: try a recon query (`SELECT * FROM wp_users WHERE user_email = 'admin@…'`) from chat → agent gets a blocked-column error, doesn't reveal whether the email exists

🤖 Generated with Claude Code

Two layers of defense for the query-database ability, which executes
LLM-generated SQL under the manage_options cap.

#166 — Email/password reconnaissance:
  Previously the user could send WHERE user_email='[email protected]' and
  infer existence from the row count, bypassing the [REDACTED] field
  display. Now any query that mentions user_pass / user_email /
  user_activation_key / session_tokens — anywhere in the string, not
  just in SELECT lists — is rejected before execution. The blocklist
  lives in a shared wp_agentic_admin_sensitive_columns() helper so
  the same list drives both query rejection and result-row redaction.

#129 — Unescaped parameter + missing safety annotations:
  $wpdb->prepare() can't be used here because the LLM authors the
  whole query (no separable placeholders to bind). Documented why,
  alongside all the other gates that defend the call:
    - read-only verb gate (SELECT/SHOW/DESCRIBE/EXPLAIN only)
    - forbidden-keyword blocklist (INSERT/UPDATE/DELETE/DROP/…)
    - sensitive-column blocklist (issue #166)
    - 2000-char length cap (new — prevents pathological obfuscation)
    - manage_options capability check
    - per-row redaction as belt-and-suspenders for SELECT *

  Consolidated the existing single-line phpcs:ignore into one covering
  WordPress.DB.PreparedSQL.NotPrepared + .DirectDatabaseQuery.DirectQuery
  + .DirectDatabaseQuery.NoCaching, with the rationale comment directly
  above the call. Caching is intentionally skipped: each LLM-generated
  query is unique, so a cache would never hit while doubling our DB
  call surface.

Renames:
  wp_agentic_admin_is_safe_query()  →  wp_agentic_admin_check_query_safety()
  Returns string|true instead of bool so the caller can surface the
  specific reason a query was rejected (helps the LLM correct course
  on the next turn).

New helpers:
  wp_agentic_admin_sensitive_columns()        — single source of truth
  wp_agentic_admin_redact_sensitive_row($row) — result-row redactor
  WP_AGENTIC_ADMIN_QUERY_MAX_LENGTH constant  — 2000

Verified manually via wp_cli eval in the running Playground:
  - SELECT * FROM wp_users WHERE user_email = '…'  → blocked
  - SELECT user_pass FROM wp_users                 → blocked
  - SELECT ID, user_login FROM wp_users            → passed
  - DELETE FROM wp_users                           → blocked
  - 2500-char SELECT string                        → blocked
  - INSERT INTO wp_users VALUES (1)                → blocked

PHP lint: clean (all phpcs warnings suppressed with rationale).
JS tests: 96 passing, unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

@ivdimova ivdimova left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid two-layer defence: sensitive columns blocked at query parse time, then redacted in results as belt-and-suspenders for SELECT *. Row-count side-channel reasoning is correct. Translated error strings are appropriate for wp.org. Safe to merge.

@ivdimova
ivdimova merged commit 7d7321c into dev May 12, 2026
4 checks passed
@ivdimova
ivdimova deleted the fix/v0-11-query-database-security branch May 12, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants