mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-01 02:59:08 +02:00
Add CrowdSec AppSec request inspection to the reverse proxy
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
package appsec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// bufferBody reads up to limit+1 bytes from r.Body and always restores r.Body so
|
||||
// the request stays forwardable. oversize reports that the body exceeded limit, in
|
||||
// which case the returned prefix must not be used for inspection: the bytes are
|
||||
// only read so they can be replayed to the backend.
|
||||
func bufferBody(r *http.Request, limit int64) (body []byte, oversize bool, err error) {
|
||||
original := r.Body
|
||||
buf, readErr := io.ReadAll(io.LimitReader(original, limit+1))
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
// Restore what was read so a downstream retry sees a consistent stream,
|
||||
// then surface the failure.
|
||||
r.Body = replay(buf, original)
|
||||
return nil, false, readErr
|
||||
}
|
||||
|
||||
if int64(len(buf)) > limit {
|
||||
r.Body = replay(buf, original)
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
// The whole body is buffered, so the original is drained and can be closed.
|
||||
// A close error on a drained read-only body does not invalidate the bytes.
|
||||
_ = original.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(buf))
|
||||
// Framing is deliberately left as the client sent it. Rewriting a chunked
|
||||
// request to a fixed Content-Length here would be invisible to the client
|
||||
// but not to the rest of the chain: a later body capture with a smaller cap
|
||||
// sees a known length over its cap and skips capture entirely, where an
|
||||
// unknown length would have given it a truncated prefix. Inspecting a
|
||||
// request must not change what any other layer gets to inspect.
|
||||
return buf, false, nil
|
||||
}
|
||||
|
||||
// replay returns a ReadCloser that yields the already-read prefix followed by
|
||||
// the remainder of the original stream, and closes the original.
|
||||
func replay(prefix []byte, rest io.ReadCloser) io.ReadCloser {
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
Reader: io.MultiReader(bytes.NewReader(prefix), rest),
|
||||
Closer: rest,
|
||||
}
|
||||
}
|
||||
|
||||
// redactedPlaceholder replaces a credential value in the mirrored body. It is
|
||||
// inert for rule matching, and its fixed length leaks nothing about the secret.
|
||||
const redactedPlaceholder = "redacted"
|
||||
|
||||
// redactFormFields returns the body to mirror for a URL-encoded form, with the
|
||||
// values of the named fields replaced. The proxy's own password / PIN login
|
||||
// form posts to the service path itself, so without this the plaintext
|
||||
// credential would reach the Security Engine.
|
||||
//
|
||||
// Only the credential values are removed, never the whole body: dropping the
|
||||
// body outright would let a caller exempt any payload from inspection just by
|
||||
// appending a field named "password". Everything else in the form stays
|
||||
// inspectable, which is the point.
|
||||
//
|
||||
// Returns body unchanged when it is not a URL-encoded form or carries none of
|
||||
// the fields.
|
||||
//
|
||||
// Substitution happens on the raw bytes rather than by re-encoding parsed
|
||||
// values. Re-encoding would drop pairs that url.ParseQuery rejects, so a
|
||||
// payload hidden in a malformed pair alongside a credential-named field would
|
||||
// never be inspected while a tolerant backend parser still acted on it. Working
|
||||
// byte-wise also avoids reordering keys and normalizing escapes, so the engine
|
||||
// sees the same bytes the backend will.
|
||||
//
|
||||
// Field names match case-sensitively, on purpose: the caller passes the exact
|
||||
// names the login handler reads via r.FormValue, and that lookup is itself
|
||||
// case-sensitive. A "Password" field is therefore never a credential as far as
|
||||
// the proxy is concerned, and redacting it would only blind the WAF to a value
|
||||
// the proxy does not own.
|
||||
func redactFormFields(contentType string, body []byte, fields []string) []byte {
|
||||
if len(fields) == 0 || len(body) == 0 {
|
||||
return body
|
||||
}
|
||||
media, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || media != "application/x-www-form-urlencoded" {
|
||||
return body
|
||||
}
|
||||
return redactURLEncoded(body, fields)
|
||||
}
|
||||
|
||||
// redactURLEncoded replaces the values of the named keys in a URL-encoded
|
||||
// key/value sequence, the shared syntax of a query string and a form body.
|
||||
func redactURLEncoded(raw []byte, fields []string) []byte {
|
||||
// Split on "&" only, matching how Go's form parser delimits pairs.
|
||||
segments := bytes.Split(raw, []byte("&"))
|
||||
redacted := false
|
||||
for i, segment := range segments {
|
||||
rawKey, _, hasValue := bytes.Cut(segment, []byte("="))
|
||||
if !hasValue {
|
||||
continue
|
||||
}
|
||||
// Compare the decoded name, so an escaped spelling of the field
|
||||
// ("pass%77ord") is redacted too: the reader decodes before looking it
|
||||
// up. A key that fails to decode never reaches that reader either,
|
||||
// since the parser drops the pair.
|
||||
name, err := url.QueryUnescape(string(rawKey))
|
||||
if err != nil || !slices.Contains(fields, name) {
|
||||
continue
|
||||
}
|
||||
// Keep the key bytes as sent and replace only the value. Assigning a
|
||||
// fresh slice leaves raw untouched, which matters: the caller restored
|
||||
// the request body from the same buffer.
|
||||
segments[i] = []byte(string(rawKey) + "=" + redactedPlaceholder)
|
||||
redacted = true
|
||||
}
|
||||
if !redacted {
|
||||
return raw
|
||||
}
|
||||
return bytes.Join(segments, []byte("&"))
|
||||
}
|
||||
|
||||
// redactQuery replaces the values of the named query parameters in a raw query
|
||||
// string, leaving every other byte as sent.
|
||||
func redactQuery(rawQuery string, params []string) string {
|
||||
if len(params) == 0 || rawQuery == "" {
|
||||
return rawQuery
|
||||
}
|
||||
return string(redactURLEncoded([]byte(rawQuery), params))
|
||||
}
|
||||
|
||||
// redactCookieHeader replaces the values of the named cookies in a Cookie
|
||||
// header, keeping the others intact: cookies are a zone WAF rules match on, so
|
||||
// dropping the whole header would cost real coverage.
|
||||
func redactCookieHeader(value string, names []string) string {
|
||||
if len(names) == 0 || value == "" {
|
||||
return value
|
||||
}
|
||||
parts := strings.Split(value, ";")
|
||||
redacted := false
|
||||
for i, part := range parts {
|
||||
name, _, hasValue := strings.Cut(part, "=")
|
||||
if !hasValue {
|
||||
continue
|
||||
}
|
||||
// Cookie names are case-sensitive and are not percent-decoded.
|
||||
if !slices.Contains(names, strings.TrimSpace(name)) {
|
||||
continue
|
||||
}
|
||||
parts[i] = name + "=" + redactedPlaceholder
|
||||
redacted = true
|
||||
}
|
||||
if !redacted {
|
||||
return value
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
// Package appsec implements the CrowdSec AppSec (WAF) side of the remediation
|
||||
// component protocol: each inspected HTTP request is mirrored to the Security
|
||||
// Engine's AppSec endpoint, which replies with an allow / ban / captcha verdict
|
||||
// for that request.
|
||||
//
|
||||
// This is a separate endpoint from the LAPI decision stream used by the
|
||||
// crowdsec package: LAPI answers "is this IP known bad", AppSec answers "is
|
||||
// this request an attack". The two are configured and enabled independently.
|
||||
package appsec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/netutil"
|
||||
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
||||
)
|
||||
|
||||
// Header names the AppSec component reads off the mirrored request. IP, URI and
|
||||
// Verb are mandatory: the engine answers 500 when any of them is missing.
|
||||
const (
|
||||
headerAPIKey = "X-Crowdsec-Appsec-Api-Key" //nolint:gosec // G101: a header name, not a credential
|
||||
headerIP = "X-Crowdsec-Appsec-Ip"
|
||||
headerURI = "X-Crowdsec-Appsec-Uri"
|
||||
headerVerb = "X-Crowdsec-Appsec-Verb"
|
||||
headerHost = "X-Crowdsec-Appsec-Host"
|
||||
headerUserAgent = "X-Crowdsec-Appsec-User-Agent"
|
||||
headerHTTPVersion = "X-Crowdsec-Appsec-Http-Version"
|
||||
headerTransactionID = "X-Crowdsec-Appsec-Transaction-Id"
|
||||
)
|
||||
|
||||
// headerPrefix covers every protocol header. Any client-supplied header in this
|
||||
// namespace is dropped before forwarding so a caller cannot influence the
|
||||
// engine's view of its own address, or replay an API key.
|
||||
const headerPrefix = "X-Crowdsec-Appsec-"
|
||||
|
||||
// Remediation actions the engine can return.
|
||||
const (
|
||||
actionAllow = "allow"
|
||||
actionBan = "ban"
|
||||
actionCaptcha = "captcha"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTimeout matches the 200ms budget CrowdSec's remediation component
|
||||
// spec sets for the blocking AppSec call.
|
||||
DefaultTimeout = 200 * time.Millisecond
|
||||
// MinTimeout and MaxTimeout bound the configured inspection timeout.
|
||||
// Inspection is synchronous, so the upper bound is what keeps a
|
||||
// mis-set value from parking every request to an inspected service on a
|
||||
// slow engine; the lower bound keeps the call from timing out before the
|
||||
// engine can realistically answer. Mirrors the per-middleware bounds the
|
||||
// proxy already applies to in-path calls.
|
||||
MinTimeout = 10 * time.Millisecond
|
||||
MaxTimeout = 5 * time.Second
|
||||
// DefaultMaxBodyBytes caps the request body mirrored to the engine.
|
||||
// Requests with a larger body are inspected on headers and URI only.
|
||||
DefaultMaxBodyBytes int64 = 64 << 10
|
||||
// DefaultMaxConcurrent bounds inspections in flight toward the engine. The
|
||||
// point is to fail fast instead of parking a goroutine per request for the
|
||||
// whole timeout once the engine is saturated: a slow engine otherwise turns
|
||||
// a traffic burst into a pile of waiters that all time out anyway. Sized so
|
||||
// a healthy engine (single-digit milliseconds per call) never reaches it.
|
||||
DefaultMaxConcurrent = 256
|
||||
// MaxConcurrentLimit is the ceiling for that bound.
|
||||
MaxConcurrentLimit = 4096
|
||||
// MaxBodyBytesLimit is the ceiling for that cap. A single request can hold
|
||||
// this much in memory; the shared Budget is what bounds the total across
|
||||
// concurrent requests. Matches the proxy-wide body-capture ceiling.
|
||||
MaxBodyBytesLimit int64 = 8 << 20
|
||||
// maxResponseBytes bounds how much of a verdict response is read. The
|
||||
// engine answers with a two-field JSON object, so anything beyond this is
|
||||
// not a response we can act on.
|
||||
maxResponseBytes int64 = 4 << 10
|
||||
)
|
||||
|
||||
// Reasons the request body was not mirrored. Reported so an access-log reader
|
||||
// can distinguish "inspected and clean" from "never inspected", and so an
|
||||
// oversize opt-out is visible rather than silent.
|
||||
const (
|
||||
BypassOversize = "oversize"
|
||||
BypassUpgrade = "upgrade"
|
||||
BypassDisabled = "disabled"
|
||||
BypassBudget = "budget_exhausted"
|
||||
)
|
||||
|
||||
// ErrUnavailable reports that the engine could not produce a verdict: the call
|
||||
// failed, timed out, or the engine rejected it (401 bad key, 500 malformed).
|
||||
// Distinguished from a block verdict so the caller can apply the per-service
|
||||
// mode: enforce fails closed, observe allows.
|
||||
var ErrUnavailable = errors.New("appsec engine unavailable")
|
||||
|
||||
// Config configures a Client.
|
||||
type Config struct {
|
||||
// URL is the AppSec endpoint, e.g. http://127.0.0.1:7422/.
|
||||
URL string
|
||||
// APIKey is the CrowdSec bouncer API key. The AppSec component validates it
|
||||
// against LAPI, so the same key used for the decision stream works here.
|
||||
APIKey string
|
||||
// Timeout bounds a single inspection call. Zero means DefaultTimeout.
|
||||
Timeout time.Duration
|
||||
// MaxBodyBytes caps the mirrored request body. Zero means
|
||||
// DefaultMaxBodyBytes; negative disables body forwarding entirely.
|
||||
MaxBodyBytes int64
|
||||
// MaxConcurrent bounds inspections in flight toward the engine. Zero means
|
||||
// DefaultMaxConcurrent; negative disables the bound.
|
||||
MaxConcurrent int
|
||||
// Budget bounds the total body buffering in flight across all inspected
|
||||
// requests. Nil disables that ceiling, which leaves the worst case at
|
||||
// MaxBodyBytes times the concurrent request count; callers serving
|
||||
// untrusted traffic should share the proxy-wide capture budget here.
|
||||
Budget Budget
|
||||
Logger *log.Entry
|
||||
}
|
||||
|
||||
// Budget is the shared allowance for in-flight body buffering. Acquire reports
|
||||
// whether n bytes could be reserved; every successful Acquire is matched by a
|
||||
// Release of the same n. Satisfied by the proxy's capture budget, so AppSec and
|
||||
// the middleware body tap draw down one pool rather than two independent ones.
|
||||
type Budget interface {
|
||||
Acquire(n int64) bool
|
||||
Release(n int64)
|
||||
}
|
||||
|
||||
// Client mirrors HTTP requests to a CrowdSec AppSec endpoint. It holds no
|
||||
// per-service state and is safe for concurrent use.
|
||||
type Client struct {
|
||||
url string
|
||||
apiKey string
|
||||
maxBodyBytes int64
|
||||
// sem bounds in-flight inspections. Nil when the bound is disabled.
|
||||
sem chan struct{}
|
||||
budget Budget
|
||||
http *http.Client
|
||||
logger *log.Entry
|
||||
}
|
||||
|
||||
// New validates the config and returns a Client. The endpoint is not contacted
|
||||
// here: the engine may come up after the proxy.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, errors.New("appsec url is empty")
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, errors.New("appsec api key is empty")
|
||||
}
|
||||
parsed, err := url.Parse(cfg.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse appsec url: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("appsec url scheme %q is not http(s)", parsed.Scheme)
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return nil, errors.New("appsec url has no host")
|
||||
}
|
||||
|
||||
logger := cfg.Logger
|
||||
if logger == nil {
|
||||
logger = log.NewEntry(log.StandardLogger())
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
switch {
|
||||
case timeout <= 0:
|
||||
timeout = DefaultTimeout
|
||||
case timeout < MinTimeout:
|
||||
logger.Warnf("appsec timeout %s is below the minimum, using %s", timeout, MinTimeout)
|
||||
timeout = MinTimeout
|
||||
case timeout > MaxTimeout:
|
||||
logger.Warnf("appsec timeout %s exceeds the maximum, using %s", timeout, MaxTimeout)
|
||||
timeout = MaxTimeout
|
||||
}
|
||||
|
||||
// A negative cap is meaningful: forward no body at all.
|
||||
maxBody := cfg.MaxBodyBytes
|
||||
switch {
|
||||
case maxBody == 0:
|
||||
maxBody = DefaultMaxBodyBytes
|
||||
case maxBody > MaxBodyBytesLimit:
|
||||
logger.Warnf("appsec max body %d exceeds the maximum, using %d", maxBody, MaxBodyBytesLimit)
|
||||
maxBody = MaxBodyBytesLimit
|
||||
}
|
||||
|
||||
maxConcurrent := cfg.MaxConcurrent
|
||||
switch {
|
||||
case maxConcurrent == 0:
|
||||
maxConcurrent = DefaultMaxConcurrent
|
||||
case maxConcurrent > MaxConcurrentLimit:
|
||||
logger.Warnf("appsec max concurrent %d exceeds the maximum, using %d", maxConcurrent, MaxConcurrentLimit)
|
||||
maxConcurrent = MaxConcurrentLimit
|
||||
}
|
||||
var sem chan struct{}
|
||||
if maxConcurrent > 0 {
|
||||
sem = make(chan struct{}, maxConcurrent)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
url: cfg.URL,
|
||||
apiKey: cfg.APIKey,
|
||||
maxBodyBytes: maxBody,
|
||||
sem: sem,
|
||||
budget: cfg.Budget,
|
||||
logger: logger,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Request is one inspection request.
|
||||
type Request struct {
|
||||
// HTTP is the in-flight client request. Inspect buffers and restores its
|
||||
// body, so the request stays forwardable afterwards.
|
||||
HTTP *http.Request
|
||||
// ClientIP is the resolved client address (after trusted-proxy handling).
|
||||
ClientIP netip.Addr
|
||||
// TransactionID correlates the engine's alert with the proxy's access log
|
||||
// entry. Empty lets the engine generate its own UUID.
|
||||
TransactionID string
|
||||
// RedactBodyFields lists form fields whose values are replaced before the
|
||||
// body is mirrored. Used to keep credentials submitted to the proxy's own
|
||||
// login form out of the engine while still inspecting the rest.
|
||||
RedactBodyFields []string
|
||||
// RedactHeaders, RedactCookies and RedactQueryParams name the credentials
|
||||
// the proxy already withholds from backends: the header-auth values, its
|
||||
// session cookie, and the OIDC session token. The engine logs and alerts on
|
||||
// what it inspects, so mirroring them there would reintroduce the leak the
|
||||
// upstream strippers exist to prevent. Only the values are replaced, so the
|
||||
// surrounding headers, cookies and query stay inspectable.
|
||||
RedactHeaders []string
|
||||
RedactCookies []string
|
||||
RedactQueryParams []string
|
||||
}
|
||||
|
||||
// Result is the outcome of an inspection.
|
||||
type Result struct {
|
||||
Verdict restrict.Verdict
|
||||
// BodyBypass names why the request body was not mirrored, empty when it
|
||||
// was (or when the request had none). The engine still saw the headers and
|
||||
// URI, so this is a coverage note, not a failure.
|
||||
BodyBypass string
|
||||
// Release returns the buffered body's budget reservation. Never nil, so it
|
||||
// is always safe to defer. It must run only once the request has been
|
||||
// served, not when Inspect returns: the buffer stays alive as r.Body for
|
||||
// the backend to read, so releasing earlier would let the budget admit
|
||||
// buffering that is still resident.
|
||||
Release func()
|
||||
}
|
||||
|
||||
// noopRelease is the Release for inspections that reserved no budget.
|
||||
func noopRelease() {}
|
||||
|
||||
// Inspect mirrors r to the AppSec engine and returns its verdict. A nil error
|
||||
// with restrict.Allow means the request passed. On failure it returns
|
||||
// DenyAppSecUnavailable wrapped with ErrUnavailable; the caller decides whether
|
||||
// that blocks, based on the per-service mode.
|
||||
func (c *Client) Inspect(ctx context.Context, req Request) (Result, error) {
|
||||
if c == nil {
|
||||
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, ErrUnavailable
|
||||
}
|
||||
if req.HTTP == nil {
|
||||
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, fmt.Errorf("%w: nil request", ErrUnavailable)
|
||||
}
|
||||
|
||||
// release is carried out to the caller rather than deferred here: the
|
||||
// buffered body outlives this call as r.Body.
|
||||
if !c.acquireSlot() {
|
||||
// Deny rather than wave through: a flood must not be a way to switch
|
||||
// inspection off. Enforce blocks, observe logs and allows, exactly as
|
||||
// for an unreachable engine.
|
||||
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease},
|
||||
fmt.Errorf("%w: %d inspections already in flight", ErrUnavailable, cap(c.sem))
|
||||
}
|
||||
defer c.releaseSlot()
|
||||
|
||||
body, bypass, release, err := c.readBody(req)
|
||||
if err != nil {
|
||||
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: release}, fmt.Errorf("%w: read body: %w", ErrUnavailable, err)
|
||||
}
|
||||
|
||||
outbound, err := c.buildRequest(ctx, req, body)
|
||||
if err != nil {
|
||||
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(outbound)
|
||||
if err != nil {
|
||||
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
|
||||
}
|
||||
defer func() {
|
||||
// Drain before closing. net/http only returns a connection to the idle
|
||||
// pool once its body is read to EOF; closing with bytes outstanding
|
||||
// discards it. Every verdict carries a JSON body, so skipping this
|
||||
// would cost a fresh handshake per inspected request, inside the
|
||||
// timeout budget.
|
||||
if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes)); err != nil {
|
||||
c.logger.Tracef("drain appsec response body: %v", err)
|
||||
}
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
c.logger.Tracef("close appsec response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
verdict, err := c.verdict(resp)
|
||||
return Result{Verdict: verdict, BodyBypass: bypass, Release: release}, err
|
||||
}
|
||||
|
||||
// acquireSlot takes an in-flight slot without blocking, reporting false when
|
||||
// the engine is already at capacity.
|
||||
func (c *Client) acquireSlot() bool {
|
||||
if c.sem == nil {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case c.sem <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// releaseSlot returns the slot. Scoped to the engine call, not the request: the
|
||||
// buffered body outlives the call but the engine's attention does not.
|
||||
func (c *Client) releaseSlot() {
|
||||
if c.sem == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-c.sem:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// readBody buffers the body so it can be mirrored, always restoring it on the
|
||||
// original request. Returns nil when there is no body to forward: no body at
|
||||
// all, an upgrade request, or a body over the cap. A login form is forwarded
|
||||
// with its credential values redacted rather than suppressed.
|
||||
// release is never nil; the caller invokes it once the request has been served.
|
||||
func (c *Client) readBody(req Request) (body []byte, bypass string, release func(), err error) {
|
||||
r := req.HTTP
|
||||
if r.Body == nil || r.Body == http.NoBody {
|
||||
return nil, "", noopRelease, nil
|
||||
}
|
||||
if c.maxBodyBytes < 0 {
|
||||
return nil, BypassDisabled, noopRelease, nil
|
||||
}
|
||||
// A genuine upgrade request carries no body to inspect (net/http hands us
|
||||
// http.NoBody, caught above); the hijacked stream is reached through
|
||||
// Hijacker, never r.Body. The test has to be the forwarder's own, because a
|
||||
// looser one would skip inspection for requests the forwarder still
|
||||
// delivers to the backend with their body intact.
|
||||
if netutil.IsUpgradeRequest(r.Header) {
|
||||
return nil, BypassUpgrade, noopRelease, nil
|
||||
}
|
||||
// A Content-Length over the cap is known to be too large before reading.
|
||||
if r.ContentLength > c.maxBodyBytes {
|
||||
return nil, BypassOversize, noopRelease, nil
|
||||
}
|
||||
|
||||
// Reserve the whole cap rather than the eventual length: the reservation
|
||||
// has to be made before the body is read, and until then the only bound
|
||||
// known is the cap. Skipping inspection when the pool is drained keeps a
|
||||
// burst of large bodies from being an out-of-memory lever; the bypass is
|
||||
// recorded so the gap in coverage is visible.
|
||||
release = noopRelease
|
||||
if c.budget != nil {
|
||||
if !c.budget.Acquire(c.maxBodyBytes) {
|
||||
c.logger.Debugf("appsec buffer budget exhausted, inspecting headers and URI only")
|
||||
return nil, BypassBudget, noopRelease, nil
|
||||
}
|
||||
var once sync.Once
|
||||
release = func() { once.Do(func() { c.budget.Release(c.maxBodyBytes) }) }
|
||||
}
|
||||
|
||||
buffered, oversize, err := bufferBody(r, c.maxBodyBytes)
|
||||
if err != nil {
|
||||
// bufferBody restored r.Body from the bytes it did read, so the
|
||||
// reservation stays held until the caller releases it.
|
||||
return nil, "", release, err
|
||||
}
|
||||
// An oversize body was only partially read: a truncated prefix changes the
|
||||
// engine's verdict in both directions, so inspect headers and URI only.
|
||||
if oversize {
|
||||
return nil, BypassOversize, release, nil
|
||||
}
|
||||
return redactFormFields(r.Header.Get("Content-Type"), buffered, req.RedactBodyFields), "", release, nil
|
||||
}
|
||||
|
||||
// buildRequest assembles the mirrored request. Per the protocol it is a GET
|
||||
// when there is no body and a POST otherwise; bytes.Reader gives the outbound
|
||||
// request an accurate Content-Length, which the engine relies on to read the
|
||||
// body at all.
|
||||
func (c *Client) buildRequest(ctx context.Context, req Request, body []byte) (*http.Request, error) {
|
||||
method := http.MethodGet
|
||||
var payload io.Reader
|
||||
if len(body) > 0 {
|
||||
method = http.MethodPost
|
||||
payload = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
outbound, err := http.NewRequestWithContext(ctx, method, c.url, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build appsec request: %w", err)
|
||||
}
|
||||
|
||||
r := req.HTTP
|
||||
copyInspectableHeaders(outbound.Header, r.Header)
|
||||
redactSecrets(outbound.Header, req)
|
||||
|
||||
outbound.Header.Set(headerAPIKey, c.apiKey)
|
||||
outbound.Header.Set(headerIP, req.ClientIP.Unmap().String())
|
||||
outbound.Header.Set(headerURI, mirroredURI(r.URL, req.RedactQueryParams))
|
||||
outbound.Header.Set(headerVerb, r.Method)
|
||||
outbound.Header.Set(headerHost, r.Host)
|
||||
if ua := r.UserAgent(); ua != "" {
|
||||
outbound.Header.Set(headerUserAgent, ua)
|
||||
}
|
||||
outbound.Header.Set(headerHTTPVersion, httpVersion(r))
|
||||
if req.TransactionID != "" {
|
||||
outbound.Header.Set(headerTransactionID, req.TransactionID)
|
||||
}
|
||||
return outbound, nil
|
||||
}
|
||||
|
||||
// verdict maps the engine's response to a restrict.Verdict. 200 is a pass and
|
||||
// 401/500 are engine-side failures; every other status carries a remediation in
|
||||
// the body. The blocked status code is operator-configurable
|
||||
// (blocked_http_code), so the action field decides, not the status.
|
||||
func (c *Client) verdict(resp *http.Response) (restrict.Verdict, error) {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: rejected api key", ErrUnavailable)
|
||||
case http.StatusInternalServerError:
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: engine error", ErrUnavailable)
|
||||
}
|
||||
|
||||
// Every status, 200 included, has to carry a decodable remediation. Taking a
|
||||
// bare 200 as a pass would mean a URL pointing at anything that answers 200
|
||||
// (a health endpoint, a load balancer's default page) silently allows every
|
||||
// request while the service reports itself as enforcing.
|
||||
|
||||
var decoded struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&decoded); err != nil {
|
||||
// Every remediation carries a decodable action, so a response without
|
||||
// one is not a verdict: most often the URL points at something that is
|
||||
// not the AppSec endpoint, which answers 404 with HTML. Reported as
|
||||
// unavailable rather than a ban so the access log names the real fault
|
||||
// instead of sending an operator hunting for a rule that never fired.
|
||||
// Enforce still blocks either way; only the recorded reason differs.
|
||||
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: undecodable response (status %d): %w", ErrUnavailable, resp.StatusCode, err)
|
||||
}
|
||||
|
||||
switch decoded.Action {
|
||||
case actionAllow:
|
||||
return restrict.Allow, nil
|
||||
case actionCaptcha:
|
||||
return restrict.DenyAppSecCaptcha, nil
|
||||
case actionBan:
|
||||
return restrict.DenyAppSecBan, nil
|
||||
case "":
|
||||
// Decodable JSON without a remediation is not a verdict either: the
|
||||
// endpoint answered, but not as the engine. Same reasoning as an
|
||||
// undecodable body, and the same reason to point at configuration.
|
||||
return restrict.DenyAppSecUnavailable,
|
||||
fmt.Errorf("%w: response carried no remediation (status %d)", ErrUnavailable, resp.StatusCode)
|
||||
default:
|
||||
// A remediation we do not implement still means the engine flagged the
|
||||
// request, so deny.
|
||||
c.logger.Debugf("unknown appsec action %q (status %d), treating as ban", decoded.Action, resp.StatusCode)
|
||||
return restrict.DenyAppSecBan, nil
|
||||
}
|
||||
}
|
||||
|
||||
// copyInspectableHeaders copies the client's headers, which are what the WAF
|
||||
// rules actually match on, dropping hop-by-hop headers that describe the
|
||||
// proxy-to-engine connection rather than the client request, and any header in
|
||||
// the AppSec protocol namespace.
|
||||
func copyInspectableHeaders(dst, src http.Header) {
|
||||
for name, values := range src {
|
||||
if hopByHopHeaders[http.CanonicalHeaderKey(name)] {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(http.CanonicalHeaderKey(name), headerPrefix) {
|
||||
continue
|
||||
}
|
||||
dst[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
|
||||
}
|
||||
// Content-Length describes the mirrored payload, not the client's: net/http
|
||||
// sets it from the body we actually attach. Content-Type is kept either way
|
||||
// so rules matching on it still fire when the body was not forwarded.
|
||||
dst.Del("Content-Length")
|
||||
}
|
||||
|
||||
// redactSecrets replaces the credential values the proxy withholds from
|
||||
// backends, so the mirrored copy does not carry them either.
|
||||
func redactSecrets(dst http.Header, req Request) {
|
||||
for _, name := range req.RedactHeaders {
|
||||
// Presence, not Get: a header whose first value is empty still carries
|
||||
// its later values to the engine, while the upstream strip deletes the
|
||||
// name outright. Set collapses every value into the placeholder.
|
||||
if len(dst.Values(name)) > 0 {
|
||||
dst.Set(name, redactedPlaceholder)
|
||||
}
|
||||
}
|
||||
// Every Cookie line, not just the first: a client may send several, and Get
|
||||
// would leave the session cookie in any later one mirrored in the clear.
|
||||
if cookies := dst.Values("Cookie"); len(cookies) > 0 {
|
||||
redacted := make([]string, len(cookies))
|
||||
for i, cookie := range cookies {
|
||||
redacted[i] = redactCookieHeader(cookie, req.RedactCookies)
|
||||
}
|
||||
dst["Cookie"] = redacted
|
||||
}
|
||||
}
|
||||
|
||||
// mirroredURI renders the request target for the URI header, with the named
|
||||
// query parameter values replaced.
|
||||
func mirroredURI(u *url.URL, redactParams []string) string {
|
||||
uri := u.RequestURI()
|
||||
if u.RawQuery == "" || len(redactParams) == 0 {
|
||||
return uri
|
||||
}
|
||||
redacted := redactQuery(u.RawQuery, redactParams)
|
||||
if redacted == u.RawQuery {
|
||||
return uri
|
||||
}
|
||||
// RequestURI is path + "?" + RawQuery; swap only the query part so the
|
||||
// path keeps its original encoding.
|
||||
return strings.TrimSuffix(uri, u.RawQuery) + redacted
|
||||
}
|
||||
|
||||
var hopByHopHeaders = map[string]bool{
|
||||
"Connection": true,
|
||||
"Keep-Alive": true,
|
||||
"Proxy-Authenticate": true,
|
||||
"Proxy-Authorization": true,
|
||||
"Proxy-Connection": true,
|
||||
"Te": true,
|
||||
"Trailer": true,
|
||||
"Transfer-Encoding": true,
|
||||
"Upgrade": true,
|
||||
}
|
||||
|
||||
// httpVersion renders the two-digit form the engine parses ("11", "20").
|
||||
func httpVersion(r *http.Request) string {
|
||||
major, minor := r.ProtoMajor, r.ProtoMinor
|
||||
if major < 0 || major > 9 || minor < 0 || minor > 9 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%d", major, minor)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user