Skip to content

Flood protection: SSRF defense, per-source/dest rate limits, SYN-flood signature, reputation store#16

Open
not-amplify wants to merge 7 commits into
soap-phia:mainfrom
not-amplify:hardening-upstream
Open

Flood protection: SSRF defense, per-source/dest rate limits, SYN-flood signature, reputation store#16
not-amplify wants to merge 7 commits into
soap-phia:mainfrom
not-amplify:hardening-upstream

Conversation

@not-amplify
Copy link
Copy Markdown
Collaborator

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:

  • Ticket 678956 (2026-05-07): 22 Kpps / 11 Mbps SYN-only burst from 15.204.247.101 to 153.75.225.178:81
  • Ticket 680426 (2026-05-11): 31 Kpps / 15 Mbps SYN-only burst from 15.204.116.230 to 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 CONNECT packets at one chosen victim. Each becomes a real net.Dial from 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 (commit 568c34b "roll back prot features"). All wisp tests pass with -race -count=1.

  1. wisp: populate wispConnection.remoteIP via ResolveClientIP — The existing remoteIP / ParseRealIP scaffolding was unwired. This adds wisp/clientip.go (5 tests) and populates c.remoteIP from r.RemoteAddr plus trustedProxies + trustedHeaders (default CF-Connecting-IP, X-Forwarded-For) when the immediate peer is trusted.

  2. wisp: enforce AllowDirectIP / AllowPrivateIPs / AllowLoopbackIPs via EgressPolicy — These three Config flags were declared but never enforced. Adds wisp/egress.go (6 tests) and wires it into handleConnect: IP-literal hostnames are refused when allowDirectIP=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 taking ips[0].

  3. 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.

  4. 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.

  5. wisp: integrate flood-protection pipeline into handleConnect — Adds FloodProtectionConfig + Globals to 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. WsCloseAfterViolations escalates repeat offenders. Also caps total concurrent WS connections at upgrade. Twisp gating now requires v2 + passwordAuth + completed auth (using your existing twispAuthorized() + authenticated flag); v1 twisp is refused outright. Every block emits a Logger.Warn line for fail2ban ingest.

  6. wisp/v2: optional bcrypt password storagepasswordUsers entries that begin with $2a$ / $2b$ / $2y$ are verified via golang.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.

  7. docs: README and example.config.json updated for flood protection — Documents the new config and the SSRF-by-default behavior change. Renames parseRealIPFrom to trustedProxies in the example to match the actual Go field.

Defaults (all tunable)

"floodProtection": {
  "enabled": true,
  "maxConnectsPerSourceIPPerSecond": 50,
  "maxConnectsPerDestPerSecond": 8,
  "maxConnectsPerDestPerMinute": 60,
  "maxInFlightSyns": 256,
  "maxConcurrentStreamsPerConnection": 256,
  "maxConcurrentConnections": 1024,
  "synFloodSignature": {
    "enabled": true, "windowMs": 2000,
    "minSamples": 32, "failedHandshakeRatio": 0.75
  },
  "wsCloseAfterViolations": 16
}

The per-destination caps are tight by design — 8 SYNs/sec to a single host:port from 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

  • The hand-rolled WS frame reader — just adds an integration seam, no rewrite.
  • Your permessage-deflate flag — left as-is (the upgrader already disables it post-rollback).
  • The static-dir / non-WS-response code paths — untouched.
  • example.config.json's blacklist.hostnames: [] — there's a pre-existing schema mismatch (Go expects map[string]struct{}, JSON sends []string) that prevents loading the example file. Out of scope for this PR; happy to follow up.

Testing

$ go test ./... -race -count=1
ok  	mrrowisp/wisp	1.21s

32 tests, 0 failures. Smoke test: ./mrrowisp -port 16006 boots, returns 400 to non-WS probes, shuts down cleanly on SIGTERM.

Test plan for review

  • go test ./... -race -count=1 passes
  • Default config still boots (no example file required)
  • A WebSocket client can connect, complete v2 handshake (with or without auth), and open a TCP CONNECT to 1.1.1.1:443
  • allowDirectIP=false (default) refuses CONNECT to 1.1.1.1 literal but allows one.one.one.one
  • allowPrivateIPs=false (default) refuses CONNECT to localhost.localtest.me (resolves to 127.0.0.1)
  • Sending 50 CONNECTs/sec to the same dstIP:dstPort from one WS triggers closeReasonThrottled after the 8th
  • Sending 50 CONNECTs to different attacker-controlled ports (most failing handshake) triggers the SYN signature and the WS closes
  • A bcrypt-hashed entry in passwordUsers authenticates correctly

Happy 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.

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.
@not-amplify
Copy link
Copy Markdown
Collaborator Author

praise opus 4.7

@not-amplify not-amplify requested a review from soap-phia May 19, 2026 15:15
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