You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
// crates/agent/src/scopes.rs:47-51pubfnallows_resource(&self,_uri:&str) -> bool{// All scopes allow all resources.true}
So every scope — incl. TokenScope::ReadOnly — can read every resource. One of those resources is willow://server/join-links (crates/agent/src/resources.rs:200-213), which returns:
The link_id is the bearer credential for WireMessage::JoinRequest (crates/client/src/listeners.rs:382-470). Anyone holding a valid link_id + a fresh keypair can broadcast JoinRequest { link_id, peer_id } on SERVER_OPS_TOPIC; an inviter holding that link in their local join_links cache will respond w/ JoinResponse { invite_data } encrypted to the requester's pubkey, granting full SendMessages permission per joining.rs:72-87.
I.e., link_id is not metadata — it's a single-step credential.
Attacker (separately, no token needed) opens iroh + sends JoinRequest { link_id, attacker_keypair } while the inviter is online.
Attacker now holds full server membership w/ a fresh identity.
Today partially gated by #311 (only Full scope reachable via CLI), so impact is latent — but it's a design flaw that bites the moment --scope readonly|messaging ships.
Suggested fix
Either:
(a) Per-scope resource allowlist (preferred). Replace _uri body w/ a match self:
Apply same exclusion to list_resources (already filtered via .filter(|r| self.scope.allows_resource(...)) in server.rs:151).
(b) Strip link_id from JoinLinkEntry so the resource only exposes counters, not credentials. Loses the ability to reconstruct/share invite URL from agent — probably unwanted.
(a) is cleaner: explicit scope/credential boundary.
Verify
# Confirm allows_resource always true:
sed -n '47,51p' crates/agent/src/scopes.rs
# Confirm link_id is the bearer credential:
rg -n "JoinRequest \{ link_id" crates/client/src/
# Confirm resource exposes link_id:
sed -n '200,215p' crates/agent/src/resources.rs
Audit finding from general-audit @
00aa515(2026-04-27)Severity: medium (privilege escalation / defense-in-depth)
Category: auth / authorization
Files:
crates/agent/src/scopes.rs:47-51,crates/agent/src/resources.rs:200-213Obvious fix: yes (close to landing — gate plumbing exists but no scope ever denies)
Related: #311 (TokenScope::Full default), #408 (read_resource scope-gate enforcement)
Description
TokenScope::allows_resourcereturnstrueunconditionally:So every scope — incl.
TokenScope::ReadOnly— can read every resource. One of those resources iswillow://server/join-links(crates/agent/src/resources.rs:200-213), which returns:The
link_idis the bearer credential forWireMessage::JoinRequest(crates/client/src/listeners.rs:382-470). Anyone holding a validlink_id+ a fresh keypair can broadcastJoinRequest { link_id, peer_id }onSERVER_OPS_TOPIC; an inviter holding that link in their localjoin_linkscache will respond w/JoinResponse { invite_data }encrypted to the requester's pubkey, granting full SendMessages permission perjoining.rs:72-87.I.e.,
link_idis not metadata — it's a single-step credential.Impact / Threat
Scenario:
--scope readonly(assumes "no mutations" = safe to share token broadly), or future patch adds a--scopeflag (per [SEC-A-09] Agent HTTP server defaults to TokenScope::Full with no CLI flag #311 follow-up) and operator picks ReadOnly.willow://server/join-links→ harvestslink_idvalues.JoinRequest { link_id, attacker_keypair }while the inviter is online.Today partially gated by #311 (only
Fullscope reachable via CLI), so impact is latent — but it's a design flaw that bites the moment--scope readonly|messagingships.Suggested fix
Either:
(a) Per-scope resource allowlist (preferred). Replace
_uribody w/ amatch self:Apply same exclusion to
list_resources(already filtered via.filter(|r| self.scope.allows_resource(...))inserver.rs:151).(b) Strip
link_idfromJoinLinkEntryso the resource only exposes counters, not credentials. Loses the ability to reconstruct/share invite URL from agent — probably unwanted.(a) is cleaner: explicit scope/credential boundary.
Verify
Tests to add
readonly_scope_denies_join_links()— assertTokenScope::ReadOnly.allows_resource("willow://server/join-links")returns false.read_resource("willow://server/join-links"), expectINVALID_REQUEST.list_resourcesunder ReadOnly omits the join-links entry.