Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,19 @@ Copy `example.config.json` to `config.json` and edit as needed:
| `certAuthPublicKeys` | []string | Allowed Ed25519 public keys (hex-encoded) |
| `enableStreamConfirm` | bool | Send confirmation when streams connect |

### Flood protection & egress

To prevent the server from being abused as a TCP SYN flood relay (see OVH abuse reports the maintainers received), this build adds:

- **SSRF defense:** `allowDirectIP` now refuses CONNECTs to IP-literal hostnames; `allowPrivateIPs` and `allowLoopbackIPs` reject CONNECTs whose resolved IP is in private/loopback/link-local/multicast ranges. The DNS resolution loop walks all returned addresses and picks the first allowed one, so DNS responses can't bypass the policy by interleaving private and public IPs.
- **Per-source / per-destination rate limits:** sliding-window limiters on source IP, destination IP:port (sec + min). Defaults: 50/sec/source, 8/sec/dest, 60/min/dest. Tune via `floodProtection`.
- **In-flight SYN cap:** counting semaphore around outbound TCP dials.
- **SYN-flood signature detector:** per-(WS, dst) ring buffer of dial outcomes. When ≥`minSamples` attempts in `windowMs` exceed `failedHandshakeRatio` failed-handshake fraction, the WS is closed.
- **Reputation store:** persists 0–100 scores per source IP and per destination IP:port to `reputation.storePath` via atomic-rename. Bad behavior (egress violations, SYN signatures, twisp-without-auth, repeated burst-rate hits) raises the score; long-lived successful streams lower it. When a destination's score reaches the strict threshold (default 81) — which happens when many distinct sources hit it — new CONNECTs to that destination are refused. This is the defense against the popular-website-script attack pattern where many residential IPs each look innocent on their own but collectively target one victim.
- **Trusted proxy support:** `parseRealIP: true` plus `trustedProxies` (CIDRs) plus `trustedHeaders` (default: CF-Connecting-IP, X-Forwarded-For). Headers are honored only when the immediate peer is in `trustedProxies`.
- **Twisp now requires auth:** with `enableTwisp: true`, you must also enable `passwordAuth` and the client must pass the v2 auth handshake. Anonymous twisp on v1 is refused. `passwordUsers` may contain bcrypt hashes (prefix `$2a$`/`$2b$`/`$2y$`); plaintext is deprecated and logs a warning on first use.
- **Structured logging:** every block emits a Logger.Warn line — fail2ban-friendly. Example regex: `"flood block".*"ip", "<HOST>"`.

