Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-------------------

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
123 changes: 123 additions & 0 deletions websocket.go
Original file line number Diff line number Diff line change
@@ -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 }
144 changes: 144 additions & 0 deletions websocket_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading