-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The 2015 loop did what the 2015 comment said\n\nThe first version of `Drupal\\Core\\Database\\Driver\\pgsql\\EntityQuery\\Condition::translateCondition()` landed on June 30, 2015, in commit `40ae780a3f`, authored by Alex Pott. The PostgreSQL override existed because PostgreSQL is case-sensitive by default on `text` columns and Drupal treats most string fields as logically case-insensitive. To emulate case-insensitive `IN` matching, the override could not delegate to the base class's array handling. The base class, `Drupal\\Core\\Entity\\Query\\Sql\\Condition::translateCondition()`, opens with:\n\n```php\npublic static function translateCondition(&$condition, SelectInterface $sql_query, $case_sensitive) {\n // // There is nothing we can do for IN ().\n if (is_array($condition['value'])) {\n return;\n }\n ...\n}\n```\n\nThe base class refuses to handle array values. The pgsql override has to handle them. It builds its own `LOWER(...) IN (LOWER(:placeholder), ...)` clause by walking the value array and emitting one bound placeholder per entry. Building those placeholder names manually is what makes the override more dangerous than the base class. The base class abstains from the unsafe operation; the override has to perform it.\n\nIn the 2015 implementation, the placeholder names were generated from a manually incremented counter `$n`:\n\n```php\n$n = 1;\n// Only use the array values in case an associative array is passed as an\n// argument following similar pattern in\n// \\Drupal\\Core\\Database\\Connection::expandArguments().\nforeach ($condition['value'] as $value) {\n $condition['where'] .= 'LOWER(:value' . $n . '),';\n $condition['where_args'][':value' . $n] = $value;\n $n++;\n}\n```\n\nThe comment is a contract description. It tells the next reader what the loop does and why: ignore the keys, use only the values, follow the pattern that `\\Drupal\\Core\\Database\\Connection::expandArguments()` already uses for parameter expansion across the codebase. The loop honors the contract: `foreach ($condition['value'] as $value)` discards the key entirely; `$n` is an integer the function controls; the placeholder name `:value1`, `:value2`, `:value3` is a constant string concatenated with an integer the attacker cannot influence.\n\nThe comment and the code agreed for five and a half years.\n\n## The 2020 refactor kept the comment\n\nOn December 18, 2020, commit `1cb136a1f0` landed under issue #3162603, titled \"EntityStorageBase::loadByProperties() is broken on PostgreSQL when using two or more case insensitive properties.\" The bug being fixed was real and unrelated to security. When a single query carried two `IN` conditions on different case-insensitive fields, both conditions emitted `:value1, :value2, ...` placeholders, and the placeholder names collided. The fix was to namespace the placeholder names per field by prepending the field's flattened path, `node_field_data_title`, `node_field_data_status`, and so on. That part of the change was correct.\n\nThe diff:\n\n```diff\n- $n = 1;\n // Only use the array values in case an associative array is passed as an\n // argument following similar pattern in\n // \\Drupal\\Core\\Database\\Connection::expandArguments().\n- foreach ($condition['value'] as $value) {\n- $condition['where'] .= 'LOWER(:value' . $n . '),';\n- $condition['where_args'][':value' . $n] = $value;\n- $n++;\n+ $where_prefix = str_replace('.', '_', $condition['real_field']);\n+ foreach ($condition['value'] as $key => $value) {\n+ $where_id = $where_prefix . $key;\n+ $condition['where'] .= 'LOWER(:' . $where_id . '),';\n+ $condition['where_args'][':' . $where_id] = $value;\n }\n $condition['where'] = trim($condition['where'], ',');\n $condition['where'] .= ')';\n- return;\n```\n\nThe `$n` counter is gone. The foreach now extracts `$key` from the iterator. The placeholder name `$where_id` is the field prefix concatenated with whatever `$key` happens to be at runtime. The author needed per-field namespacing and could have written `$where_prefix . $n` to get it without disturbing the original \"values only\" invariant; they chose to read the key directly out of the iteration and concatenate it. PHP's `foreach ($array as $key => $value)` yields the array's actual keys, not synthetic indices. When `$condition['value']` is associative, `$key` is whatever the caller put there.\n\nThe comment was not deleted. The comment still said \"Only use the array values in case an associative array is passed.\" Three lines below the comment, the new loop did the opposite.\n\n## The path from URL to placeholder name\n\nJSON:API has been bundled in Drupal core since 8.7 and enabled by default for read access since 8.8. It exposes every entity type as a JSON-API-spec collection at `/jsonapi//` and accepts `filter[...]` query parameters that translate directly into EntityQuery conditions. The filter syntax accepts nested keys; PHP's URL parser produces a nested associative array; JSON:API forwards the nested structure into the condition without normalizing the array's keys, because there was no perceived reason to.\n\nA request that fires the bug:\n\n```\nGET /jsonapi/node/article\n ?filter[t][condition][path]=title\n &filter[t][condition][operator]=IN\n &filter[t][condition][value][PAYLOAD]=x\n &page[limit]=1\n```\n\nPHP parses the query string into:\n\n```php\n['filter' => ['t' => ['condition' => [\n 'path' => 'title',\n 'operator' => 'IN',\n 'value' => ['PAYLOAD' => 'x'],\n]]]]\n```\n\nJSON:API's `Filter::createFromQueryParameter()` normalizes that into a `Drupal\\Core\\Entity\\Query\\Condition` with `value = ['PAYLOAD' => 'x']`. EntityQuery execution reaches `pgsql\\EntityQuery\\Condition::translateCondition()`. The guard `is_array($value) && $case_sensitive === FALSE` fires because `title` is one of Drupal's case-insensitive-by-default string fields. The foreach runs with `$key = 'PAYLOAD'`:\n\n```\n$where_id = 'node_field_data_title' . 'PAYLOAD'\n = 'node_field_data_titlePAYLOAD'\n\n$condition['where'] .= 'LOWER(:node_field_data_titlePAYLOAD),'\n$condition['where_args'][':node_field_data_titlePAYLOAD'] = 'x'\n```\n\nThe attacker's string is now part of a PDO placeholder name. PostgreSQL's prepared-statement parser accepts identifier characters in a placeholder name and stops at the first non-identifier character. A payload that contains SQL metacharacters terminates the placeholder grammar and the remainder is parsed as raw SQL inside the WHERE clause. The PoC's smoking-gun probe is `_) AND 1=1--`, which produces this SQL:\n\n```sql\nSELECT \"base_table\".\"vid\", \"base_table\".\"nid\"\nFROM \"node\" \"base_table\"\nINNER JOIN \"node_field_data\" \"node_field_data\" ON ...\nWHERE (\n (LOWER(\"node_field_data\".\"title\") IN (\n LOWER(:node_field_data_title_) AND 1=1--\n ))\n) AND\n (\"node_field_data_2\".\"status\" = :db_condition_placeholder_0) AND\n (\"node_field_data_3\".\"type\" = :db_condition_placeholder_1)\nGROUP BY \"base_table\".\"vid\", \"base_table\".\"nid\"\nLIMIT 2 OFFSET 0\n```\n\nThe PDO driver fails to reconcile the bind key (still the literal string `:node_field_data_title_) AND 1=1--`) with the placeholder PostgreSQL just parsed (`:node_field_data_title_`), throws `SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens`, and Drupal returns HTTP 500 with the failed SQL in the response body. That diagnostic dump is itself a confirmation channel: the failed query reveals the structural injection. A more careful payload produces well-formed SQL that runs, and the WHERE clause becomes attacker-controlled.\n\nThe 2015 loop emitted `LOWER(:value1)`. The 2020 loop emits `LOWER(:` followed by attacker bytes. Both loops have the same comment above them.\n\n## The patch is the function call the comment named\n\nThe advisory SA-CORE-2026-004, tracked as CVE-2026-9082, releases the fix across every supported branch: Drupal 11.3.10, 11.2.12, 11.1.10, 10.6.9, 10.5.10, and the EOL emergency releases 10.4.10 and 11.1.10. The fix lands in three places. The primary one is the line below the 2015 comment:\n\n```diff\n- foreach ($condition['value'] as $key => $value) {\n+ foreach (array_values($condition['value']) as $key => $value) {\n```\n\n`array_values()` returns the values of the passed array re-indexed from zero. After the wrap, `$key` is guaranteed to be `0, 1, 2, ...` regardless of what the caller supplied. The placeholder names become `:node_field_data_title0`, `:node_field_data_title1`, all safe identifiers. The comment above this line, present since 2015, now describes the code below it again.\n\nThe patch is also applied as defense-in-depth in the base class, two directories up, in `\\Drupal\\Core\\Entity\\Query\\Sql\\Condition::compile()`:\n\n```diff\n $condition['real_field'] = $field;\n+ if (is_array($condition['value'])) {\n+ $condition['value'] = array_values($condition['value']);\n+ }\n static::translateCondition($condition, $sql_query, $tables->isFieldCaseSensitive($condition['field']));\n```\n\nThis is the admission. The base class's own `translateCondition` returns early on array values; it is not directly vulnerable. The patch adds `array_values()` to the base class anyway, before any subclass's `translateCondition` runs. The Drupal core maintainers are saying, in code, that they do not trust the next dialect-specific subclass to handle the key-vs-value distinction either. The same guard was added to `ConditionAggregate::compile()` in the same diff. Three insertions of `array_values()`, all closing the same shape, all assuming the shape will try to recur.\n\n## Why this is PostgreSQL only\n\nA common reading of \"SQL injection in Drupal core\" is that the database abstraction layer broke a parameterization invariant that should have held across every backend. That is not what happened. The base class's `translateCondition` returns early on array values, which means MySQL and SQLite installs never reach a code path that builds custom placeholder names from array data. Drupal's MySQL and SQLite layers fall back to the database driver's own `IN`-array expansion logic, which uses positional placeholders the array's keys never reach.\n\nPostgreSQL is exposed because of a property of the dialect that has nothing to do with security. PostgreSQL's `text` type is case-sensitive; the default text collation does not fold case. Drupal pretends string fields are case-insensitive because every other backend it supports treats them that way. To preserve the illusion, the pgsql override has to emit `LOWER()` on both sides of the comparison, which means it has to construct the IN clause manually, which means it has to choose its own placeholder names. The base class is safe because it gives up. The pgsql override is unsafe because it does not give up. The fix is to give up on attacker-influenced keys while still doing the work.\n\nEvery Drupal site on PostgreSQL in the supported version range exposes a JSON:API endpoint at `/jsonapi` by default; the Standard install profile enables it with anonymous read access; every entity type with `access content` is reachable. The exploit precondition for CVE-2026-9082 is \"site administrator left core upgrades on the standard cadence between December 2020 and May 2026.\" That covers every long-running Drupal/PostgreSQL deployment the maintainers care about, which is the population the advisory rates 20/25 against.\n\n## The comment outlasted the code under it\n\nThe Todo That Shipped pattern in the nefariousplan catalog covers cases where a comment names a security control and the code below the comment never implements the named control. This case is the variant where the named control was implemented, lived under the comment for five and a half years, and was then deleted by a refactor that left the comment standing. The artifact is the same: a contract description in source, sitting above a line of code that does not honor it. Attackers grep for the contract; defenders grep for the keyword in the comment. Both find the same artifact. Only one side is auditing on the assumption the comment might be lying.\n\nThe kindred pattern in this case is also visible. The base class refuses to handle array `IN` because the maintainers knew building placeholder names from array data was hard. The pgsql override does it anyway because dialect-specific case-insensitivity made it necessary. The Parallel Implementation Gap framing fits cleanly: the canonical Sql/Condition is safe because it abstains; the divergent pgsql Condition is unsafe because it overrides the abstention. The 2026 patch wires `array_values()` into the base class's `compile()` so the next dialect override does not have to remember.\n\nDrupal's response to this CVE includes emergency releases for two EOL branches (10.4.x and 11.1.x), which is the maintainer body's standard practice for `Highly Critical` issues. The emergency releases are evidence of the threat model the project actually holds: a publicly disclosed unauthenticated SQLi against the JSON:API endpoint that ships enabled by default is a population-level event, not a per-site one. The five and a half years the comment spent describing the loop and the loop spending five and a half years not honoring the comment is the gap that population was exposed across.","closing_line":"The 2015 author wrote the comment to describe their own code. The 2026 patch rewrote the code to match the comment. The five and a half years between them are the CVE.","hook_md":"Drupal's patch for CVE-2026-9082 is one function call: `array_values()`, wrapped around a foreach in the PostgreSQL Entity Query condition translator. The comment two lines above that foreach has named that exact function since June 30, 2015. Between 2015 and December 18, 2020, the code under the comment did what the comment said. On that date, a refactor that had nothing to do with security replaced the inner counter with the loop key. The comment stayed. The loop did not. The five years and five months between the refactor and the CVE are the window the comment kept telling readers what the code should have been doing.","post_id":424,"slug":"drupal-cve-2026-9082-the-comment-said-values","title":"CVE-2026-9082: The Comment Said Use the Array Values. A 2020 Refactor Started Using the Keys.","type":"initial","unreadable_sentence":"The 2015 author wrote the comment to describe their own code. The 2026 patch rewrote the code to match the comment. The five and a half years between them are the CVE."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCag610gAKCRDeZjl4jgkQ JsItAQC4rUpqtXufUfZb51QAUaQG94Hoq4MxuuAVPPRbFq3uHQEAvJFrW80VSNTw YBv0jq68PX0619p9GTeQ+YFC3B/dcwc= =aXK8 -----END PGP SIGNATURE-----