From 8e37bbc9e980cb5b648d6cd05b1017ee96293fc2 Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 7 Oct 2026 08:24:22 +0200 Subject: [PATCH 1/2] release(1.3.1): the echo/v4 dependency bump --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0342e47..6d21993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ Release Notes ___ +v1.3.1 (2026-10-07) +------------------- + +echo/v4 dependency bump +----------------------- + +### Changed + +- **Raised `github.com/labstack/echo/v4` to the patched release** (#24). Dependency-only: no engine, middleware, or config surface changes; the module floor and CI gates are unchanged. + +___ + v1.3.0 (2026-10-01) ------------------- From 49d13bd6b2502e98038cbfdff16500cb041f1479 Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 7 Oct 2026 23:50:28 +0200 Subject: [PATCH 2/2] feat(echo): the websocket handshake guard Ports the reference websocket guard (fastapi-guard guard/websocket.py guard_websocket / make_guard_websocket) onto the echo adapter: - GuardWebSocket(engine, c) runs the engine's websocket handshake checks over the upgrade request (identity, fail-secure unknown-address close, ban, is_ip_allowed, the ws rate limit, and the penetration detection pass sharing the HTTP pipeline's suspicious counts). A nil result allows the upgrade; a non-nil reason closes it. A nil engine fails closed with the security-check-failed reason. - WebSocketHTTPStatus maps a close reason onto the HTTP status an upgrade rejection carries (503 try-again-later, 403 policy violation). - The ws request shim mirrors the reference _WebSocketGuardRequest: method WEBSOCKET, empty body, repeated headers joined with a comma (_join_repeated_header_lines), fresh request state. Floors guard-core-go at the websocket-guard pseudo-version. --- go.mod | 2 +- go.sum | 4 +- websocket.go | 123 +++++++++++++++++++++++++++++++++++++++ websocket_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 websocket.go create mode 100644 websocket_test.go diff --git a/go.mod b/go.mod index 89d4ad6..d0a9fb3 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.0 require ( github.com/labstack/echo/v4 v4.16.0 - github.com/rennf93/guard-core-go/v4 v4.3.0 + github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261007214542-f0bb238e52f7 ) require ( diff --git a/go.sum b/go.sum index 41e1dda..8c06805 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,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/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -github.com/rennf93/guard-core-go/v4 v4.3.0 h1:W5j0I9VMyANJ4apFOwEwLd3c+J9hD4JImEReZWdXT+E= -github.com/rennf93/guard-core-go/v4 v4.3.0/go.mod h1:ThG64TPXFAbFtoxKZyusi9k9ZNrj/BXR7Cr7fJmpdys= +github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261007214542-f0bb238e52f7 h1:yO6a1Cb4Gyxq6H3IwnheNRN28cydOqSnvq3223V3tIU= +github.com/rennf93/guard-core-go/v4 v4.3.2-0.20261007214542-f0bb238e52f7/go.mod h1:ThG64TPXFAbFtoxKZyusi9k9ZNrj/BXR7Cr7fJmpdys= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/websocket.go b/websocket.go new file mode 100644 index 0000000..36d81a2 --- /dev/null +++ b/websocket.go @@ -0,0 +1,123 @@ +package echo + +import ( + "net" + "net/http" + "strings" + + echolib "github.com/labstack/echo/v4" + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +// The websocket handshake guard, ported from fastapi-guard guard/websocket.py +// (guard_websocket / make_guard_websocket): the reference HTTP middleware +// never sees websocket connections (Starlette routes them separately), so +// the guard runs as an explicit dependency in the websocket handler before +// the upgrade. Echo exposes the upgrade as the handler's *http.Request: +// call GuardWebSocket before upgrading and reject the handshake when it +// answers a close reason (a rejected upgrade has no websocket to close, so +// the reason maps onto an HTTP status through WebSocketHTTPStatus; a +// handler that already accepted can close with the reason's code and +// reason string verbatim). + +// GuardWebSocket runs the engine's websocket handshake checks over the +// upgrade request: identity resolution, the fail-secure unknown-address +// close, the ban check, the is_ip_allowed verdict, the ws rate limit, and +// the penetration detection pass sharing the HTTP pipeline's suspicious +// counts. A nil result allows the upgrade; a non-nil reason closes it. A +// nil engine fails closed with the security-check-failed reason. +func GuardWebSocket(engine *guardcore.Engine, c echolib.Context) *guardcore.WebSocketCloseReason { + if engine == nil || c == nil { + reason := guardcore.WSCloseSecurityCheckFailed + return &reason + } + return engine.GuardWebSocket(newWSRequestShim(c.Request())) +} + +// WebSocketHTTPStatus maps a close reason onto the HTTP status an upgrade +// rejection carries: 503 for the try-again-later security-check failure, +// 403 for every policy-violation reason, 200 for a nil (allowed) verdict. +func WebSocketHTTPStatus(reason *guardcore.WebSocketCloseReason) int { + if reason == nil { + return http.StatusOK + } + if reason.Code == guardcore.WebSocketCloseTryAgainLater { + return http.StatusServiceUnavailable + } + return http.StatusForbidden +} + +// wsRequestShim mirrors the reference _WebSocketGuardRequest: method +// "WEBSOCKET", an empty body, repeated headers joined with ", " +// (_join_repeated_header_lines), and a fresh request state. +type wsRequestShim struct { + req *http.Request + state guardcore.RequestState +} + +func newWSRequestShim(req *http.Request) *wsRequestShim { + return &wsRequestShim{req: req} +} + +func (s *wsRequestShim) URLPath() string { return s.req.URL.Path } + +func (s *wsRequestShim) URLScheme() string { + if s.req.TLS != nil { + return "https" + } + return "http" +} + +func (s *wsRequestShim) URLFull() string { + full := s.URLScheme() + "://" + s.req.Host + s.req.URL.Path + if s.req.URL.RawQuery != "" { + full += "?" + s.req.URL.RawQuery + } + return full +} + +func (s *wsRequestShim) URLReplaceScheme(scheme string) string { + full := s.URLFull() + if scheme == "" { + return full + } + return scheme + "://" + strings.TrimPrefix(strings.TrimPrefix(full, "http://"), "https://") +} + +func (s *wsRequestShim) Method() string { return "WEBSOCKET" } + +func (s *wsRequestShim) ClientHost() string { + host, _, err := net.SplitHostPort(s.req.RemoteAddr) + if err != nil { + return s.req.RemoteAddr + } + return host +} + +func (s *wsRequestShim) Headers() guardcore.Headers { + headers := guardcore.NewHeaders() + for name, values := range s.req.Header { + if len(values) > 0 { + headers.Set(name, strings.Join(values, ", ")) + } + } + if s.req.Host != "" { + headers.Set("Host", s.req.Host) + } + return headers +} + +func (s *wsRequestShim) QueryParams() map[string]string { + values := s.req.URL.Query() + params := make(map[string]string, len(values)) + for key, parsed := range values { + if len(parsed) > 0 { + params[key] = parsed[0] + } + } + return params +} + +func (s *wsRequestShim) Body() ([]byte, error) { return nil, nil } + +func (s *wsRequestShim) State() *guardcore.RequestState { return &s.state } diff --git a/websocket_test.go b/websocket_test.go new file mode 100644 index 0000000..fe09a7d --- /dev/null +++ b/websocket_test.go @@ -0,0 +1,144 @@ +package echo + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + echolib "github.com/labstack/echo/v4" + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +func wsTestEngine(t *testing.T, mutate func(*guardcore.SecurityConfig)) *guardcore.Engine { + t.Helper() + cfg, err := guardcore.NewSecurityConfig(func(c *guardcore.SecurityConfig) { + c.EnableRedis = false + if mutate != nil { + mutate(c) + } + }) + if err != nil { + t.Fatalf("NewSecurityConfig: %v", err) + } + engine, err := guardcore.NewEngine(cfg) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + return engine +} + +func wsUpgradeContext(t *testing.T, remote string, mutate func(r *http.Request)) echolib.Context { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/ws", nil) + r.RemoteAddr = remote + r.Header.Set("Upgrade", "websocket") + r.Header.Set("Connection", "Upgrade") + if mutate != nil { + mutate(r) + } + w := httptest.NewRecorder() + return echolib.New().NewContext(r, w) +} + +func TestGuardWebSocketAllowsCleanUpgrade(t *testing.T) { + engine := wsTestEngine(t, nil) + if reason := GuardWebSocket(engine, wsUpgradeContext(t, "203.0.113.9:12345", nil)); reason != nil { + t.Fatalf("clean upgrade must pass, got %+v", reason) + } + if status := WebSocketHTTPStatus(nil); status != http.StatusOK { + t.Fatalf("nil reason must map to 200, got %d", status) + } +} + +func TestGuardWebSocketBannedIPCloses(t *testing.T) { + engine := wsTestEngine(t, nil) + if _, err := engine.Ban.Ban("203.0.113.9", 60, "test"); err != nil { + t.Fatalf("Ban: %v", err) + } + reason := GuardWebSocket(engine, wsUpgradeContext(t, "203.0.113.9:12345", nil)) + if reason == nil || *reason != guardcore.WSCloseIPBanned { + t.Fatalf("banned IP must close with the banned reason, got %+v", reason) + } + if status := WebSocketHTTPStatus(reason); status != http.StatusForbidden { + t.Fatalf("policy violation must map to 403, got %d", status) + } +} + +func TestGuardWebSocketSuspiciousHandshakeCloses(t *testing.T) { + engine := wsTestEngine(t, nil) + reason := GuardWebSocket(engine, wsUpgradeContext(t, "203.0.113.9:12345", func(r *http.Request) { + r.URL.Path = "/ws/search" + r.URL.RawQuery = "q=1%27%20UNION%20SELECT%20username%2Cpassword%20FROM%20users--" + })) + if reason == nil || *reason != guardcore.WSCloseSuspiciousActivity { + t.Fatalf("suspicious handshake must close with the suspicious reason, got %+v", reason) + } + if status := WebSocketHTTPStatus(reason); status != http.StatusForbidden { + t.Fatalf("suspicious must map to 403, got %d", status) + } +} + +func TestGuardWebSocketFailSecureUnknownAddress(t *testing.T) { + engine := wsTestEngine(t, nil) // fail_secure defaults true + reason := GuardWebSocket(engine, wsUpgradeContext(t, "", nil)) + if reason == nil || *reason != guardcore.WSCloseClientAddressUnknown { + t.Fatalf("unknown address under fail_secure must close, got %+v", reason) + } +} + +func TestGuardWebSocketNilEngineFailsClosed(t *testing.T) { + reason := GuardWebSocket(nil, wsUpgradeContext(t, "203.0.113.9:12345", nil)) + if reason == nil || *reason != guardcore.WSCloseSecurityCheckFailed { + t.Fatalf("nil engine must fail closed, got %+v", reason) + } + if status := WebSocketHTTPStatus(reason); status != http.StatusServiceUnavailable { + t.Fatalf("security-check-failed must map to 503, got %d", status) + } +} + +func TestWSRequestShimContract(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/ws?a=1&a=2", nil) + req.RemoteAddr = "203.0.113.9:12345" + req.Header["X-Custom"] = []string{"one", "two"} + req.TLS = &tls.ConnectionState{} + shim := newWSRequestShim(req) + if shim.Method() != "WEBSOCKET" { + t.Fatalf("the ws shim must report the WEBSOCKET method, got %q", shim.Method()) + } + if body, err := shim.Body(); err != nil || body != nil { + t.Fatalf("the ws shim must carry an empty body, got (%v, %v)", body, err) + } + if joined, _ := shim.Headers().Get("X-Custom"); joined != "one, two" { + t.Fatalf("repeated headers must join with a comma, got %q", joined) + } + if shim.QueryParams()["a"] != "1" { + t.Fatalf("query params must keep the first value, got %q", shim.QueryParams()["a"]) + } + if shim.ClientHost() != "203.0.113.9" { + t.Fatalf("client host must strip the port, got %q", shim.ClientHost()) + } + if shim.State() == nil { + t.Fatal("the ws shim must expose a request state") + } + if shim.URLScheme() != "https" { + t.Fatalf("the ws shim must resolve the request scheme, got %q", shim.URLScheme()) + } + if full := shim.URLFull(); full != "https://example.com/ws?a=1&a=2" { + t.Fatalf("the ws shim must build the full URL, got %q", full) + } + if replaced := shim.URLReplaceScheme("wss"); replaced != "wss://example.com/ws?a=1&a=2" { + t.Fatalf("scheme replacement must swap the scheme, got %q", replaced) + } + if same := shim.URLReplaceScheme(""); same != shim.URLFull() { + t.Fatalf("empty scheme replacement must keep the URL, got %q", same) + } + + plain := newWSRequestShim(httptest.NewRequest(http.MethodGet, "/ws", nil)) + if plain.URLScheme() != "http" { + t.Fatalf("a plaintext request must resolve http, got %q", plain.URLScheme()) + } + if full := plain.URLFull(); full != "http://example.com/ws" { + t.Fatalf("the ws shim must build the plain full URL, got %q", full) + } +}