Skip to content

feat: refuse invites from the source address behind a banned member - #85

Draft
sanity wants to merge 3 commits into
mainfrom
feat/invite-source-blocklist
Draft

feat: refuse invites from the source address behind a banned member#85
sanity wants to merge 3 commits into
mainfrom
feat/invite-source-blocklist

Conversation

@sanity

@sanity sanity commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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:

Time (UTC) Member Outcome
16:33:28 S5VJWFCV banned for hate speech at 16:35
17:10:11 3Q4SVBTK banned for hate speech at 18:13
17:10:44 HI6M76G6 gone
17:15:42 WSVRYONK gone

One 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

  • Per-address, not per-subnet. Three other addresses in 170.62.100.0/24 took invites that day (.43, .183, .204) and none produced a banned member. Widening to the subnet would refuse people on no evidence. Pinned by blocks_a_single_address_not_its_neighbours.
  • Invite issuance only. Never Freenet or River themselves, so a wrongly blocked user is inconvenienced, not cut off.

Why a week

The addresses reaching this path are rented infrastructure, where a longer block costs real money to route around. 170.62.100.54 is 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:

  • the refusal tells the user what to do (retry without the VPN, or ask for an invite link) rather than leaving them stuck;
  • POST /unblock-source lifts a block immediately, without waiting out the week or restarting;
  • every block records the member whose ban caused it, so blocks are reviewable.

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.

Endpoint Purpose
POST /report-ban {member_id} Block the source behind a banned member
POST /block-source {ip, reason?} Block an address directly, including ones predating the ledger
POST /unblock-source {ip} Lift a block, and list what remains

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 --check clean. Clippy clean for new code (two remaining warnings are pre-existing in tor.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_FILEno default; without it the endpoints are disabled

Since gkapi deploys by rebuilding from main, this needs merge before it can go live. Built and tested against c0ebbf26, the commit currently deployed on vega.

Follow-up

The moderator side is not in this PR: river-moderator needs to POST /report-ban after a successful ban. Until then the mechanism is operator-driven via /block-source.

[AI-assisted - Claude]

sanity added 3 commits July 26, 2026 13:47
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant