fix(search): close SQLi, draft leak, and DoS in public /api/search - #1064
fix(search): close SQLi, draft leak, and DoS in public /api/search#1064mmcintosh wants to merge 1 commit into
Conversation
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).
|
Closing in favor of #1058, which supersedes this PR — and correcting the framing in the process. The "unauthenticated SQLi" framing here was inaccurate. On #1058 does everything this PR intended, correctly, as the intended replacement for the plugin:
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. |
Summary
The
ai-searchplugin (active by default) mountsPOST /api/searchwith no authentication. This PR closes three issues on that public surface:filters.dateRange.fieldfrom 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 legacycontenttable.limitwas uncapped, allowing an anonymous caller to request an arbitrarily large result set.status != 'deleted'(returning drafts), andGET /api/search/analytics(which exposes other users' popular queries) had no auth.Fix
dateRange.fieldtocreated_at/updated_at, falling back tocreated_atfor anything else.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./api/search/analyticsto privileged sessions (403otherwise).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
maintoday.Tests
ai-search-injection.test.ts— proves the injection payload never reaches the generated SQL (falls back toc.created_at) and the oversized limit is clamped.ai-search-route-auth.test.ts— anonymous search is forced tostatus=published, privileged search preserves its filter, analytics is403for anon and200for admin.tests/e2e/109-search-security.spec.ts— end-to-end: maliciousdateRange.fieldreturns200(no injection/500), oversized limit is bounded, analytics denied to anon.