Documentation
¶
Overview ¶
Package waf provides a Web Application Firewall middleware for parapet.
Rules are written in the Common Expression Language (CEL, https://github.com/google/cel-go) which allows safe, sandboxed expressions to be evaluated against incoming HTTP requests without restarting the process.
Quick start ¶
w := waf.New()
_ = w.SetRules([]waf.Rule{{
ID: "block-sqli",
Expression: `request.query.contains("' OR '1'='1") || request.path.matches("(?i).*union.*select.*")`,
Action: waf.ActionBlock,
Status: http.StatusForbidden,
}})
srv := parapet.NewFrontend()
srv.Use(w)
Hot reload ¶
Rules can be replaced atomically at runtime via WAF.SetRules. Existing in-flight requests continue to use the previous compiled ruleset; new requests use the new ruleset on the very next call. Compilation happens inside SetRules so the request path is never blocked by parsing or type-checking.
Variables exposed to expressions ¶
The top-level identifier `request` is a map with these fields:
request.method string request.host string request.path string request.query string // raw query string request.uri string // request URI request.proto string // "HTTP/1.1", "HTTP/2.0", ... request.scheme string // "http" or "https" request.remote_ip string // best-effort client IP (X-Real-IP -> X-Forwarded-For -> RemoteAddr) request.country string // ISO 3166-1 alpha-2 from WAF.Country (e.g. "TH"); "" if unresolved/unset request.asn int // autonomous system number from WAF.ASN (e.g. 13335); 0 if unresolved/unset request.content_length int request.headers map<string, string> // single value per name (canonicalised, lowercase keys) request.cookies map<string, string> request.args map<string, string> // first value of each query parameter request.user_agent string request.referer string request.body string // populated only when WAF.InspectBody > 0; truncated to that many bytes
Custom functions ¶
ipInCidr(ip, cidr) bool // CIDR membership regexMatch(s, pattern) bool // pre-compiled regex (cached) containsAny(s, list) bool // substring contains any of the list entries hasPrefixAny(s, list) bool // hasPrefix any of the list entries lower(s) string // ascii lowercase upper(s) string // ascii uppercase urlDecode(s) string // percent-decode (errors return empty string)
Performance ¶
Each Rule is compiled once when SetRules is called, into a stateless, thread-safe cel.Program. Evaluation uses ContextEval with a CostLimit and per-request deadline (WAF.EvalTimeout) to keep the request path bounded.
Reusing the CEL surface outside the firewall (Predicate) ¶
The same `request` model and custom functions are available standalone via Predicate, a compiled boolean expression with no action/status — it answers only "does this request match". This lets other middleware reuse the WAF's CEL surface without duplicating (or drifting from) the environment; the canonical consumer is rate limiting, which gates a limit on a Predicate:
p, _ := waf.NewPredicate(`request.method == "POST" && request.path.startsWith("/api/")`)
in := waf.NewInput(r, "", country, asn) // one snapshot, reusable across predicates
match, err := p.Eval(r.Context(), in)
Build a Predicate once (NewPredicate validates the bool result type and bounds it with the same cost limit as a rule); evaluation is concurrency-safe. Eval returns an error on timeout/cost/type failure, leaving the fail-open vs fail-closed decision to the caller.
Security notes ¶
- Cost limit is enforced (default 1_000_000 units) to prevent runaway rules.
- CEL macros (all/exists/filter/map/comprehensions) are kept enabled by default but bounded by the cost limit. Set WAF.DisableMacros = true to refuse rules that try to use them.
- Body inspection is OFF by default. Enabling it buffers up to InspectBody bytes; set this conservatively to avoid memory-amplification attacks.
- Failed rule compilation returns an error from SetRules; the previous ruleset stays in place.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Action ¶
type Action int
Action describes what the WAF should do when a Rule matches.
const ( // ActionLog records the match (via WAF.Logger) and lets the request continue. // Useful for shadow-deploying new rules. ActionLog Action = iota // ActionAllow short-circuits the WAF chain: no further rules are evaluated // and the request is forwarded to the next handler. This is the inverse of // ActionBlock and is intended for explicit allowlists (e.g. trusted health // checkers, internal scanners). ActionAllow // ActionBlock terminates the request with the rule's Status (defaults to 403). ActionBlock )
type EvalEvent ¶ added in v0.18.0
type EvalEvent struct {
// Request is the request that was evaluated. The hook may read it (e.g. to
// derive a custom label) but must not mutate it; prom.WAF ignores it.
Request *http.Request
// Outcome is how evaluation terminated. Bounded; safe as a metric label.
Outcome Outcome
// Duration is the wall time spent evaluating rules — the SAME span as
// MatchEvent.Elapsed. It is time.Since(start) where start is taken AFTER
// body buffering, Country/ASN lookup and request-map construction, measured
// at the terminating decision point BEFORE the request is handed to the next
// handler. It therefore EXCLUDES client body I/O, geo/ASN/map-build cost, and
// downstream-handler latency, and measures rule evaluation only.
Duration time.Duration
}
EvalEvent is delivered to WAF.Observe (if set) exactly ONCE per request that reaches rule evaluation, after evaluation terminates, regardless of outcome. Unlike MatchEvent (which fires per matched rule and only on a match), it makes the WAF's per-request overhead visible on EVERY path — including the common no-match fall-through and the allow/error short-circuits.
type Input ¶ added in v0.18.3
type Input struct {
// contains filtered or unexported fields
}
Input is a materialised `request` snapshot for one HTTP request. Build it once with NewInput and reuse it across several Predicate.Eval calls so the request (headers, cookies, query) is walked only once per request, however many predicates evaluate against it. It is read-only; do not mutate after building.
func NewInput ¶ added in v0.18.3
NewInput materialises the `request` snapshot for r, exposing exactly the fields documented for WAF rules. body is the (caller-truncated) string for request.body — pass "" to leave it empty (no body inspection). country/asn are the resolved GeoIP values for request.country / request.asn — pass "" / 0 when unresolved or unused; the fields are always present so an expression referencing them never errors.
type Logger ¶
Logger is the interface used by the WAF to emit match events. It deliberately avoids a hard dependency on a structured logger package; callers can adapt to slog, zap, or stdlib as they prefer.
type LoggerFunc ¶
LoggerFunc adapts a plain function into the Logger interface.
func (LoggerFunc) Logf ¶
func (f LoggerFunc) Logf(format string, args ...any)
Logf implements Logger.
type MatchEvent ¶
type MatchEvent struct {
Request *http.Request
RuleID string
Action Action
// Status is the rule's configured Status field (defaulted to 403 when
// the rule didn't set one). It is the HTTP status that *would be*
// returned for an ActionBlock match; for ActionLog and ActionAllow it
// carries the configured value but does not describe the actual response.
Status int
Expression string
ClientIP string
// Elapsed is the time spent inside the WAF up to and including this
// match — i.e. evaluating every rule that ran before this one plus the
// matching rule itself. It is NOT the per-rule evaluation latency.
Elapsed time.Duration
}
MatchEvent is delivered to OnMatch (if set) for every rule that fires. It is intentionally lightweight so handlers don't have to copy the request.
type ObserveFunc ¶ added in v0.18.0
type ObserveFunc func(EvalEvent)
ObserveFunc is the per-request evaluation-observation hook shape, returned by prom.WAF for wiring into WAF.Observe (the prom.Mirror / prom.Cache convention), keeping pkg/waf free of any Prometheus dependency.
type Outcome ¶ added in v0.18.0
type Outcome uint8
Outcome classifies the terminal disposition of one WAF evaluation, reported once per request via WAF.Observe regardless of whether any rule matched. It is a small, bounded set so it is safe as a Prometheus label; it is NEVER the rule ID (which is unbounded and lives on MatchEvent.RuleID instead).
const ( // OutcomePass: evaluation finished without a terminating match and the // request was forwarded to the next handler. This is the common case and // also subsumes ActionLog-only matches (which never terminate) and any // FailOpen-swallowed rule errors along the way (which also do not // terminate). It is the silent-majority path OnMatch cannot see. OutcomePass Outcome = iota // OutcomeAllow: an ActionAllow rule fired; evaluation short-circuited and // the request was forwarded without running the remaining rules. OutcomeAllow // OutcomeBlock: an ActionBlock rule fired; the request was rejected with // the rule's status. OutcomeBlock // OutcomeError: a rule errored (panic recovered, timeout, cost exceeded, // type mismatch) under FailMode=FailClosed, terminating the request with // 500. Under the default FailOpen a rule error is swallowed and the request // continues, so it is NOT OutcomeError — it folds into the eventual // Pass/Allow/Block outcome. An EvalTimeout firing is just such an eval // error, so "timeout" is not a distinct outcome. OutcomeError )
type Predicate ¶ added in v0.18.3
type Predicate struct {
// contains filtered or unexported fields
}
Predicate is a standalone compiled CEL boolean expression over the SAME `request` model and helper functions as WAF rules (see package docs for the variables and functions). It exists so other middleware can gate behaviour on request attributes using the WAF's CEL surface without duplicating — or drifting from — the environment. The canonical consumer is rate limiting, which uses a Predicate to decide whether a limit applies to a request.
A Predicate is compiled once by NewPredicate and is safe for concurrent Eval. Unlike a Rule it carries no action/status/priority: it answers one question, "does this request match", and the caller decides what to do with the answer.
func NewPredicate ¶ added in v0.18.3
func NewPredicate(expr string, opts ...PredicateOption) (*Predicate, error)
NewPredicate compiles expr into a Predicate using the WAF's CEL environment. expr must be non-empty and evaluate to bool; a compile error, a non-bool result type, or a program-build error is returned (the same checks SetRules applies to a rule expression), so an invalid predicate is caught at construction, never at request time.
func (*Predicate) Eval ¶ added in v0.18.3
Eval evaluates the predicate against in and returns the boolean result. It applies the predicate's own eval timeout (derived from ctx) and cost limit; a timeout, cost-limit breach, runtime type error, or recovered panic is returned as a non-nil error with a false result, leaving the fail-open/fail-closed decision to the caller. ctx may carry the request's cancellation.
func (*Predicate) Expression ¶ added in v0.18.3
Expression returns the source expression, for logs and introspection.
type PredicateOption ¶ added in v0.18.3
type PredicateOption func(*predicateConfig)
PredicateOption configures NewPredicate. The defaults match the WAF's own rule compilation (cost limit defaultCostLimit, eval timeout defaultEvalTimeout, macros enabled), so a predicate is bounded exactly like a rule unless the caller tightens it.
func WithPredicateCostLimit ¶ added in v0.18.3
func WithPredicateCostLimit(n uint64) PredicateOption
WithPredicateCostLimit caps CEL evaluator cost per Eval (0 ⇒ defaultCostLimit).
func WithPredicateDisableMacros ¶ added in v0.18.3
func WithPredicateDisableMacros() PredicateOption
WithPredicateDisableMacros refuses expressions that use CEL macros (all/exists/filter/map/comprehensions) — recommended when the expression comes from a less-trusted source, mirroring WAF.DisableMacros.
func WithPredicateEvalTimeout ¶ added in v0.18.3
func WithPredicateEvalTimeout(d time.Duration) PredicateOption
WithPredicateEvalTimeout sets the per-Eval deadline (<=0 ⇒ defaultEvalTimeout).
type Rule ¶
type Rule struct {
// ID is a stable identifier used in logs. Required.
ID string
// Description is an optional human-readable summary recorded in match logs.
Description string
// Expression is a CEL expression that must evaluate to bool.
// See package docs for the available variables and functions.
Expression string
// Action determines what to do when the expression returns true.
Action Action
// Status is the HTTP status returned for ActionBlock matches.
// Defaults to 403 Forbidden when zero.
Status int
// Message is the response body for ActionBlock matches. Defaults to a
// generic "Forbidden" string. Returned as text/plain.
Message string
// Priority controls evaluation order; lower values run first.
// Allowlist rules should typically have the smallest Priority so they
// short-circuit before any block rules are considered.
Priority int
}
Rule is a single WAF rule definition.
Rules are sorted by ascending Priority (smaller runs first) when SetRules is called. Within equal priorities, declaration order is preserved.
type WAF ¶
type WAF struct {
// Logger receives one line per matched rule. nil disables logging.
Logger Logger
// OnMatch is invoked for every rule that fires (any Action). Optional.
// Runs synchronously on the request goroutine — keep it cheap.
OnMatch func(MatchEvent)
// Observe is invoked exactly once per request that reaches rule evaluation,
// after evaluation terminates, with the terminal Outcome and the rule-eval
// Duration. Optional. Runs synchronously on the request goroutine just
// before the request is forwarded or rejected — keep it cheap. Unlike
// OnMatch (per matched rule, only on a match), it fires on every path, so it
// answers "how much does the WAF cost per request". Wire prom.WAF() here for
// a latency histogram. The no-rules fast path does NOT call Observe (no
// evaluation happened).
Observe func(EvalEvent)
// EvalTimeout is the per-request deadline for evaluating the entire
// ruleset. Defaults to 5ms. A timeout treats the request as Allow
// (i.e. fails open) but logs the error — this is the safer default for
// a reverse proxy because failing closed during a config bug would
// drop legitimate traffic. Override with FailMode to change behaviour.
EvalTimeout time.Duration
// CostLimit caps CEL evaluator cost per rule. 0 = use defaultCostLimit.
// Set explicitly to a smaller number when running untrusted rules.
CostLimit uint64
// FailMode controls behaviour when a rule errors at evaluation time
// (panic recovered, timeout, cost exceeded, type mismatch).
// Default is FailOpen: error rules are skipped and the request continues.
FailMode FailMode
// DisableMacros prevents rules from using all/exists/filter/map/etc.
// when set to true. Recommended when rules come from less-trusted sources.
DisableMacros bool
// InspectBody enables body inspection up to N bytes. 0 = body is empty
// in expressions (request.body == ""). The buffered body is restored
// to r.Body so downstream handlers can still read it.
InspectBody int64
// Country resolves the client's ISO 3166-1 alpha-2 country code for a
// request, exposed to rules as `request.country` (e.g. for GeoIP filtering:
// `request.country == "TH"`). The WAF stays storage-agnostic — the caller
// supplies the lookup (a GeoIP database, an edge header, etc.). nil leaves
// `request.country` as the empty string; the field is always present in the
// map, so a rule referencing it never errors on a missing key.
Country func(r *http.Request) string
// ASN resolves the client's autonomous system number for a request,
// exposed to rules as `request.asn` (e.g. for ASN filtering:
// `request.asn == 13335`). The WAF stays storage-agnostic — the caller
// supplies the lookup (an IP-to-ASN database, an edge header, etc.). nil
// leaves `request.asn` as 0; the field is always present in the map, so a
// rule referencing it never errors on a missing key. The value is an int64
// (not uint) so rules compare against plain CEL integer literals; valid
// ASNs fit losslessly, and 0 — reserved by RFC 7607 — means unresolved.
ASN func(r *http.Request) int64
// contains filtered or unexported fields
}
WAF is the middleware. Construct with New.
All fields except the atomic rules pointer should be configured before the first request is served. Rules can be replaced at any time via SetRules.
Example (Geo) ¶
Filter by GeoIP country and ASN. The WAF stays storage-agnostic: supply the lookups (a GeoIP/IP-to-ASN database, an edge header, etc.) and reference the resolved values as request.country and request.asn in expressions.
package main
import (
"net/http"
"github.com/moonrhythm/parapet"
"github.com/moonrhythm/parapet/pkg/waf"
)
func main() {
w := waf.New()
w.Country = func(r *http.Request) string {
// e.g. resolve from a MaxMind DB or trust an edge header.
return r.Header.Get("CF-IPCountry")
}
w.ASN = func(r *http.Request) int64 {
return 0 // e.g. resolve from an IP-to-ASN database.
}
_ = w.SetRules([]waf.Rule{{
ID: "block-country-and-asn",
Expression: `request.country == "XX" || request.asn == 13335`,
Action: waf.ActionBlock,
}})
s := parapet.NewFrontend()
s.Use(w)
}
Output:
Example (InspectBody) ¶
Enable request-body inspection and tighten the evaluation limits. Body inspection is off by default; set InspectBody to buffer up to N bytes so rules can match on request.body. EvalTimeout and CostLimit bound the cost of each request, and FailClosed rejects a request whose rules error instead of failing open.
package main
import (
"time"
"github.com/moonrhythm/parapet"
"github.com/moonrhythm/parapet/pkg/waf"
)
func main() {
w := waf.New()
w.InspectBody = 64 << 10 // buffer up to 64 KiB of the body
w.EvalTimeout = 2 * time.Millisecond
w.CostLimit = 500_000
w.FailMode = waf.FailClosed
_ = w.SetRules([]waf.Rule{{
ID: "block-body-script-tag",
Expression: `request.method == "POST" && lower(request.body).contains("<script")`,
Action: waf.ActionBlock,
}})
s := parapet.NewFrontend()
s.Use(w)
}
Output:
Example (Observe) ¶
Observe fires once per evaluated request regardless of outcome, so it can answer "how much is the WAF costing me" on the common no-match path that OnMatch never sees. Wire prom.WAF() for a histogram, or a custom hook.
package main
import (
"log"
"github.com/moonrhythm/parapet"
"github.com/moonrhythm/parapet/pkg/waf"
)
func main() {
w := waf.New()
_ = w.SetRules([]waf.Rule{{
ID: "block-sqli",
Expression: `request.query.contains("' OR '1'='1")`,
Action: waf.ActionBlock,
}})
w.Observe = func(ev waf.EvalEvent) {
// Fires on pass/allow/block/error alike; ev.Duration is rule-eval time.
log.Printf("waf eval outcome=%s took=%s", ev.Outcome, ev.Duration)
}
s := parapet.NewFrontend()
s.Use(w)
}
Output:
Example (Shadow) ¶
Shadow-deploy a new rule with ActionLog: matches are recorded via the Logger (and OnMatch) but the request still passes, so you can measure a rule's hit rate before switching it to ActionBlock.
package main
import (
"log"
"github.com/moonrhythm/parapet"
"github.com/moonrhythm/parapet/pkg/waf"
)
func main() {
w := waf.New()
w.Logger = waf.LoggerFunc(log.Printf)
w.OnMatch = func(ev waf.MatchEvent) {
// e.g. increment a metric keyed by ev.RuleID / ev.Action.
_ = ev
}
_ = w.SetRules([]waf.Rule{{
ID: "candidate-bad-ua",
Expression: `lower(request.user_agent).contains("sqlmap")`,
Action: waf.ActionLog, // observe only; does not block
}})
s := parapet.NewFrontend()
s.Use(w)
}
Output:
func New ¶
func New() *WAF
New creates a WAF with no rules loaded. Call SetRules before mounting it.
Example ¶
Mount a WAF on an edge-facing server: construct it, load a CEL ruleset that blocks obvious SQL-injection attempts, then wire it ahead of the upstream.
package main
import (
"net/http"
"github.com/moonrhythm/parapet"
"github.com/moonrhythm/parapet/pkg/waf"
)
func main() {
w := waf.New()
_ = w.SetRules([]waf.Rule{{
ID: "block-sqli",
Expression: `request.query.contains("' OR '1'='1") || request.path.matches("(?i).*union.*select.*")`,
Action: waf.ActionBlock,
Status: http.StatusForbidden,
}})
s := parapet.NewFrontend()
s.Use(w)
// s.Use(upstream.SingleHost("10.0.0.1:8080")) — the protected backend.
}
Output:
func (*WAF) Rules ¶
Rules returns a snapshot of the currently loaded rule IDs in evaluation order. Useful for admin endpoints / introspection.
func (*WAF) ServeHandler ¶
ServeHandler implements parapet.Middleware.
func (*WAF) SetRules ¶
SetRules atomically replaces the ruleset.
It compiles every Rule first and only swaps in the new ruleset if all rules compile successfully. This means a bad rule cannot brick the WAF — the previous ruleset stays in place and the caller gets an error describing every failure.
Example (Allowlist) ¶
Allowlist rules short-circuit before any block rule runs. Give them the smallest Priority so trusted clients (here, an internal scanner) are waved through, while a lower-priority block rule rejects everyone else from a sensitive path.
package main
import (
"net/http"
"github.com/moonrhythm/parapet"
"github.com/moonrhythm/parapet/pkg/waf"
)
func main() {
w := waf.New()
_ = w.SetRules([]waf.Rule{
{
ID: "allow-internal-scanner",
Priority: 0, // runs first; ActionAllow ends evaluation
Expression: `ipInCidr(request.remote_ip, "10.0.0.0/8")`,
Action: waf.ActionAllow,
},
{
ID: "block-admin-from-outside",
Priority: 10,
Expression: `hasPrefixAny(request.path, ["/admin", "/internal"])`,
Action: waf.ActionBlock,
Status: http.StatusForbidden,
Message: "admin access denied",
},
})
s := parapet.NewFrontend()
s.Use(w)
}
Output: