Reproduction steps:
- Two stations in the same team; the attacker holds a shareable credential
(any member with the operator role — the role every share_grant sender needs).
- Attacker mints a share_grant for a victim teammate the normal way, but sets
the grant's inner grant_id to a traversal string instead of the usual
"grt_<hex>", e.g. grant_id = "../../../STATION_OWNED" (any relative path; the
handler appends ".json"). The attacker signs the grant with their own key —
grant_id is part of the canonically-signed body, so the signature is valid.
- Attacker seals the grant to the victim's key and sends it as a normal
operator-role share_grant envelope (the malicious grant_id rides INSIDE the
sealed body, so the outer envelope is well-formed; envelope_id is a normal
"env_<hex>").
- Victim's poll loop calls team_mesh.receive() -> verify_envelope (passes: valid
sig, member, operator role) -> team_share._handle_incoming_grant, which
decrypts the grant, runs _verify_grant (passes: holder sig valid, both parties
in the manifest), then writes
team_share.py:209 _jwrite(os.path.join(_dirp(ws,"grants_in"),
grant["grant_id"] + ".json"), grant)
- Run repro_team_share_grant_id_path_traversal_v099.py against a clean v0.99
extraction. It drives the REAL modules end to end (real Ed25519, real
ChaCha20-Poly1305 seal/unseal, a real root-signed manifest, the real
mesh.receive entry point) and clobbers a *.json file OUTSIDE the workspace.
Expected:
An id received from a teammate and used as a filename should be validated to a
fixed charset/shape (like envelope_id/from_pubkey/sig are) before it is joined
into a path. A grant_id of "../../../x" should be rejected as malformed, never
written.
Actual:
grant_id is generated on the SEND side as "grt_" + secrets.token_hex(12), but on
the RECEIVE side it comes straight from the decrypted payload and is validated
NOWHERE. _verify_grant checks the holder's Ed25519 signature over the canonical
grant — and grant_id is included in the signed bytes — so a holder who signs
grant_id="../../../x" produces a grant that verifies "ok". Unlike envelope_id
(forced "env_" prefix, so its first path component can never be a bare ".."),
grant_id has no prefix, so the first component IS ".." — a real parent-dir hop,
clean escape, no ENOENT. verify_envelope only inspects the OUTER envelope
(envelope_id/from/sig/role); the malicious grant_id is inside the sealed body, so
the mesh layer cannot catch it. Result: any current team member can create/
overwrite an arbitrary .json file anywhere the station process can write, on a
PEER'S station, by sending one message. Security-relevant targets a station
trusts are all .json: team/approval_policy.json (relax approvals),
team/manifest.json / team/manifest_highwater.json (membership / replay floor),
plan pins, workflow specs.
Proof (repro output):
forged grant_id : '../../../STATION_OWNED'
holder signature over that grant_id VERIFIES: (True, 'ok')
mesh.receive() -> True
CONFIRMED — a file OUTSIDE the workspace now contains the attacker's grant JSON.
Suggested fix:
Validate every id that arrives over the mesh and is used as a path component,
at the trust boundary, against the same fixed shape the sender mints — before it
ever reaches os.path.join. In team_share._handle_incoming_grant, right after
decrypt/_verify_grant:
import re
_GRANT_ID = re.compile(r"^grt_[0-9a-f]{24}$")
...
if not _GRANT_ID.match(str(grant.get("grant_id", ""))):
mesh._inbox_append(ws, {"at": _now_iso(),
"verdict": "share_grant_rejected", "reason": "malformed grant_id",
"envelope_id": env.get("envelope_id")})
return
_jwrite(os.path.join(_dirp(ws, "grants_in"), grant["grant_id"] + ".json"), grant)
Apply the identical guard to the SIBLING receive-side sinks that build a filename
from an incoming/decrypted id, all of which share this class:
- team_share.py:308 grant_id from a capability_use_request (traversal READ)
- team_share.py:348/366 use_id from a capability_use_result (READ + overwrite)
- team_jobs.py:156/193 job_id from a job_result (READ + overwrite)
- team_approval.py:308 env["envelope_id"] as the filename (write; the forced
"env_" prefix blocks a clean escape today, but it is the same unguarded
pattern — pin it to ^env_[0-9a-f]{32}$).
A single helper (validate_id(kind, value) -> bool, rejecting anything not
matching the minter's charset) applied at each boundary closes the class. Do NOT
rely on os.path.basename alone — the fix is to reject, so a malformed id is
recorded as a denial, not silently coerced to a different (still attacker-chosen) filename.