Skip to content

fix(search): close SQLi, draft leak, and DoS in public /api/search - #1064

Closed
mmcintosh wants to merge 1 commit into
mainfrom
fix/search-injection-hardening
Closed

fix(search): close SQLi, draft leak, and DoS in public /api/search#1064
mmcintosh wants to merge 1 commit into
mainfrom
fix/search-injection-hardening

Conversation

@mmcintosh

Copy link
Copy Markdown
Collaborator

Summary

The ai-search plugin (active by default) mounts POST /api/search with no authentication. This PR closes three issues on that public surface:

  1. SQL injectionfilters.dateRange.field from the request body was interpolated raw into SQL in column-identifier position (c.${field} >= ?), which cannot be parameterized as a bound value. An un-allowlisted value is an injection sink against the legacy content table.
  2. Unbounded page size — the keyword-search limit was uncapped, allowing an anonymous caller to request an arbitrarily large result set.
  3. Unpublished / analytics exposure — the default visibility filter was only status != 'deleted' (returning drafts), and GET /api/search/analytics (which exposes other users' popular queries) had no auth.

Fix

  • Allowlist dateRange.field to created_at / updated_at, falling back to created_at for anything else.
  • Clamp the keyword-search limit to a hard ceiling (100) and floor the offset at 0.
  • Lock non-privileged callers to status = published (overriding any status filter in the body); privileged sessions (admin/editor/author) retain full access. Privilege is read from the session the app-level middleware already populates.
  • Gate /api/search/analytics to privileged sessions (403 otherwise).

Deeper ACL/tenant scoping and per-IP rate-limiting for this surface are folded into the FTS5 search rewrite (#1058); this PR is the minimal hardening for the plugin as it ships on main today.

Tests

  • ai-search-injection.test.ts — proves the injection payload never reaches the generated SQL (falls back to c.created_at) and the oversized limit is clamped.
  • ai-search-route-auth.test.ts — anonymous search is forced to status=published, privileged search preserves its filter, analytics is 403 for anon and 200 for admin.
  • tests/e2e/109-search-security.spec.ts — end-to-end: malicious dateRange.field returns 200 (no injection/500), oversized limit is bounded, analytics denied to anon.
  • Full core suite green (1746 passed); type-check clean.

Marked draft pending maintainer review of the disclosure/coordination approach.

The ai-search plugin mounts POST /api/search with no auth. Three fixes:

- SQL injection: filters.dateRange.field was interpolated raw into SQL in
  column-identifier position (`c.${field} >= ?`). Allowlist it to
  created_at/updated_at, falling back to created_at otherwise.
- Unbounded page size: clamp the keyword-search limit to a hard ceiling (100)
  and floor the offset at 0.
- Unpublished/PII exposure: non-privileged callers are now locked to
  status=published (overriding any status filter in the body), and
  /api/search/analytics (which exposes other users' popular queries) returns
  403 to anyone who isn't admin/editor/author. Privilege is read from the
  session the app middleware already populates.

Deeper ACL/tenant scoping and rate-limiting for this surface are folded into
the FTS5 search rewrite (#1058).
@mmcintosh

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #1058, which supersedes this PR — and correcting the framing in the process.

The "unauthenticated SQLi" framing here was inaccurate. On main, POST /api/search is route-shadowed and never reaches this plugin's handler: the ai-search plugin mounts /api/search after the generic app.route('/api', apiRoutes) catch-all, so the request is captured by /api/:collection (requireAuth401) and /api/search/analytics by /api/:collection/:id (→ 404). Confirmed on a live wrangler dev, not just a routing probe: POST /api/search returns {"error":"Authentication required"}, byte-identical to POST /api/nonexistentcollection. So the vulnerable filters.dateRange.field path had no reachable anonymous HTTP path — the hardening in this PR was defense-in-depth over dead code.

#1058 does everything this PR intended, correctly, as the intended replacement for the plugin:

  • Fixes the shadowing — promotes /api/search out of the plugin and into core app.ts, mounted before the /api/:collection catch-all, so search is actually reachable (and, being a core route, survives a plugins-off disableAll deploy).
  • Removes the SQLi sink entirely — the raw dateRange.field identifier interpolation is gone in the FTS5 rewrite, not just allowlisted.
  • DoS clampMAX_LIMIT = 100 on the public endpoint.
  • Published-only for anonymous callers (D5) — plus a publicReadableTypeIds() allowlist so internal types never surface, and a server-authoritative filters.status gate.

Closing this leaves no live exposure uncovered — it guarded code with no reachable path, and the same protections ship (live) in #1058. Tracking the hardening there.

@mmcintosh mmcintosh closed this Aug 21, 2026
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.

1 participant