## Credits
- [soap phia](https://github.com/soap-phia/) - writing most of this
- [rebecca](https://github.com/rebeccaheartz69/) - greatly helping with implementing wisp v2 and extensions
Expand Down
43 changes: 41 additions & 2 deletions example.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"connectionsLimitPerIP": 20,
"connectionWindowSeconds": 10,
"parseRealIP": true,
"parseRealIPFrom": ["127.0.0.1"],
"trustedProxies": ["127.0.0.1"],
"trustedHeaders": ["CF-Connecting-IP", "X-Forwarded-For"],
"maxMessageSize": 0,
"staticDir": "",
"nonWSResponse": "mrrow merp >w<",
Expand All @@ -59,5 +60,43 @@
"maxConnectionsPerIP": 0,
"globalMaxConnections": 0,
"writeQueueSize": 4096,
"maxInboundBytesPerSecond": 0
"maxInboundBytesPerSecond": 0,
"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,
"logBlockedDials": true
},
"reputation": {
"enabled": true,
"storePath": "./data/mrrowisp-reputation.json",
"saveIntervalSeconds": 30,
"scoreDecayPerHour": 1,
"evictAfterDays": 7,
"thresholds": { "warn": 21, "throttle": 51, "strict": 81 },
"weights": {
"privateEgress": 15,
"synSignature": 25,
"twispNoAuth": 40,
"burstRate": 5,
"successfulStream": -2,
"requestKnownBadDest": 2
},
"destinationWeights": {
"privateEgress": 20,
"synSignature": 30,
"distinctSourcesEscalation": 1
}
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/creack/pty v1.1.21
github.com/lxzan/gws v1.8.3
golang.org/x/crypto v0.51.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.9.0
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
Expand Down
56 changes: 56 additions & 0 deletions wisp/clientip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package wisp

import (
"net"
"net/http"
"strings"
)

// ResolveClientIP returns the originating client IP for r. If the immediate
// peer (r.RemoteAddr) is contained in trustedProxies, the named headers are
// consulted in order; the first usable IP that is itself NOT in
// trustedProxies (walking right-to-left for XFF semantics) is returned.
// Otherwise the peer IP is returned. Always returns a non-nil IP; falls
// back to the IPv4 unspecified address (0.0.0.0) on parse error.
func ResolveClientIP(r *http.Request, trustedProxies []*net.IPNet, headers []string) net.IP {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer := net.ParseIP(host)
if peer == nil {
return net.IPv4zero
}

if !ipInAny(peer, trustedProxies) {
return peer
}

for _, h := range headers {
v := r.Header.Get(h)
if v == "" {
continue
}
parts := strings.Split(v, ",")
for i := len(parts) - 1; i >= 0; i-- {
candidate := net.ParseIP(strings.TrimSpace(parts[i]))
if candidate == nil {
continue
}
if ipInAny(candidate, trustedProxies) {
continue
}
return candidate
}
}
return peer
}

func ipInAny(ip net.IP, nets []*net.IPNet) bool {
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
65 changes: 65 additions & 0 deletions wisp/clientip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package wisp

import (
"net"
"net/http"
"testing"
)

func mustCIDR(s string) *net.IPNet {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic(err)
}
return n
}

func TestResolveClientIPFromRemoteAddr(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.RemoteAddr = "203.0.113.5:12345"
ip := ResolveClientIP(r, nil, nil)
if ip.String() != "203.0.113.5" {
t.Fatalf("got %v", ip)
}
}

func TestResolveClientIPIgnoresUntrustedHeader(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.RemoteAddr = "203.0.113.5:1"
r.Header.Set("X-Forwarded-For", "1.1.1.1")
ip := ResolveClientIP(r, nil, []string{"X-Forwarded-For"})
if ip.String() != "203.0.113.5" {
t.Fatalf("got %v", ip)
}
}

func TestResolveClientIPTrustsHeaderFromTrustedProxy(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.0.0.5:1"
r.Header.Set("CF-Connecting-IP", "198.51.100.7")
trusted := []*net.IPNet{mustCIDR("10.0.0.0/8")}
ip := ResolveClientIP(r, trusted, []string{"CF-Connecting-IP"})
if ip.String() != "198.51.100.7" {
t.Fatalf("got %v", ip)
}
}

func TestResolveClientIPXFFRightmostInTrust(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.0.0.5:1"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8")
trusted := []*net.IPNet{mustCIDR("10.0.0.0/8")}
ip := ResolveClientIP(r, trusted, []string{"X-Forwarded-For"})
if ip.String() != "5.6.7.8" {
t.Fatalf("got %v", ip)
}
}

func TestResolveClientIPFallbackOnGarbage(t *testing.T) {
r, _ := http.NewRequest("GET", "/", nil)
r.RemoteAddr = "garbage"
ip := ResolveClientIP(r, nil, nil)
if !ip.IsUnspecified() {
t.Fatalf("got %v", ip)
}
}
58 changes: 52 additions & 6 deletions wisp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,13 @@ type Config struct {
PasswordAuthRequired bool `json:"passwordAuthRequired"`
PasswordUsers map[string]string `json:"passwordUsers"`

ParseRealIP bool `json:"parseRealIP"`
NonWSResponse string `json:"nonWSResponse"`
ParseRealIP bool `json:"parseRealIP"`
TrustedProxies []string `json:"trustedProxies"`
TrustedHeaders []string `json:"trustedHeaders"`
NonWSResponse string `json:"nonWSResponse"`

// Parsed at construction; not user-visible JSON.
trustedProxyNets []*net.IPNet

LogLevel string `json:"logLevel"`

Expand All @@ -63,10 +68,47 @@ type Config struct {

BufferRemainingLength uint32 `json:"bufferRemainingLength"`

FloodProtection *FloodProtectionConfig `json:"floodProtection"`
Reputation *ReputationConfig `json:"reputation"`

Logger Logger
DNSCache *DNSCache
ReadBufPool *sync.Pool
Dialer net.Dialer
Globals *Globals
}

// FloodProtectionConfig groups every flood-mitigation knob.
type FloodProtectionConfig struct {
Enabled bool `json:"enabled"`
MaxConnectsPerSourceIPPerSecond int `json:"maxConnectsPerSourceIPPerSecond"`
MaxConnectsPerDestPerSecond int `json:"maxConnectsPerDestPerSecond"`
MaxConnectsPerDestPerMinute int `json:"maxConnectsPerDestPerMinute"`
MaxInFlightSyns int `json:"maxInFlightSyns"`
MaxConcurrentStreamsPerConnection int `json:"maxConcurrentStreamsPerConnection"`
MaxConcurrentConnections int `json:"maxConcurrentConnections"`
SynFloodSignature struct {
Enabled bool `json:"enabled"`
WindowMs int `json:"windowMs"`
MinSamples int `json:"minSamples"`
FailedHandshakeRatio float64 `json:"failedHandshakeRatio"`
} `json:"synFloodSignature"`
WsCloseAfterViolations int `json:"wsCloseAfterViolations"`
LogBlockedDials bool `json:"logBlockedDials"`
}

// Globals holds process-wide enforcement state injected into wispConnection.
// Fields may be nil when the corresponding feature is disabled; all methods
// on the contained types are nil-safe.
type Globals struct {
PerSource *SlidingWindow
PerDestSec *SlidingWindow
PerDestMin *SlidingWindow
InFlightSyns *Semaphore
Connections *Semaphore
Egress *EgressPolicy
Reputation *Reputation
Signature *Signatures
}

func DefaultConfig() Config {
Expand Down Expand Up @@ -95,8 +137,10 @@ func DefaultConfig() Config {
PasswordAuthRequired: false,
PasswordUsers: map[string]string{},

ParseRealIP: true,
NonWSResponse: "",
ParseRealIP: true,
TrustedProxies: []string{},
TrustedHeaders: []string{"CF-Connecting-IP", "X-Forwarded-For"},
NonWSResponse: "",

LogLevel: "info",

Expand Down Expand Up @@ -137,8 +181,10 @@ func CreateWispConfig(cfg *Config) *Config {
PasswordAuthRequired: cfg.PasswordAuthRequired,
PasswordUsers: cfg.PasswordUsers,

ParseRealIP: cfg.ParseRealIP,
NonWSResponse: cfg.NonWSResponse,
ParseRealIP: cfg.ParseRealIP,
TrustedProxies: cfg.TrustedProxies,
TrustedHeaders: cfg.TrustedHeaders,
NonWSResponse: cfg.NonWSResponse,

LogLevel: cfg.LogLevel,

Expand Down
Loading