feat: refuse invites from the source address behind a banned member - #85
Draft
sanity wants to merge 3 commits into
Draft
feat: refuse invites from the source address behind a banned member#85sanity wants to merge 3 commits into
sanity wants to merge 3 commits into
Conversation
Per-IP rate limiting bounds how fast one address mints invites but does nothing once that address is known hostile. On 2026-07-26 a single address minted an invite, had the member banned for hate speech within two minutes, came back 37 minutes later on the same address, and did it again. Both identities were banned. Nothing stopped the second from being created, and that address went on to take all four of its daily invites. Record which address minted each invite, and refuse that address for a week when the room moderator reports the member banned. The check sits in the shared network gate, so a blocked source is turned away before any proof of work is issued rather than after paying for it. Scope is deliberately narrow. The block is per-address, not per-subnet: three other addresses in the same /24 took invites that day and none of them produced a banned member, so widening would refuse people on no evidence. And it covers invite issuance only, never Freenet or River, so the refusal is recoverable. A week rather than a day because the addresses that reach this path are rented hosting and VPN exits, where a longer block costs real money to route around. That address belongs to AS212238 (Datacamp/CDNEXT), a bare-metal and CDN host, not a consumer ISP; the rest of the day's invite traffic was Comcast, Charter, Vodafone and similar. It is bought infrastructure, not a compromised home machine. The same property means a block can catch unrelated subscribers sharing a VPN exit, so: the refusal tells the user to retry without the VPN or ask for an invite link, an operator can lift a block immediately via /unblock-source rather than waiting out the week or restarting, and every block records the member that caused it. /report-ban, /block-source and /unblock-source share a constant-time token check, read from a file so the secret is not in the unit file or /proc/<pid>/environ. With no token configured the endpoints return 404 and do nothing: an unauthenticated version would let anyone deny invites to any address. Blocks and mappings persist across restarts via write-then-rename, so a crash mid-write cannot truncate the list into an empty file and silently unblock everything. Mappings expire after 30 days and are capped at 100,000 entries, oldest first. A poisoned lock fails open, since the blocklist must not be able to take invite issuance down. Tests cover the incident sequence end to end through the handlers, that a neighbouring address is unaffected, restart persistence, expiry, early lift, and an unknown member being reported rather than silently doing nothing. The handler test was mutation-checked: disabling the gate fails it.
…ken compare Both from an independent review of this PR. The poisoned-lock handling presented a false dilemma. `is_blocked` bailed out and returned false on a poisoned lock, reasoning that the blocklist must never be able to take invite issuance down. But poisoning is sticky, so a single panic anywhere under that lock silently disabled the security control for the rest of the process lifetime, with a `warn!` as the only evidence. If a panic is reachable under the lock that is an attacker-reachable escalation; if it is not, it is still a control that can switch itself off unobserved. `PoisonError::into_inner()` recovers the guard, so the control keeps enforcing AND issuance stays up. That is strictly better than either option originally weighed. The data behind the lock is a map rewritten whole on each mutation, so a panic cannot leave it half-updated in a way that matters. The token check hand-rolled a constant-time compare that short-circuited on `presented.len() == expected.len()`, leaking the token length, and nothing stopped the optimiser from short-circuiting the fold either. Compare SHA-256 digests with `subtle::ConstantTimeEq` instead: inputs are equal-length regardless of what was presented, and the primitive is purpose-built. `subtle` was already in the tree via ed25519-dalek, so this adds no new transitive dependency.
Every call site recovers the guard rather than surfacing a lock failure, so the variant is unconstructible.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Per-IP rate limiting bounds how fast one address can mint invites. It does nothing once that address is known to be hostile.
On 2026-07-26,
170.62.100.54:S5VJWFCV3Q4SVBTKHI6M76G6WSVRYONKOne address, all four of its daily invites, and both of that day's confirmed hate spammers. The second identity was created 37 minutes after the first was banned, from the same address, and nothing stopped it.
Approach
Record which address minted each invite. When the room moderator reports that member banned, refuse that address for a week.
The check lives in
check_invite_network, the shared gate, so a blocked source is turned away before proof of work is issued rather than after paying for it.Scope is deliberately narrow
170.62.100.0/24took invites that day (.43,.183,.204) and none produced a banned member. Widening to the subnet would refuse people on no evidence. Pinned byblocks_a_single_address_not_its_neighbours.Why a week
The addresses reaching this path are rented infrastructure, where a longer block costs real money to route around.
170.62.100.54is AS212238, Datacamp Limited (CDNEXT) — a bare-metal and CDN host, no reverse DNS. The rest of that day's invite traffic was Comcast, Charter, Vodafone, Cable One, Two Degrees NZ, Faisal Cable PK. Consumer ISPs. The abuser's address is categorically different: bought infrastructure, not a compromised home machine.The tradeoff that follows from that
Datacamp space is widely rented by consumer VPN providers, so a block can catch unrelated subscribers sharing an exit — and Freenet's audience skews toward VPN users. Mitigations:
POST /unblock-sourcelifts a block immediately, without waiting out the week or restarting;This raises cost, it does not stop a determined actor. Someone who rotates addresses takes the next one. What it kills is the cheap case above.
Endpoints
All three share a constant-time token check. The token is read from a file, not an env var, so it is not in the unit file or
/proc/<pid>/environ. With no token configured they return 404 and do nothing — an unauthenticated version would let anyone deny invites to any address.POST /report-ban{member_id}POST /block-source{ip, reason?}POST /unblock-source{ip}Durability
Write-then-rename, so a crash mid-write cannot truncate the list into an empty file and silently unblock everything. Mappings expire after 30 days, capped at 100,000 entries oldest-first. A poisoned lock fails open — the blocklist must never be able to take invite issuance down; rate limiting and proof of work still apply.
Testing
cargo test -p ghostkey-api: 52 passed.cargo fmt --checkclean. Clippy clean for new code (two remaining warnings are pre-existing intor.rs).Covers the incident sequence end to end through the handlers, neighbouring addresses unaffected, restart persistence, expiry, early lift, direct block, and an unknown member reported rather than silently doing nothing.
The handler test was mutation-checked: disabling the gate makes it fail, so it is not vacuous.
Deployment
Two new options, both with safe defaults:
INVITE_BLOCKLIST_FILE(default/var/lib/gkapi/invite_blocklist.json)BAN_REPORT_TOKEN_FILE— no default; without it the endpoints are disabledSince gkapi deploys by rebuilding from
main, this needs merge before it can go live. Built and tested againstc0ebbf26, the commit currently deployed on vega.Follow-up
The moderator side is not in this PR:
river-moderatorneeds toPOST /report-banafter a successful ban. Until then the mechanism is operator-driven via/block-source.[AI-assisted - Claude]