Flood protection: SSRF defense, per-source/dest rate limits, SYN-flood signature, reputation store#16
Open
not-amplify wants to merge 7 commits into
Open
Conversation
Wires the existing remoteIP/ParseRealIP scaffolding to a proper client-IP resolver that honors X-Forwarded-For / CF-Connecting-IP only when the immediate peer is in trustedProxies. Required for accurate flood-protection, reputation, and logging. Adds: - wisp/clientip.go + tests (5 covering trusted/untrusted/XFF/garbage) - Config.TrustedProxies []string (CIDRs) - Config.TrustedHeaders []string (default: CF-Connecting-IP, X-Forwarded-For) - parsed cache cfg.trustedProxyNets, populated in InitResolver
…ssPolicy Activates the already-declared but unused safety flags in Config: - AllowDirectIP=false now refuses CONNECTs to IP-literal hostnames - AllowPrivateIPs=false / AllowLoopbackIPs=false now refuse CONNECTs whose resolved IP is in a private / loopback / link-local / multicast / unspecified range - IP-literal evaluation is also routed through the EgressPolicy so ::ffff:127.0.0.1 (IPv4-mapped IPv6 loopback) is rejected correctly - The DNS resolution loop now picks the first allowed IP rather than blindly taking ips[0], so DNS responses interleaving allowed and denied records cannot SSRF Adds wisp/egress.go + tests (6 covering private/public/allow/deny/v6).
…ture Three small dependency-free modules used by the next commit to enforce per-source / per-destination rate limits and detect SYN-flood patterns: - SlidingWindow: keyed fixed-window limiter (nil-safe = allow-all) - Semaphore: lock-free counting semaphore (nil-safe = unlimited) - Signatures + Detector: per-(connection, destination) ring buffer of dial outcomes; matches when MinSamples reached AND failed/total exceeds FailedHandshakeRatio in the configured time window Tests cover: rate limit boundary, window rollover, 16-way concurrent contention, nil-safety, signature matching/non-matching, eviction.
Tracks per-source-IP and per-destination-ip:port scores (0-100). Bad behavior raises score; long-lived successful streams lower it. Persisted to JSON via atomic-rename so it survives restart, with eviction of low-score idle entries. Designed for the distributed-source attack pattern the OVH abuse tickets revealed: client-side script on a popular attacker site drives many residential IPs at one target, each looking innocent per-source. The destination accumulates flags from distinct sources, eventually crossing the strict tier and refusing new CONNECTs to it. Tests cover: score arithmetic, clamping, distinct-source escalation, JSON round-trip, decay, tier mapping, nil-safety (7 tests).
Adds FloodProtectionConfig + Globals to wisp.Config, builds the global limiters at handler init, and wires every CONNECT through: per-WS concurrent-stream cap per-source-IP sliding window per-destination IP:port sliding windows (sec + min) reputation strict-tier refusal in-flight SYN semaphore (TCP only) per-(WS, dst) SYN-flood signature detector with WS-close on match WsCloseAfterViolations escalates repeat offenders by closing the entire WebSocket after N enforcement hits. Also caps total concurrent WS connections at upgrade time, releases the semaphore on disconnect, and forgets per-WS signature state. Twisp gating now requires v2 + PasswordAuth + completed auth (using upstream's existing twispAuthorized() + authenticated flag); v1 twisp is refused outright. Every block emits a structured wisp.Logger Warn line for fail2ban-friendly ingest. Tests: existing test suite still passes; smoke test boots and returns the expected 400 to non-WS probes.
passwordUsers entries that start with a bcrypt prefix ($2a$/$2b$/$2y$) are verified via golang.org/x/crypto/bcrypt; plaintext entries continue to work but emit a one-time deprecation warning to the configured Logger. The constant-time comparison is preserved for plaintext. Tests cover plaintext match/mismatch, bcrypt match/mismatch, and length-mismatch (constant-time).
Documents the new floodProtection and reputation config blocks, the trustedProxies/trustedHeaders fields, the twisp-auth requirement, and bcrypt password support. Also notes the SSRF-by-default behavior change (allowDirectIP / allowPrivateIPs / allowLoopbackIPs default false). Renames example.config.json's parseRealIPFrom to trustedProxies to match the field actually consumed by the Go server.
Collaborator
Author
|
praise opus 4.7 |
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.
Background
OVHcloud has issued multiple abuse tickets against deployments of mrrowisp (and other Wisp implementations) showing the server being abused as a TCP SYN flood relay:
15.204.247.101to153.75.225.178:8115.204.116.230to the same destination. Edge-firewall blocking the actor's source IP did not stop the attack — because the server itself is the SYN source.The attack pattern: an actor opens a Wisp WebSocket and sends thousands of legitimate
CONNECTpackets at one chosen victim. Each becomes a realnet.Dialfrom the server. The victim drops or RSTs the SYN-ACKs, so OVH's egress detector sees SYN-only outbound traffic. A second variant uses a client-side script on a popular attacker site to drive many residential IPs at the same victim, defeating per-source-IP rate limits.This PR adds defense in depth.
What's in the PR
7 focused commits on top of
main(commit568c34b"roll back prot features"). All wisp tests pass with-race -count=1.wisp: populate wispConnection.remoteIP via ResolveClientIP— The existingremoteIP/ParseRealIPscaffolding was unwired. This addswisp/clientip.go(5 tests) and populatesc.remoteIPfromr.RemoteAddrplustrustedProxies+trustedHeaders(defaultCF-Connecting-IP,X-Forwarded-For) when the immediate peer is trusted.wisp: enforce AllowDirectIP / AllowPrivateIPs / AllowLoopbackIPs via EgressPolicy— These three Config flags were declared but never enforced. Addswisp/egress.go(6 tests) and wires it intohandleConnect: IP-literal hostnames are refused whenallowDirectIP=false; resolved IPs in private/loopback/link-local/multicast/unspecified ranges are refused unless the matching flag is set. The DNS resolution loop now walks all returned addresses and picks the first allowed one rather than blindly takingips[0].wisp: add sliding-window limiter, counting semaphore, SYN-flood signature— Three small dependency-free modules:SlidingWindow(keyed fixed-window limiter, nil-safe),Semaphore(lock-free counting semaphore),Signatures+Detector(per-(WS, dst) ring buffer matching when failed-handshake fraction crosses a configurable threshold). 11 tests including 16-way concurrent contention.wisp: add persistent reputation store— Tracks 0–100 scores per source IP and per destination IP:port. JSON persistence via atomic-rename. Decay (scoreDecayPerHour), eviction (evictAfterDays), tier mapping (warn/throttle/strict). Designed for the distributed-source attack pattern: the destination accumulates flags from distinct sources and crosses the strict tier when many residential IPs each look innocent individually but collectively target one victim. 7 tests.wisp: integrate flood-protection pipeline into handleConnect— AddsFloodProtectionConfig+Globalsto Config, builds limiters at handler init, wires every CONNECT through: per-WS concurrent-stream cap → per-source-IP rate → per-destination IP:port rate (sec + min) → reputation strict-tier refusal → in-flight SYN semaphore → SYN-flood signature with WS-close on match.WsCloseAfterViolationsescalates repeat offenders. Also caps total concurrent WS connections at upgrade. Twisp gating now requires v2 +passwordAuth+ completed auth (using your existingtwispAuthorized()+authenticatedflag); v1 twisp is refused outright. Every block emits aLogger.Warnline for fail2ban ingest.wisp/v2: optional bcrypt password storage—passwordUsersentries that begin with$2a$/$2b$/$2y$are verified viagolang.org/x/crypto/bcrypt. Plaintext entries continue to work but emit a one-time deprecation warning. The constant-time compare you already had is preserved for plaintext.docs: README and example.config.json updated for flood protection— Documents the new config and the SSRF-by-default behavior change. RenamesparseRealIPFromtotrustedProxiesin the example to match the actual Go field.Defaults (all tunable)
The per-destination caps are tight by design — 8 SYNs/sec to a single
host:portfrom one connection covers any real client (browsers reuse connections; a DNS-load-balanced site has many distinct IPs anyway) but crushes the observed 22-31 Kpps abuse pattern by 4 orders of magnitude.What I did NOT change
permessage-deflateflag — left as-is (the upgrader already disables it post-rollback).blacklist.hostnames: []— there's a pre-existing schema mismatch (Go expectsmap[string]struct{}, JSON sends[]string) that prevents loading the example file. Out of scope for this PR; happy to follow up.Testing
32 tests, 0 failures. Smoke test:
./mrrowisp -port 16006boots, returns 400 to non-WS probes, shuts down cleanly on SIGTERM.Test plan for review
go test ./... -race -count=1passes1.1.1.1:443allowDirectIP=false(default) refuses CONNECT to1.1.1.1literal but allowsone.one.one.oneallowPrivateIPs=false(default) refuses CONNECT tolocalhost.localtest.me(resolves to 127.0.0.1)dstIP:dstPortfrom one WS triggerscloseReasonThrottledafter the 8thpasswordUsersauthenticates correctlyHappy to iterate on naming, defaults, or the integration approach. The intent is to give you a drop-in protection layer that addresses the OVH-reported abuse without rewriting your existing protocol code.