Skip to content

[AUD-2] agent: TokenScope::ReadOnly leaks JoinLink credentials via willow://server/join-links #436

@intendednull

Description

@intendednull

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-213
Obvious 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_resource returns true unconditionally:

// crates/agent/src/scopes.rs:47-51
pub fn allows_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:

JoinLinkEntry { id: l.link_id, max_uses, uses, active, expires_at }

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.

Impact / Threat

Scenario:

  1. Op deploys agent w/ --scope readonly (assumes "no mutations" = safe to share token broadly), or future patch adds a --scope flag (per [SEC-A-09] Agent HTTP server defaults to TokenScope::Full with no CLI flag #311 follow-up) and operator picks ReadOnly.
  2. Token leaks (logs, accidental sharing, browser extension, copy-paste).
  3. Attacker reads willow://server/join-links → harvests link_id values.
  4. Attacker (separately, no token needed) opens iroh + sends JoinRequest { link_id, attacker_keypair } while the inviter is online.
  5. 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:

match self {
    Self::Full | Self::Admin => true,
    Self::ReadOnly | Self::Messaging => !uri.starts_with("willow://server/join-links"),
    Self::Custom(set) => set.contains(uri),
}

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

Tests to add

  • readonly_scope_denies_join_links() — assert TokenScope::ReadOnly.allows_resource("willow://server/join-links") returns false.
  • E2E: agent w/ ReadOnly scope, attempt read_resource("willow://server/join-links"), expect INVALID_REQUEST.
  • E2E: list_resources under ReadOnly omits the join-links entry.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions