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
16 changes: 4 additions & 12 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion examples/advanced_app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions examples/advanced_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 16 additions & 23 deletions examples/advanced_app/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion examples/simple_app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 2 additions & 9 deletions examples/simple_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
39 changes: 16 additions & 23 deletions examples/simple_app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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"],
Expand Down
74 changes: 74 additions & 0 deletions held_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
44 changes: 39 additions & 5 deletions middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down
Loading
Loading