waf

package
v0.18.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: MIT Imports: 19 Imported by: 0

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.

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
)

func (Action) String

func (a Action) String() string

String implements fmt.Stringer for log readability.

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 FailMode

type FailMode int

FailMode controls behaviour when rule evaluation errors.

const (
	// FailOpen logs the error and lets the request through. Default.
	FailOpen FailMode = iota
	// FailClosed treats an evaluation error as ActionBlock (status 500).
	FailClosed
)

type Logger

type Logger interface {
	Logf(format string, args ...any)
}

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

type LoggerFunc func(format string, args ...any)

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
)

func (Outcome) String added in v0.18.0

func (o Outcome) String() string

String implements fmt.Stringer; the returned values are exactly the Prometheus label values used by prom.WAF.

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)
}
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)
}
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)
}
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)
}

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.
}

func (*WAF) Rules

func (w *WAF) Rules() []string

Rules returns a snapshot of the currently loaded rule IDs in evaluation order. Useful for admin endpoints / introspection.

func (*WAF) ServeHandler

func (w *WAF) ServeHandler(h http.Handler) http.Handler

ServeHandler implements parapet.Middleware.

func (*WAF) SetRules

func (w *WAF) SetRules(rules []Rule) error

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)
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL