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
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 }andJoinDenied { target_peer, reason }are accepted by the requester whenevertarget_peer == self.endpoint_id(), with no check thatsigneris the actual inviter the requester sent itsJoinRequestto. Theinvite_datapayload is encrypted to the requester's pubkey, so a forged response can't smuggle a working invite, butJoinDeniedcarries an unencrypted attacker-controlledreasonstring surfaced to the user via theJoinLinkDeniedevent, and a forgedJoinResponsecauses 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. WithJoinResponse, the worst case is denial-of-service (UI churn) and a phishing surface via thereasontext.Suggested fix
Track outstanding join requests by
(link_id, inviter_endpoint)and requiresigner == expected_inviteronJoinResponse/JoinDenied. Sanitize / strip / length-limitreasonbefore display.Verify
rg -n "WireMessage::JoinResponse|WireMessage::JoinDenied" crates/client/src/listeners.rs