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
15 changes: 4 additions & 11 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +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, so mirror it explicitly 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
3 changes: 1 addition & 2 deletions examples/advanced_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ curl -s -X POST http://localhost/admin/unban -H 'X-Admin-Token: admin-token-chan
`/admin/*`), attached with `nethttp.WithRouteID`
- `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
36 changes: 16 additions & 20 deletions examples/advanced_app/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,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, ...). The Go engine
// does not apply that built-in exclusion yet, so mirror it here;
// otherwise nginx's forwarded headers get flagged as ssrf.
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 @@ -78,14 +72,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
4 changes: 1 addition & 3 deletions examples/simple_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,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)

## Environment variables

Expand Down
34 changes: 16 additions & 18 deletions examples/simple_app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,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, ...). The Go engine
// does not apply that built-in exclusion yet, so mirror it here;
// otherwise a plain "Host: localhost" request is flagged as ssrf.
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 @@ -63,12 +57,16 @@ func main() {
"/docs", "/redoc", "/openapi.json", "/favicon.ico", "/static", "/health",
}

// OnBlock is the engine's telemetry seam. 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.
// 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
72 changes: 68 additions & 4 deletions middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
"strconv"

"github.com/rennf93/guard-core-go/v4/guardcore"
)
Expand Down Expand Up @@ -65,8 +66,9 @@ func (m *middleware) wrap(next http.Handler) http.Handler {
// 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 @@ -77,33 +79,46 @@ func (m *middleware) wrap(next http.Handler) http.Handler {
// 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.
capture := m.newBodyCapture()
observed := &statusObserver{ResponseWriter: w, capture: capture}
observed := &statusObserver{ResponseWriter: w, capture: capture,
hold: m.engine.Config.CustomResponseModifier != nil}
next.ServeHTTP(observed, r)
m.engine.ProcessResponse(req, &guardcore.Response{
StatusCode: observed.status(),
Headers: map[string]string{},
Body: observed.body(),
})
if observed.hold {
observed.release(m.engine, cors)
}
})
}

// statusObserver wraps the net/http ResponseWriter to record the status
// code (defaulting to the implicit 200) and the leading response body bytes.
// In hold mode it also buffers the whole response (status, body) until
// release, so the engine's CustomResponseModifier can rewrite it.
type statusObserver struct {
http.ResponseWriter
capture *bodyCapture
wroteHeader bool
statusCode int
hold bool
held []byte
}

func (o *statusObserver) WriteHeader(code int) {
if !o.wroteHeader {
o.statusCode = code
o.wroteHeader = true
}
o.ResponseWriter.WriteHeader(code)
if !o.hold {
o.ResponseWriter.WriteHeader(code)
}
}

func (o *statusObserver) Write(p []byte) (int, error) {
Expand All @@ -114,6 +129,10 @@ func (o *statusObserver) Write(p []byte) (int, error) {
if o.capture != nil {
o.capture.record(p)
}
if o.hold {
o.held = append(o.held, p...)
return len(p), nil
}
return o.ResponseWriter.Write(p)
}

Expand All @@ -131,6 +150,51 @@ func (o *statusObserver) body() []byte {
return o.capture.buf
}

// 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 (o *statusObserver) release(engine *guardcore.Engine, cors map[string]string) {
header := o.ResponseWriter.Header()
resp := &guardcore.Response{StatusCode: o.status(), Headers: headerSnapshot(header), Body: o.held}
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)
}
o.ResponseWriter.WriteHeader(out.StatusCode)
if len(out.Body) > 0 {
_, _ = o.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
}

// bodyCapture records the leading bytes of the pass-through response body,
// bounded by the engine's behavior_max_response_body_inspect_bytes budget,
// and only when behavior_scan_response_body is enabled (with the flag off
Expand Down
Loading
Loading