Skip to content

[SEC-A-07] JoinResponse / JoinDenied not bound to the inviter; spoofable by any signer #309

@intendednull

Description

@intendednull

Audit finding from #300 (commit 679f9fe)

Severity: low
Category: auth / authorization
File: crates/client/src/listeners.rs:519
Obvious fix: yes

Description

JoinResponse { target_peer, invite_data } and JoinDenied { target_peer, reason } are accepted by the requester whenever target_peer == self.endpoint_id(), with no check that signer is the actual inviter the requester sent its JoinRequest to. The invite_data payload is encrypted to the requester's pubkey, so a forged response can't smuggle a working invite, but JoinDenied carries an unencrypted attacker-controlled reason string surfaced to the user via the JoinLinkDenied event, and a forged JoinResponse causes the requester to attempt decryption and surface a UI-level join attempt.

Impact / Threat

Any peer that observes a join flow can race a JoinDenied { reason: "invite expired — try foo.example/phish" } to the requester and spoof rejection messaging. With JoinResponse, the worst case is denial-of-service (UI churn) and a phishing surface via the reason text.

Suggested fix

Track outstanding join requests by (link_id, inviter_endpoint) and require signer == expected_inviter on JoinResponse / JoinDenied. Sanitize / strip / length-limit reason before display.

Verify

rg -n "WireMessage::JoinResponse|WireMessage::JoinDenied" crates/client/src/listeners.rs

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions