diff --git a/docs/configuration.md b/docs/configuration.md index 3b38ce1..6a7f1f6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,19 +25,11 @@ cfg.CustomErrorResponses = map[int]string{ ## Address headers -The Python engine skips ssrf scanning for address headers (`host`, -`x-forwarded-for`, `x-real-ip`, ...) automatically. The Go engine does not -apply that built-in exclusion yet, and the Echo adapter passes an explicit -`Host` header, so mirror the exclusion when clients can send internal -hostnames: +The engine skips ssrf scanning for address headers (`host`, `x-forwarded-for`, `x-real-ip`, ...) through its built-in default set (`guardcore.DefaultExcludedHeaders`, the reference's `_DEFAULT_EXCLUDED_HEADERS`), so no manual exclusion is needed. `ExcludedDetectionHeaders` only adds names on top of that default. -```go -cfg.ExcludedDetectionHeaders = map[string]bool{ - "host": true, "origin": true, "via": true, - "x-forwarded-for": true, "x-forwarded-host": true, - "x-real-ip": true, "x-client-ip": true, -} -``` +## Custom response modifier + +`cfg.CustomResponseModifier` runs on every pass-through response after the security and CORS headers, as the last step of the reference response pass, and CORS headers are applied again after it. Blocked responses already carry it from the engine. With a modifier configured the adapter holds the whole handler response in memory until the handler returns, so a streamed response is delivered at the end instead of incrementally; without one the response is untouched and streams as usual. ## Redis diff --git a/examples/advanced_app/Dockerfile b/examples/advanced_app/Dockerfile index d53cc84..acc35a4 100644 --- a/examples/advanced_app/Dockerfile +++ b/examples/advanced_app/Dockerfile @@ -9,7 +9,7 @@ RUN go mod download # The adapter package lives at the repository root (package echo); the example # is a second package tree inside the same module. -COPY middleware.go request.go ./ +COPY middleware.go modifier.go request.go ./ COPY examples/advanced_app/ examples/advanced_app/ RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./examples/advanced_app/cmd/server diff --git a/examples/advanced_app/README.md b/examples/advanced_app/README.md index 616606b..e83150d 100644 --- a/examples/advanced_app/README.md +++ b/examples/advanced_app/README.md @@ -110,8 +110,7 @@ curl -s -X POST http://localhost:8080/admin/unban -H 'X-Admin-Token: admin-token - Cloud provider blocking (`BlockCloudProviders`, off by default) - `OnBlock` hook: the telemetry seam for [guard-agent-go](https://github.com/Guard-Core/guard-agent-go) wiring - (comment-level guidance in `internal/config/config.go`; `EnableAgent` is - fail-closed in this port, so the hook is the integration point) + (comment-level guidance in `internal/config/config.go`; `EnableAgent` validates but streams only to a `guardcore.AgentHandler` set in `AgentHandler`, so the example keeps it off and only logs blocks) ## Intentional simplifications diff --git a/examples/advanced_app/internal/config/config.go b/examples/advanced_app/internal/config/config.go index 6df4925..d766692 100644 --- a/examples/advanced_app/internal/config/config.go +++ b/examples/advanced_app/internal/config/config.go @@ -46,21 +46,12 @@ func New() (*guardcore.SecurityConfig, error) { // Detection: every category, default detector tuning. c.EnablePenetrationDetection = true - // The Python engine automatically skips ssrf scanning for address - // headers (host, x-forwarded-for, x-real-ip, ...). This port does - // not apply that built-in exclusion yet, and the Echo adapter passes - // an explicit Host header (see request.go), so mirror the exclusion - // here; otherwise a plain "Host: localhost" request is flagged as - // ssrf. Excluding these headers only narrows detection scanning, it - // does not affect trusted-proxy client IP resolution. - c.ExcludedDetectionHeaders = map[string]bool{ - "host": true, "origin": true, "via": true, - "x-forwarded-for": true, "x-forwarded-host": true, - "x-real-ip": true, "x-client-ip": true, - "x-cluster-client-ip": true, "cf-connecting-ip": true, - "true-client-ip": true, "fly-client-ip": true, - "x-envoy-external-address": true, - } + // Address headers (host, x-forwarded-for, x-real-ip, ...) are already + // excluded from detection by the engine's built-in default set + // (guardcore.DefaultExcludedHeaders, the reference's + // _DEFAULT_EXCLUDED_HEADERS), so a plain "Host: localhost" request is + // not flagged and no ExcludedDetectionHeaders entry is needed here. + // ExcludedDetectionHeaders only adds names on top of that default. // Edge filters. c.BlockedUserAgents = []string{"badbot", "evil-crawler", "sqlmap"} @@ -82,14 +73,16 @@ func New() (*guardcore.SecurityConfig, error) { c.LogRequestLevel = EnvOr("LOG_REQUEST_LEVEL", "INFO") c.LogSuspiciousLevel = EnvOr("LOG_SUSPICIOUS_LEVEL", "WARNING") - // Agent wiring (comment-level): guard-core-go's only telemetry seam - // is OnBlock. The Go agent (guard-agent-go, - // https://github.com/Guard-Core/guard-agent-go) mirrors the Python - // guard-agent's API: once its engine-event pipeline accepts these - // payloads, replace the log line below with the agent client call - // and set AGENT_ENDPOINT/AGENT_PROJECT_ID here. EnableAgent stays - // false because the engine fails config validation on it (the - // feature is not ported yet); do not turn it on. + // OnBlock is the engine's block-event hook (check_name, reason, + // trigger_info, passive_mode, client_ip, path, method, status_code). + // Agent telemetry rides a different seam: EnableAgent validates, and + // the engine streams events and metrics to the guardcore.AgentHandler + // set in AgentHandler (SendEvent/SendMetric). Without a handler the + // engine logs that the agent cannot initialize and continues without + // it (AgentStrict makes NewEngine fail instead), so this example keeps + // EnableAgent off and only logs blocks. guard-agent-go takes a + // context and its own event types, so it needs a small bridge to + // satisfy guardcore.AgentHandler. c.OnBlock = func(req guardcore.Request, payload map[string]any) { log.Printf("guard blocked %s %s from %s via %s: %s", payload["method"], payload["path"], payload["client_ip"], diff --git a/examples/simple_app/Dockerfile b/examples/simple_app/Dockerfile index 97fb4c3..9fd9481 100644 --- a/examples/simple_app/Dockerfile +++ b/examples/simple_app/Dockerfile @@ -9,7 +9,7 @@ RUN go mod download # The adapter package lives at the repository root (package echo); the example # is a second package inside the same module. -COPY middleware.go request.go ./ +COPY middleware.go modifier.go request.go ./ COPY examples/simple_app/ examples/simple_app/ RUN CGO_ENABLED=0 go build -trimpath -o /out/simple_app ./examples/simple_app diff --git a/examples/simple_app/README.md b/examples/simple_app/README.md index 21f3752..9dcdbbb 100644 --- a/examples/simple_app/README.md +++ b/examples/simple_app/README.md @@ -56,12 +56,7 @@ curl -i http://localhost:8080/ ## A note on address headers -The Python engine skips ssrf scanning for address headers (`host`, -`x-forwarded-for`, `x-real-ip`, ...) automatically. This Go port does not -apply that built-in exclusion yet, and the Echo adapter passes an explicit -`Host` header, so `main.go` mirrors the exclusion through -`ExcludedDetectionHeaders`; without it, a plain `Host: localhost` request is -flagged as an ssrf attempt. +The engine skips ssrf scanning for address headers (`host`, `x-forwarded-for`, `x-real-ip`, ...) through its built-in default set (`guardcore.DefaultExcludedHeaders`), as the reference does, so a plain `Host: localhost` request is not flagged and `main.go` needs no `ExcludedDetectionHeaders` entry. That field only adds names on top of the default. ## Configuration knobs demonstrated @@ -78,9 +73,7 @@ Inline comments in `main.go` walk through every knob used: - Redis via `REDIS_URL` / `REDIS_PREFIX` (compose wires Redis in; without it the managers fall back to in-process state) - The `OnBlock` hook: the telemetry seam for wiring - [guard-agent-go](https://github.com/Guard-Core/guard-agent-go) (comment-level - guidance in `main.go`; agent integration is not implemented in the engine - port yet, and `EnableAgent` fails config validation) + [guard-agent-go](https://github.com/Guard-Core/guard-agent-go) (comment-level guidance in `main.go`; `EnableAgent` validates but streams only to a `guardcore.AgentHandler` set in `AgentHandler`, so the example keeps it off) ## Echo notes diff --git a/examples/simple_app/main.go b/examples/simple_app/main.go index 644d912..c805113 100644 --- a/examples/simple_app/main.go +++ b/examples/simple_app/main.go @@ -49,21 +49,12 @@ func main() { // Penetration detection: all categories, default thresholds. c.EnablePenetrationDetection = true - // The Python engine automatically skips ssrf scanning for address - // headers (host, x-forwarded-for, x-real-ip, ...). This port does - // not apply that built-in exclusion yet, and the Echo adapter passes - // an explicit Host header (see request.go), so mirror the exclusion - // here; otherwise a plain "Host: localhost" request is flagged as - // ssrf. Excluding these headers only narrows detection scanning, it - // does not affect trusted-proxy client IP resolution. - c.ExcludedDetectionHeaders = map[string]bool{ - "host": true, "origin": true, "via": true, - "x-forwarded-for": true, "x-forwarded-host": true, - "x-real-ip": true, "x-client-ip": true, - "x-cluster-client-ip": true, "cf-connecting-ip": true, - "true-client-ip": true, "fly-client-ip": true, - "x-envoy-external-address": true, - } + // Address headers (host, x-forwarded-for, x-real-ip, ...) are already + // excluded from detection by the engine's built-in default set + // (guardcore.DefaultExcludedHeaders, the reference's + // _DEFAULT_EXCLUDED_HEADERS), so a plain "Host: localhost" request is + // not flagged and no ExcludedDetectionHeaders entry is needed here. + // ExcludedDetectionHeaders only adds names on top of that default. // Blocked user agents (regex patterns). c.BlockedUserAgents = []string{"badbot", "evil-crawler", "sqlmap"} @@ -78,14 +69,16 @@ func main() { "/docs", "/redoc", "/openapi.json", "/favicon.ico", "/static", "/health", } - // OnBlock is the telemetry seam of this port. Guard Agent - // integration is not implemented in guard-core-go yet (setting - // EnableAgent fails config validation), so wire the agent from - // here: forward these payloads to guard-agent-go - // (https://github.com/Guard-Core/guard-agent-go) once its event - // pipeline accepts engine events. The payload carries check_name, - // reason, trigger_info, passive_mode, client_ip, path, method, and - // status_code. + // OnBlock is the engine's block-event hook (check_name, reason, + // trigger_info, passive_mode, client_ip, path, method, status_code). + // Agent telemetry rides a different seam: EnableAgent validates, and + // the engine streams events and metrics to the guardcore.AgentHandler + // set in AgentHandler (SendEvent/SendMetric). Without a handler the + // engine logs that the agent cannot initialize and continues without + // it (AgentStrict makes NewEngine fail instead), so this example keeps + // EnableAgent off and only logs blocks. guard-agent-go takes a + // context and its own event types, so it needs a small bridge to + // satisfy guardcore.AgentHandler. c.OnBlock = func(req guardcore.Request, payload map[string]any) { log.Printf("guard blocked %s %s from %s via %s: %s", payload["method"], payload["path"], payload["client_ip"], diff --git a/held_test.go b/held_test.go new file mode 100644 index 0000000..f17d70e --- /dev/null +++ b/held_test.go @@ -0,0 +1,74 @@ +package echo + +import ( + "bufio" + "net" + "net/http" + "net/http/httptest" + "testing" + + echolib "github.com/labstack/echo/v4" + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +func TestParityHeldWriterHoldsAndFlushIsHarmless(t *testing.T) { + rec := httptest.NewRecorder() + held := &heldWriter{ResponseWriter: rec} + held.WriteHeader(http.StatusTeapot) + if _, err := held.Write([]byte("ab")); err != nil { + t.Fatal(err) + } + if err := held.FlushError(); err != nil { + t.Fatal(err) + } + if string(held.body) != "ab" || rec.Body.Len() != 0 || rec.Code != http.StatusOK { + t.Fatalf("nothing may reach the client before release: %q %d %d", held.body, rec.Body.Len(), rec.Code) + } +} + +func TestParityFlushInsideHeldResponseDoesNotPanic(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { return resp } + }) + rec := serveWith(t, engine, func(c echolib.Context) error { + _, _ = c.Response().Write([]byte("part")) + c.Response().Flush() + return nil + }, httptest.NewRequest("GET", "/stream", nil)) + if rec.Code != 200 || rec.Body.String() != "part" { + t.Fatalf("flush in a held response must be harmless, got %d %q", rec.Code, rec.Body.String()) + } +} + +type hijackRecorder struct { + *httptest.ResponseRecorder +} + +func (hijackRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + server, client := net.Pipe() + _ = client.Close() + return server, bufio.NewReadWriter(bufio.NewReader(server), bufio.NewWriter(server)), nil +} + +func TestParityHijackedResponseIsNotReleased(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { + t.Error("a hijacked connection must not reach the modifier") + return resp + } + }) + guard, err := New(engine) + if err != nil { + t.Fatal(err) + } + router := echolib.New() + router.Use(guard) + router.Any("/*", func(c echolib.Context) error { + conn, _, err := c.Response().Hijack() + if err != nil { + return err + } + return conn.Close() + }) + router.ServeHTTP(hijackRecorder{httptest.NewRecorder()}, httptest.NewRequest("GET", "/ws", nil)) +} diff --git a/middleware.go b/middleware.go index b0ceb52..8d18d95 100644 --- a/middleware.go +++ b/middleware.go @@ -66,8 +66,9 @@ func (m *middleware) wrap(next echolib.HandlerFunc) echolib.HandlerFunc { // merged on top (the reference _inject_cors_headers runs after the // security-header set, so CORS wins on a shared name) whenever the // request carries an Origin and CORS is enabled. + cors := m.engine.CORSResponseHeaders(req) headers := m.engine.ResponseHeaders() - for name, value := range m.engine.CORSResponseHeaders(req) { + for name, value := range cors { headers[name] = value } for name, value := range headers { @@ -78,22 +79,55 @@ func (m *middleware) wrap(next echolib.HandlerFunc) echolib.HandlerFunc { // response factory's behavioral phase. The adapter captures the // leading response body up to the configured inspect budget and // hands status code plus captured prefix to the engine after the - // handler ran: return rules never modify the response. + // handler ran: return rules never modify the response. With a + // CustomResponseModifier configured the whole response is held + // instead, so the modifier (the last step of the reference + // process_response) can rewrite it before it is sent. + origWriter := c.Response().Writer + var held *heldWriter + if m.engine.Config.CustomResponseModifier != nil { + held = &heldWriter{ResponseWriter: origWriter} + c.Response().Writer = held + } capture := m.newBodyCapture() - if capture != nil { - c.Response().Writer = capture.wrap(c.Response().Writer) + if capture != nil && held == nil { + c.Response().Writer = capture.wrap(origWriter) } err = next(c) + // A handler error is turned into a response by Echo's + // HTTPErrorHandler, which normally runs after the whole middleware + // chain returned: c.Response().Status would still be the unwritten + // default and the engine would record a 200. Run the error handler + // here, as Echo's own logger middleware does, so the real status + // (and the held body, when a modifier is configured) exists first. + if err != nil { + c.Error(err) + } var observedBody []byte if capture != nil { observedBody = capture.body() } + if held != nil { + if held.hijacked { + return nil + } + if capture != nil { + observedBody = held.body + if len(observedBody) > capture.budget { + observedBody = observedBody[:capture.budget] + } + } + c.Response().Writer = origWriter + } m.engine.ProcessResponse(req, &guardcore.Response{ StatusCode: c.Response().Status, Headers: map[string]string{}, Body: observedBody, }) - return err + if held != nil { + held.release(m.engine, c.Response().Status, cors) + } + return nil } } diff --git a/modifier.go b/modifier.go new file mode 100644 index 0000000..eeefc12 --- /dev/null +++ b/modifier.go @@ -0,0 +1,85 @@ +package echo + +import ( + "bufio" + "net" + "net/http" + "strconv" + + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +// heldWriter buffers the handler's response (headers, body) so the engine's +// CustomResponseModifier can rewrite it before anything reaches the client. +// The reference response factory applies the modifier to the pass-through +// response object, which the Go net/http model can only mirror by holding +// the response: the adapter installs this writer only when a modifier is +// configured, so streaming responses are untouched otherwise. Echo's own +// Response keeps the status, so only the body is buffered here. +type heldWriter struct { + http.ResponseWriter + hijacked bool + body []byte +} + +func (w *heldWriter) WriteHeader(int) {} + +func (w *heldWriter) Write(p []byte) (int, error) { + w.body = append(w.body, p...) + return len(p), nil +} + +// FlushError keeps http.ResponseController.Flush (what echo's Response.Flush +// uses) from failing: nothing is flushed, the response is held until the +// handler chain returns. +func (w *heldWriter) FlushError() error { return nil } + +func (w *heldWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + w.hijacked = true + return http.NewResponseController(w.ResponseWriter).Hijack() +} + +// release runs the modifier over the held response and writes the result to +// the real writer. Headers the modifier did not touch keep every value +// (Set-Cookie and friends); changed or added ones are set, removed ones are +// deleted. CORS headers are applied again after the modifier, as the +// reference dispatch injects them on the processed response. +func (w *heldWriter) release(engine *guardcore.Engine, status int, cors map[string]string) { + header := w.ResponseWriter.Header() + resp := &guardcore.Response{StatusCode: status, Headers: headerSnapshot(header), Body: w.body} + before := make(map[string]string, len(resp.Headers)) + for name, value := range resp.Headers { + before[name] = value + } + out := engine.ModifyResponse(resp) + for name := range before { + if _, kept := out.Headers[name]; !kept { + header.Del(name) + } + } + for name, value := range out.Headers { + if prev, seen := before[name]; !seen || prev != value { + header.Set(name, value) + } + } + if header.Get("Content-Length") != "" { + header.Set("Content-Length", strconv.Itoa(len(out.Body))) + } + for name, value := range cors { + header.Set(name, value) + } + w.ResponseWriter.WriteHeader(out.StatusCode) + if len(out.Body) > 0 { + _, _ = w.ResponseWriter.Write(out.Body) + } +} + +func headerSnapshot(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for name, values := range h { + if len(values) > 0 { + out[name] = values[0] + } + } + return out +} diff --git a/parity_test.go b/parity_test.go new file mode 100644 index 0000000..e2fdd32 --- /dev/null +++ b/parity_test.go @@ -0,0 +1,209 @@ +package echo + +// Parity tests against the reference adapter (fastapi-guard): the response +// pass runs the custom response modifier last, and the request metrics and +// return rules see the real response status. + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + echolib "github.com/labstack/echo/v4" + "github.com/rennf93/guard-core-go/v4/guardcore" +) + +func serveWith(t *testing.T, engine *guardcore.Engine, handler echolib.HandlerFunc, r *http.Request) *httptest.ResponseRecorder { + t.Helper() + guard, err := New(engine) + if err != nil { + t.Fatalf("middleware: %v", err) + } + router := echolib.New() + router.Use(guard) + router.Any("/*", handler) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, r) + return rec +} + +func TestParityModifierRewritesPassThroughResponse(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { + resp.SetHeader("X-Modified", "yes") + delete(resp.Headers, "X-Frame-Options") + resp.Headers["X-Content-Type-Options"] = "changed" + resp.Body = append([]byte("modified:"), resp.Body...) + return resp + } + }) + rec := serveWith(t, engine, func(c echolib.Context) error { + c.Response().Header().Add("Set-Cookie", "a=1") + c.Response().Header().Add("Set-Cookie", "b=2") + c.Response().Header().Set("Content-Length", "5") + return c.String(http.StatusCreated, "hello") + }, httptest.NewRequest("GET", "/api", nil)) + if rec.Code != http.StatusCreated { + t.Fatalf("modifier must keep the handler status, got %d", rec.Code) + } + if rec.Body.String() != "modified:hello" { + t.Fatalf("modifier must rewrite the pass-through body, got %q", rec.Body.String()) + } + if rec.Header().Get("X-Modified") != "yes" { + t.Fatalf("modifier header missing: %v", rec.Header()) + } + if rec.Header().Get("X-Frame-Options") != "" { + t.Fatalf("modifier must be able to drop a security header: %v", rec.Header()) + } + if rec.Header().Get("X-Content-Type-Options") != "changed" { + t.Fatalf("modifier must be able to change a header: %v", rec.Header()) + } + if got := rec.Header().Values("Set-Cookie"); len(got) != 2 { + t.Fatalf("untouched multi-value headers must survive, got %v", got) + } + if rec.Header().Get("Content-Length") != "14" { + t.Fatalf("content length must follow the modified body, got %q", rec.Header().Get("Content-Length")) + } +} + +func TestParityModifierRunsAfterCORSAndCORSIsReapplied(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.EnableCORS = true + c.CORSAllowOrigins = []string{"https://app.example"} + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { + sawCORS := resp.Headers["Access-Control-Allow-Origin"] == "https://app.example" + delete(resp.Headers, "Access-Control-Allow-Origin") + if sawCORS { + resp.SetHeader("X-Saw-CORS", "yes") + } + return resp + } + }) + r := httptest.NewRequest("GET", "/api", nil) + r.Header.Set("Origin", "https://app.example") + rec := serveWith(t, engine, func(c echolib.Context) error { return c.String(200, "ok") }, r) + if rec.Header().Get("X-Saw-CORS") != "yes" { + t.Fatalf("the modifier must see the CORS headers: %v", rec.Header()) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "https://app.example" { + t.Fatalf("CORS is injected after the modifier in the reference dispatch: %v", rec.Header()) + } +} + +func TestParityModifierAppliesToGuardBlockedResponse(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { + resp.SetHeader("X-Modified", "yes") + return resp + } + }) + rec := serveWith(t, engine, func(c echolib.Context) error { return c.String(200, "ok") }, + httptest.NewRequest("GET", "/api?"+xssVector, nil)) + if rec.Code != 400 || rec.Header().Get("X-Modified") != "yes" { + t.Fatalf("guard responses carry the modifier, got %d %v", rec.Code, rec.Header()) + } +} + +func TestParityModifierStatusAndBodyFeedReturnRules(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.BehaviorScanResponseBody = true + c.BehaviorMaxResponseBodyInspectBytes = 1024 + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { return resp } + c.GlobalBehaviorRules = []guardcore.BehaviorRuleConfig{ + {RuleType: "return_pattern", Pattern: "secret", Threshold: 1, Window: 60, Action: "ban"}, + } + }) + for i := 0; i < 2; i++ { + rec := serveWith(t, engine, func(c echolib.Context) error { return c.String(200, "a secret leaked here") }, + httptest.NewRequest("GET", "/report", nil)) + if rec.Code != 200 || rec.Body.String() != "a secret leaked here" { + t.Fatalf("held response must reach the client intact, got %d %q", rec.Code, rec.Body.String()) + } + } + if !engine.Ban.IsIPBanned("192.0.2.1") { + t.Fatal("the body prefix of a held response must reach the return rules") + } +} + +func TestParityHeldBodyBeyondBudgetIsNotScanned(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.BehaviorScanResponseBody = true + c.BehaviorMaxResponseBodyInspectBytes = 1024 + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { return resp } + c.GlobalBehaviorRules = []guardcore.BehaviorRuleConfig{ + {RuleType: "return_pattern", Pattern: "beyond-the-budget", Threshold: 1, Window: 60, Action: "ban"}, + } + }) + big := []byte(strings.Repeat("a", 2048)) + copy(big[1500:], "beyond-the-budget") + for i := 0; i < 3; i++ { + rec := serveWith(t, engine, func(c echolib.Context) error { _, err := c.Response().Write(big); return err }, + httptest.NewRequest("GET", "/report", nil)) + if rec.Code != 200 || rec.Body.Len() != 2048 { + t.Fatalf("the client must get the whole body, got %d with %d bytes", rec.Code, rec.Body.Len()) + } + } + if engine.Ban.IsIPBanned("192.0.2.1") { + t.Fatal("a marker beyond the inspect budget must never trip a return rule") + } +} + +// A handler that returns an error leaves the status to Echo's HTTPErrorHandler, +// which only runs after the middleware chain returns. The adapter must run +// it first (the Echo logger-middleware pattern) so the engine sees the real +// status instead of the not-yet-written default. +func TestParityHandlerErrorStatusReachesEngine(t *testing.T) { + for name, handler := range map[string]echolib.HandlerFunc{ + "HTTPError": func(c echolib.Context) error { return echolib.NewHTTPError(http.StatusInternalServerError, "boom") }, + "plain error": func(c echolib.Context) error { return errors.New("boom") }, + "explicit 500": func(c echolib.Context) error { return c.NoContent(http.StatusInternalServerError) }, + } { + t.Run(name, func(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.GlobalBehaviorRules = []guardcore.BehaviorRuleConfig{ + {RuleType: "return_pattern", Pattern: "status:500", Threshold: 1, Window: 60, Action: "ban"}, + } + }) + for i := 0; i < 2; i++ { + rec := serveWith(t, engine, handler, httptest.NewRequest("GET", "/boom", nil)) + if rec.Code != 500 { + t.Fatalf("handler status must reach the client, got %d", rec.Code) + } + } + if !engine.Ban.IsIPBanned("192.0.2.1") { + t.Fatal("a 500 must be reported to the engine as 500") + } + }) + } +} + +func TestParityHandlerErrorWithModifierIsHeldAndModified(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { + c.CustomResponseModifier = func(resp *guardcore.Response) *guardcore.Response { + resp.SetHeader("X-Seen-Status", http.StatusText(resp.StatusCode)) + return resp + } + }) + rec := serveWith(t, engine, func(c echolib.Context) error { return echolib.NewHTTPError(http.StatusTeapot, "short") }, + httptest.NewRequest("GET", "/tea", nil)) + if rec.Code != http.StatusTeapot || rec.Header().Get("X-Seen-Status") != "I'm a teapot" { + t.Fatalf("the modifier must see the error status, got %d %v", rec.Code, rec.Header()) + } +} + +// The engine's built-in default header exclusions (DefaultExcludedHeaders) +// keep address headers out of ssrf detection, so the examples and docs need +// no manual ExcludedDetectionHeaders workaround. +func TestParityAddressHeadersNeedNoManualExclusion(t *testing.T) { + engine := newTestEngine(t, func(c *guardcore.SecurityConfig) { c.EnablePenetrationDetection = true }) + r := httptest.NewRequest("GET", "/api", nil) + r.Host = "169.254.169.254" + r.Header.Set("X-Forwarded-For", "10.0.0.1") + r.Header.Set("Origin", "http://localhost") + rec := serveWith(t, engine, func(c echolib.Context) error { return c.String(200, "ok") }, r) + if rec.Code != 200 { + t.Fatalf("address headers must not be flagged by default, got %d %q", rec.Code, rec.Body.String()) + } +}