Documentation
¶
Overview ¶
Package gwaf is an embeddable, Go-native web application firewall.
gwaf is a library. It is imported into an application, runs on that application's goroutines, and holds no global state. There is no daemon, no admin server, and no user interface: gwaf decides whether a request is an attack and reports why, and the embedder decides what to do about it.
Getting started ¶
New with no options returns a working, blocking firewall with the first-party ruleset loaded:
waf, err := gwaf.New()
if err != nil {
return err
}
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine(r.Method, r.RequestURI, r.Proto)
tx.SetRemoteAddr(r.RemoteAddr)
for name, values := range r.Header {
for _, v := range values {
tx.AddRequestHeader(name, v)
}
}
if d := tx.ProcessRequestHeaders(); d.Blocked() {
http.Error(w, "forbidden", d.Status())
return
}
Blocking at the header phase means the body is never read from the client, never parsed, and never transformed.
How it works ¶
A conventional WAF walks its ruleset per request: for every rule, resolve its targets, transform each value, run its operator. That is O(rules × values) transform-and-match operations, and it is why WAF latency scales with ruleset size.
gwaf compiles instead. Rules, their transform chains, and their required literals are inputs to a compiler that emits an execution plan: rules are grouped by transform chain, each chain's literals are compiled into one Aho-Corasick automaton, and at request time each value is normalized once per chain and scanned once. Only rules whose literals actually appeared are evaluated. On benign traffic the candidate set is empty and no operator runs at all — a ruleset of ten rules and one of ten thousand cost the same.
Guarantees ¶
- Benign traffic evaluates zero rules and performs zero allocations. Both are asserted by tests, not just measured by benchmarks.
- Work is metered in deterministic fuel rather than wall-clock time, so the denial-of-service bound is provable and a budget violation reproduces in a unit test.
- Every decision is explainable: it carries the rule, the matched byte span, and the score that produced it.
- Rules evaluate in (phase, ID) order regardless of how the ruleset was assembled, so the same request always produces the same decision.
Scope ¶
gwaf analyses one request in isolation, with no memory of any other. Anything requiring state, identity, time, or infrastructure — rate limiting, IP reputation, bot scoring, packet filtering — belongs to the embedder and reaches gwaf as an input rather than being maintained by it.
Concurrency ¶
A WAF is safe for concurrent use by any number of goroutines. A Transaction is not: each is owned by exactly one goroutine for its lifetime. This is the most common misuse of every WAF library.
Any number of independent WAF instances may coexist in one process with different rulesets, which is what makes multi-tenant embedding and parallel tests work.
Rules ¶
The first-party ruleset in ruleset/core contains only Certain and High confidence rules, which is what makes blocking by default defensible. Rules are plain struct literals:
rules.Rule{
ID: 1_000_001,
Phase: types.PhaseRequestHeaders,
Targets: []types.Target{{Kind: types.TargetRequestHeaders, Name: "User-Agent"}},
Transforms: []rules.Transform{transform.Lowercase},
Op: op.ContainsAny("sqlmap", "nikto"),
Actions: []rules.Action{rules.Block},
Severity: types.SeverityCritical,
Confidence: types.Certain,
Msg: "Known vulnerability scanner",
}
Confidence is not an opinion about a rule; it is a measured property, and the tier a rule declares bounds the false-positive rate it is allowed to exhibit against the benign corpus.
Five interfaces are the extension surface: rules.Operator, rules.Transform, rules.Action, and — once implemented — Resolver and Detector. Custom operators that can honestly declare their required literals are prefiltered exactly like built-in ones; those that cannot are reported as unconditional at compile time, so their cost is visible before deployment rather than after.
See the docs directory for the architecture (CONCEPT.md), rule authoring (RULES.md), integration profiles (INTEGRATION.md), and the performance model (PERFORMANCE.md).
Index ¶
- type Decision
- func (d Decision) Allowed() bool
- func (d Decision) Blocked() bool
- func (d Decision) Confidence() types.Confidence
- func (d Decision) Detail() string
- func (d Decision) Explain() Explanation
- func (d Decision) Interpretation() string
- func (d Decision) Key() string
- func (d Decision) LogValue() slog.Value
- func (d Decision) MatchedSpan() (types.Span, bool)
- func (d Decision) Message() string
- func (d Decision) Reason() Reason
- func (d Decision) RuleID() types.RuleID
- func (d Decision) RulesEvaluated() int
- func (d Decision) Score() int
- func (d Decision) Severity() types.Severity
- func (d Decision) Status() int
- func (d Decision) String() string
- func (d Decision) Target() types.Target
- func (d Decision) Verdict() Verdict
- type Diagnostic
- type Explanation
- func (e Explanation) Confidence() types.Confidence
- func (e Explanation) Interpretation() string
- func (e Explanation) Key() string
- func (e Explanation) MatchedBytes() []byte
- func (e Explanation) MatchedSpan() (types.Span, bool)
- func (e Explanation) Message() string
- func (e Explanation) NarrowestException() (rules.Exception, bool)
- func (e Explanation) Reason() Reason
- func (e Explanation) RuleID() types.RuleID
- func (e Explanation) RulesEvaluated() int
- func (e Explanation) Score() int
- func (e Explanation) Severity() types.Severity
- func (e Explanation) String() string
- func (e Explanation) Tags() []string
- func (e Explanation) Target() types.Target
- func (e Explanation) TransformChain() []string
- func (e Explanation) Verdict() Verdict
- type FailMode
- type Limits
- type Match
- type Mode
- type Option
- func OnDecision(fn func(Decision)) Option
- func WithBlockStatus(code int) Option
- func WithException(x rules.Exception) Option
- func WithExceptions(xs ...rules.Exception) Option
- func WithFailMode(f FailMode) Option
- func WithFuelLimit(f types.Fuel) Option
- func WithLimits(l Limits) Option
- func WithLogger(l *slog.Logger) Option
- func WithMinConfidence(c0 types.Confidence) Option
- func WithMode(m Mode) Option
- func WithOrigins(hosts ...string) Option
- func WithParanoiaLevel(pl int) Option
- func WithRuleset(set rules.Set) Option
- func WithSchema(s *schema.Schema) Option
- func WithThreshold(n int) Option
- func WithoutCoreRuleset() Option
- type Reason
- type Transaction
- func (tx *Transaction) AddArgument(name, value string)
- func (tx *Transaction) AddRequestHeader(name, value string)
- func (tx *Transaction) AddResolver(r rules.Resolver)
- func (tx *Transaction) AddResponseHeader(name, value string)
- func (tx *Transaction) BodyParseError() string
- func (tx *Transaction) Close()
- func (tx *Transaction) Decision() Decision
- func (tx *Transaction) FuelSpent() types.Fuel
- func (tx *Transaction) Matches() []Match
- func (tx *Transaction) ProcessRequestBody() Decision
- func (tx *Transaction) ProcessRequestHeaders() Decision
- func (tx *Transaction) ProcessResponseBody() Decision
- func (tx *Transaction) ProcessResponseHeaders() Decision
- func (tx *Transaction) RulesEvaluated() int
- func (tx *Transaction) Score() int
- func (tx *Transaction) SetRemoteAddr(addr string)
- func (tx *Transaction) SetRequestBody(b []byte)
- func (tx *Transaction) SetRequestLine(method, target, proto string)
- func (tx *Transaction) SetResponseStatus(status int)
- func (tx *Transaction) UndeclaredRoute() bool
- func (tx *Transaction) WriteResponseBody(chunk []byte) Decision
- type Verdict
- type WAF
- func (w *WAF) Compile(set rules.Set) (*rules.Ruleset, error)
- func (w *WAF) Diagnostics() []Diagnostic
- func (w *WAF) Limits() Limits
- func (w *WAF) Mode() Mode
- func (w *WAF) NewTransaction() *Transaction
- func (w *WAF) Report() rules.Report
- func (w *WAF) Ruleset() *rules.Ruleset
- func (w *WAF) Schema() *schema.Schema
- func (w *WAF) SwapRuleset(rs *rules.Ruleset)
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Decision ¶
type Decision struct {
// contains filtered or unexported fields
}
Decision is the outcome of evaluating a phase.
It is a value type rather than a nillable pointer: callers write `if d.Blocked()` with no nil check and no type assertion. Every decision is explainable — it carries the rule that caused it, the matched bytes, and the score that produced it, because a block nobody can explain is a block that gets disabled.
func (Decision) Confidence ¶
func (d Decision) Confidence() types.Confidence
Confidence returns the responsible rule's confidence tier.
func (Decision) Detail ¶
Detail returns extra context for decisions that have no responsible rule, such as why the input was undecidable. It is empty otherwise.
func (Decision) Explain ¶
func (d Decision) Explain() Explanation
Explain returns the full anatomy of this decision.
Safe to call on any Decision, including one that allowed the request: the result then carries the verdict and the evaluation count with no rule.
Example ¶
Every block is explainable. Explain returns the matched span, the transform chain that produced it, and the narrowest exception that would suppress this exact finding without weakening the rule anywhere else.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
)
func main() {
waf, _ := gwaf.New()
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("GET", "/search", "HTTP/1.1")
tx.AddArgument("q", "<script>alert(1)</script>")
d := tx.ProcessRequestHeaders()
e := d.Explain()
fmt.Println("rule:", e.RuleID())
fmt.Println("matched:", string(e.MatchedBytes()))
if x, ok := e.NarrowestException(); ok {
fmt.Printf("exception: rule %d on %s:%s\n", x.RuleID, x.Target, x.Key)
}
}
Output: rule: 3010 matched: <script>alert(1)</script> exception: rule 3010 on ARGS:q
func (Decision) Interpretation ¶
Interpretation names the alternative decoding under which the match was found, or "none" when it matched the value exactly as sent.
A non-"none" value is the most useful line in the audit record for that request: it says the payload was invisible in the bytes on the wire and only appeared once the value was read the way the origin would read it.
func (Decision) LogValue ¶
LogValue implements slog.LogValuer, so a Decision logs as structured fields rather than as a formatted string.
slog.Warn("request blocked", "waf", d)
Every field an operator needs to triage is present, and the interpretation is included because a payload found only under an alternative decoding is the single most confusing thing to see in a log: the bytes on the wire look harmless, and without this field the firewall appears to have malfunctioned.
func (Decision) MatchedSpan ¶
MatchedSpan returns the byte range within the evaluated value that matched. The second result is false when the decision was not caused by a rule match.
func (Decision) RulesEvaluated ¶
RulesEvaluated returns how many operators actually ran.
func (Decision) Status ¶
Status returns the HTTP status to respond with when blocked. It is zero when the decision did not specify one and the caller's default applies.
func (Decision) String ¶
String returns a compact, greppable summary.
Deliberately one line and stable in shape, because the first thing anyone does with a Decision is print it while working out why a request was blocked.
type Diagnostic ¶ added in v0.5.0
type Diagnostic struct {
// ID and Msg identify the rule as it appears in a Decision.
ID types.RuleID
Msg string
// Reason states what the rule cannot do, in one sentence.
Reason string
// Fix names the option or helper that closes it.
Fix string
}
Diagnostic reports a rule that compiled cleanly and will still not detect what its presence in the ruleset suggests it does.
This is not a compile error: every rule here is well-formed, and an embedder may have declined the missing capability on purpose. It is the gap between what a ruleset looks like it covers and what it covers, which is otherwise visible only by reading the engine.
The two cases it reports today are both ones gwaf shipped and could not see: an off-origin rule with no origins to compare against, and an argument rule with no body-phase counterpart, which inspects the query string while the payload arrives in JSON.
type Explanation ¶
type Explanation struct {
// contains filtered or unexported fields
}
Explanation is the full anatomy of one decision, as data.
gwaf ships no UI and never will, but "no UI" is not a licence to withhold information (CLAUDE.md §2b). Everything a control plane would want to draw — what fired, which bytes matched, how the input was normalised to get there, and the narrowest exception that would have allowed it — has to be reachable programmatically, or the missing accessor is a tier-1 API gap.
Why this hangs off Decision rather than off WAF ¶
docs/INTEGRATION.md used to describe `waf.Explain(txID)`, and that API cannot exist: looking a transaction up by ID means the WAF remembers transactions, which is cross-request state and the first of the five ownership tests. gwaf analyses one request in isolation and keeps nothing. So the explanation travels with the decision the caller already holds.
An Explanation borrows nothing from the transaction arena — MatchedBytes is copied — so it stays valid after the transaction is closed and can be handed to an audit sink or a queue.
func (Explanation) Confidence ¶
func (e Explanation) Confidence() types.Confidence
Confidence is the rule's declared confidence tier.
func (Explanation) Interpretation ¶
func (e Explanation) Interpretation() string
Interpretation names the decoding under which the match was found, or "none" when the value matched as sent. A finding visible only under an alternative reading is the most confusing kind to meet in a log.
func (Explanation) Key ¶
func (e Explanation) Key() string
Key is the specific value's name — a header name, an argument name, a JSON field path — or empty for unkeyed collections.
func (Explanation) MatchedBytes ¶
func (e Explanation) MatchedBytes() []byte
MatchedBytes is a copy of the bytes that matched.
Copied rather than borrowed: the transaction arena is recycled on Close, and an explanation that dangles into a reused buffer is worse than no explanation at all — it reports a different request's data with total confidence.
func (Explanation) MatchedSpan ¶
func (e Explanation) MatchedSpan() (types.Span, bool)
MatchedSpan is the byte range within the transformed value that matched, and whether there was one.
func (Explanation) Message ¶
func (e Explanation) Message() string
Message is the rule's human-readable description.
func (Explanation) NarrowestException ¶
func (e Explanation) NarrowestException() (rules.Exception, bool)
NarrowestException returns the tightest exception that would have allowed this request, and whether one exists.
"Tightest" means every field the finding pins down is pinned down: the rule, the request path, the collection, and the specific key. Suppressing that exception silences this finding and nothing else — not the same rule on another route, not another argument on the same route.
This is the whole point of computing it rather than leaving it to an operator. Under time pressure the exception a human writes is `{RuleID: 7002}`, because it is the one they can be sure will work, and it disables the rule everywhere. Handing back the narrow form makes the correct fix the cheap one.
It is a suggestion, not an endorsement. CLAUDE.md §6 prefers deleting a rule over excepting it, and a rule needing exceptions on many routes is a rule that is wrong rather than a rule that needs tuning.
func (Explanation) RuleID ¶
func (e Explanation) RuleID() types.RuleID
RuleID is the rule that produced the decision, or zero if none did.
func (Explanation) RulesEvaluated ¶
func (e Explanation) RulesEvaluated() int
RulesEvaluated is how many rules actually ran.
func (Explanation) Score ¶
func (e Explanation) Score() int
Score is the accumulated anomaly score at the point of decision.
func (Explanation) Severity ¶
func (e Explanation) Severity() types.Severity
Severity is the rule's declared severity.
func (Explanation) String ¶
func (e Explanation) String() string
String renders the explanation as an operator would want to read it.
func (Explanation) Tags ¶
func (e Explanation) Tags() []string
Tags are the rule's classification tags.
func (Explanation) Target ¶
func (e Explanation) Target() types.Target
Target is the collection the matched value came from.
func (Explanation) TransformChain ¶
func (e Explanation) TransformChain() []string
TransformChain is the normalization applied before the operator ran, in order. This is the step operators most often need and most often cannot get: a payload that looks harmless on the wire matched because it was decoded, and without the chain the finding reads as a malfunction.
func (Explanation) Verdict ¶
func (e Explanation) Verdict() Verdict
Verdict is what gwaf recommends.
type FailMode ¶
type FailMode uint8
FailMode selects what happens when gwaf cannot complete its analysis — budget exhaustion, a limit breach, or an internal error.
There is no safe default here, which is why the embedder must own it: availability versus security is a deployment decision, not a library one.
const ( // FailClosed rejects requests that could not be fully analysed. Correct // when a missed attack is worse than a dropped request. FailClosed FailMode = iota // FailOpen permits requests that could not be fully analysed. Correct when // availability dominates. It is loud: every occurrence emits a decision // with ReasonBudget or ReasonLimit. FailOpen )
type Limits ¶
type Limits struct {
// MaxBodySize is the largest request body inspected, in bytes.
MaxBodySize int
// MaxArgs is the largest number of arguments inspected.
MaxArgs int
// MaxHeaders is the largest number of headers inspected.
MaxHeaders int
// MaxValueLen is the largest single value inspected, in bytes.
//
// Exceeding it is a decision, never a truncation: a value too large to
// inspect is not a value shown to be clean. Set it large enough for the
// traffic you actually serve — base64 file content in a JSON or protobuf
// field, and long bearer tokens in query parameters, since browsers cannot
// set headers on WebSocket or EventSource connections.
MaxValueLen int
// MaxArenaSize bounds per-transaction working memory, in bytes.
MaxArenaSize int
}
Limits bound the input gwaf will analyse.
These are enforced before parsing, as a cheap pre-check. They are both a denial-of-service defence and a latency guarantee: they bound worst-case work per request independently of ruleset size.
Exceeding a limit is a decision, never a truncation. Half-inspecting an oversized body is indistinguishable from a bypass, so the request is rejected (or allowed, per FailMode) rather than partially analysed.
func DefaultLimits ¶
func DefaultLimits() Limits
DefaultLimits are sized for typical API traffic while keeping the worst case well inside the memory SLO in CLAUDE.md §2.
type Match ¶
type Match struct {
RuleID types.RuleID
Msg string
Severity types.Severity
Confidence types.Confidence
Target types.Target
Key string
Span types.Span
// Interpretation names the alternative decoding that revealed the payload,
// or "none" when it matched the bytes as sent.
Interpretation string
// Score is this match's contribution to the anomaly total.
Score int
}
Match is one rule that fired during a transaction.
Decisions report the rule responsible for the outcome; this reports every rule that matched, including scoring rules that did not block on their own. Calibration needs the full set — a rule's false-positive rate is how often it matches benign traffic, whether or not that match decided anything — and so does any control plane explaining a decision to an operator.
type Mode ¶
type Mode uint8
Mode selects whether decisions are enforced.
const ( // Blocking enforces decisions. This is the default: the core ruleset ships // only Certain and High confidence rules, which is what makes blocking by // default defensible. A WAF that silently protects nothing is worse than no // WAF, because the operator believes they are covered. Blocking Mode = iota // DetectionOnly evaluates rules and reports decisions without enforcing // them. It is the rollout path, not the destination. DetectionOnly )
type Option ¶
type Option func(*config)
Option configures a WAF. Options are applied in order; later ones win.
func OnDecision ¶
OnDecision registers a callback invoked for every terminal decision. It runs on the request path, so it must not block.
func WithBlockStatus ¶
WithBlockStatus sets the HTTP status reported for blocked requests when a rule does not specify one.
func WithException ¶
WithException suppresses one rule in one place.
Exceptions are conjunctive and the narrow form is the short one: every field set must match, every field left zero matches anything. Decision.Explain() computes the tightest exception that would have allowed a request it blocked, so the correct fix is the one an operator can copy rather than derive.
waf, err := gwaf.New(gwaf.WithException(rules.Exception{
RuleID: 7002,
Path: "/api/v1/query",
Key: "filter[$gt]",
Note: "this endpoint publishes Mongo operators as its filter DSL",
}))
An exception with nothing set is refused rather than honoured: it would disable every rule everywhere, and a configuration that does that by accident should not be expressible.
CLAUDE.md §6 prefers deleting a rule over excepting it. A rule needing exceptions on many routes is a rule that is wrong, not one that needs tuning.
Example ¶
An exception suppresses one finding on one route without weakening the rule anywhere else. Prefer this to deleting a rule: the narrow form is the short one, and Explain hands you exactly this struct.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
"github.com/gsoultan/gwaf/rules"
"github.com/gsoultan/gwaf/types"
)
func main() {
waf, _ := gwaf.New(gwaf.WithException(rules.Exception{
RuleID: 2010,
Path: "/admin/query-console",
Target: types.TargetArgs,
Key: "sql",
}))
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("POST", "/admin/query-console", "HTTP/1.1")
tx.AddArgument("sql", "SELECT * FROM users WHERE id = 1 OR 1=1")
d := tx.ProcessRequestHeaders()
fmt.Println("blocked on the excepted route:", d.Blocked())
// The same payload anywhere else is still blocked.
tx2 := waf.NewTransaction()
defer tx2.Close()
tx2.SetRequestLine("GET", "/search", "HTTP/1.1")
tx2.AddArgument("sql", "SELECT * FROM users WHERE id = 1 OR 1=1")
fmt.Println("blocked elsewhere:", tx2.ProcessRequestHeaders().Blocked())
}
Output: blocked on the excepted route: false blocked elsewhere: true
func WithExceptions ¶ added in v0.4.0
WithExceptions applies several exceptions at once.
It exists for ruleset/profiles, which returns a platform's whole set:
waf, err := gwaf.New(gwaf.WithExceptions(profiles.WordPress()...))
Each is validated exactly as WithException validates one, so a malformed entry in a profile fails at construction rather than silently suppressing everything.
Example ¶
ExampleWithExceptions applies a platform profile.
Some rules are correct in general and wrong for one field of one application. A WordPress comment carrying "<?php echo $name; ?>" really is PHP; it is benign because of where it lands -- a field that is stored and displayed, never executed -- and that is knowledge the application has and gwaf does not. Scoping costs almost nothing: the same payload one field over, or one route over, still blocks.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
"github.com/gsoultan/gwaf/ruleset/profiles"
)
func main() {
waf, err := gwaf.New(gwaf.WithExceptions(profiles.WordPress()...))
if err != nil {
panic(err)
}
post := func(path, field, value string) string {
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("POST", path, "HTTP/1.1")
tx.SetRemoteAddr("192.0.2.1")
tx.AddRequestHeader("Content-Type", "application/x-www-form-urlencoded")
tx.AddArgument(field, value)
if d := tx.ProcessRequestHeaders(); d.Blocked() {
return "blocked"
}
if d := tx.ProcessRequestBody(); d.Blocked() {
return "blocked"
}
return "allowed"
}
const php = "In PHP you write <?php echo $name; ?> to print a variable"
fmt.Println("comment field:", post("/wp-comments-post.php", "comment", php))
fmt.Println("author field: ", post("/wp-comments-post.php", "author", php))
}
Output: comment field: allowed author field: blocked
func WithFailMode ¶
WithFailMode selects what happens when analysis cannot complete.
func WithFuelLimit ¶
WithFuelLimit sets the per-transaction work ceiling. A non-positive value disables metering, which is intended for offline tooling — calibration, corpus replay — and must not be used for serving traffic.
func WithLimits ¶
WithLimits sets input limits. Zero-valued fields fall back to the defaults, so a caller can override one limit without restating the rest.
func WithLogger ¶
WithLogger sets the logger. The library never constructs a global logger and never writes to one it was not given.
func WithMinConfidence ¶
func WithMinConfidence(c0 types.Confidence) Option
WithMinConfidence sets the least-trustworthy rule tier that will be evaluated. It replaces the global paranoia-level dial: "only run rules at least this trustworthy" is a statement that can be defined precisely, where "paranoia level 3" cannot. See docs/RULES.md §8.
func WithMode ¶
WithMode selects blocking or detection-only.
Example ¶
Detection-only is the rollout path: rules are evaluated and reported, and nothing is blocked. Run it against real traffic, read the decisions, then switch to blocking once the log is clean.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
)
func main() {
waf, _ := gwaf.New(gwaf.WithMode(gwaf.DetectionOnly))
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("GET", "/search", "HTTP/1.1")
tx.AddArgument("q", "1' OR 1=1--")
d := tx.ProcessRequestHeaders()
fmt.Println("blocked:", d.Blocked())
fmt.Println("would have matched rule:", d.RuleID())
}
Output: blocked: false would have matched rule: 2010
func WithOrigins ¶ added in v0.4.1
WithOrigins declares the hostnames this application answers on.
Rules that ask whether a destination points somewhere else need something trustworthy to compare against, and the request is not it: an attacker supplies the Host header as freely as the destination, so comparing the two concludes same-origin whenever they are set to match. That was a live bypass in v0.4.0.
waf, err := gwaf.New(gwaf.WithOrigins("shop.example.com", "auth.example.com"))
Subdomains of a declared origin are accepted, so "shop.example.com" covers "www.shop.example.com". Ports are ignored.
Without it, the off-origin redirect and SSRF rules cannot establish that any destination is foreign and report nothing. That is deliberate: silently trusting the Host header would give a guarantee the request can revoke.
Example ¶
ExampleWithOrigins shows why an open redirect needs configuration to detect.
"Is this destination somewhere else?" cannot be answered from the request alone, because the request supplies both sides: an attacker who sets Host to match their own destination makes any comparison against it conclude same-origin. Declaring the hostnames the application answers on is what makes the question answerable, and it is the difference between catching an open redirect and blocking a legitimate OAuth callback.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
)
func main() {
waf, err := gwaf.New(gwaf.WithOrigins("shop.example.com"))
if err != nil {
panic(err)
}
check := func(destination string) string {
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("GET", "/login?redirect_to="+destination, "HTTP/1.1")
tx.SetRemoteAddr("192.0.2.1")
// Attacker-chosen, and deliberately not what the rule trusts.
tx.AddRequestHeader("Host", "evil.tld")
if d := tx.ProcessRequestHeaders(); d.Blocked() {
return "blocked"
}
return "allowed"
}
fmt.Println("own site: ", check("https://shop.example.com/cart"))
fmt.Println("elsewhere:", check("https://evil.tld/phish"))
}
Output: own site: allowed elsewhere: blocked
func WithParanoiaLevel ¶
WithParanoiaLevel maps a CRS paranoia level (1-4) onto a minimum confidence, so existing CRS configuration and operator knowledge keep working.
func WithRuleset ¶
WithRuleset adds rules. It may be called more than once; sets accumulate.
Example ¶
Rules are Go values, so a typo in a target name is a build failure rather than a rule that silently never fires at three in the morning.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
"github.com/gsoultan/gwaf/rules"
"github.com/gsoultan/gwaf/rules/op"
"github.com/gsoultan/gwaf/types"
)
func main() {
waf, err := gwaf.New(gwaf.WithRuleset(rules.Set{{
ID: 1_000_001,
Phase: types.PhaseRequestHeaders,
Targets: []types.Target{{Kind: types.TargetRequestPath}},
Op: op.HasPrefix("/internal/"),
Actions: []rules.Action{rules.Block},
Severity: types.SeverityCritical,
Confidence: types.Certain,
Msg: "internal-only path reached from outside",
}}))
if err != nil {
panic(err)
}
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("GET", "/internal/metrics", "HTTP/1.1")
d := tx.ProcessRequestHeaders()
fmt.Println("blocked by:", d.RuleID())
}
Output: blocked by: 1000001
func WithSchema ¶
WithSchema supplies an API description.
The schema is both a validator and a compiler input. Requests outside it are rejected before any rule runs, and values inside it that validate as a constrained type — an integer, a UUID, a declared enum — skip rule evaluation entirely, because such a value provably cannot carry a payload.
The better an API is specified, the faster and the more precise gwaf gets. See docs/CONCEPT.md §6 and §9.
Example ¶
Describing the API is the highest-value thing an embedder can do. A field declared an integer that validates as one cannot contain "UNION SELECT", so those rules are skipped soundly rather than heuristically — the schema makes gwaf both faster and stricter at once.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
"github.com/gsoultan/gwaf/schema"
)
func main() {
api, err := schema.New(schema.Operation{
Method: "POST",
Path: "/api/v1/bets",
Strict: true,
Body: []schema.Field{
{Name: "stake", Kind: schema.KindNumber, Required: true,
Min: schema.Bound(0.01), Max: schema.Bound(10_000)},
{Name: "currency", Kind: schema.KindEnum, Enum: []string{"USD", "EUR"}},
},
})
if err != nil {
panic(err)
}
waf, _ := gwaf.New(gwaf.WithSchema(api))
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("POST", "/api/v1/bets", "HTTP/1.1")
tx.AddRequestHeader("Content-Type", "application/json")
tx.ProcessRequestHeaders()
tx.SetRequestBody([]byte(`{"stake":-5000,"currency":"USD"}`))
d := tx.ProcessRequestBody()
// A negative stake is a perfectly good number and no signature describes it.
fmt.Println("blocked:", d.Blocked())
fmt.Println("reason:", d.Reason())
}
Output: blocked: true reason: schema_violation
func WithThreshold ¶
WithThreshold sets the anomaly score at or above which a request is blocked.
func WithoutCoreRuleset ¶
func WithoutCoreRuleset() Option
WithoutCoreRuleset omits the first-party ruleset, leaving only rules the embedder supplies.
Use it when you are replacing detection wholesale — a migration running gwaf alongside another engine, or a test exercising specific rules. A WAF with no rules blocks nothing, so this is an explicit opt-out rather than a consequence of forgetting to configure something.
type Reason ¶
type Reason uint8
Reason explains why a Decision reached its verdict.
const ( // ReasonNoMatch means nothing matched. ReasonNoMatch Reason = iota // ReasonRule means a rule demanded a terminal outcome. ReasonRule // ReasonThreshold means the accumulated anomaly score crossed the policy // threshold. ReasonThreshold // ReasonBudget means the fuel budget was exhausted and the configured fail // mode decided the outcome. The ruleset was only partially evaluated, so // this verdict carries less information than the others — it says what the // deployment chose to do about not knowing, not that the request was clean. ReasonBudget // ReasonLimit means a hard input limit was exceeded before rules ran. ReasonLimit // ReasonSchema means the request fell outside the declared API description. // // This is positive security rather than signature matching: the request was // rejected for not being something the API accepts, without any claim about // what it was trying to do. ReasonSchema // ReasonDesync means the request's framing was ambiguous: Content-Length // and Transfer-Encoding disagreed, or one of them was malformed in a way // two parsers resolve differently. // // This is request smuggling, and it is the one reason FailOpen does not // soften: an ambiguously framed request is potentially two requests, the // second of which no firewall has seen. ReasonDesync // ReasonUndecidable means the input had more plausible interpretations than // gwaf will enumerate, so no verdict about it would be meaningful. // // This is deliberately distinct from ReasonNoMatch. A value too ambiguous // to analyse has not been shown to be clean, and reporting it as clean is // exactly the assumption that CVE-2026-21876 exploited. ReasonUndecidable )
type Transaction ¶
type Transaction struct {
// contains filtered or unexported fields
}
Transaction analyses one request.
A Transaction is owned by exactly one goroutine for its entire lifetime. It is not safe for concurrent use, unlike the WAF that produced it.
Phases run in order and each may terminate the transaction. Blocking at ProcessRequestHeaders means the body is never read from the client, never parsed, and never transformed — the cheapest rules run first by construction.
func (*Transaction) AddArgument ¶
func (tx *Transaction) AddArgument(name, value string)
AddArgument records one request argument.
func (*Transaction) AddRequestHeader ¶
func (tx *Transaction) AddRequestHeader(name, value string)
AddRequestHeader records one request header.
Headers beyond the configured limit are not silently dropped: the count is tracked and ProcessRequestHeaders reports a limit breach, because a request that was only partly inspected must not be reported as clean.
func (*Transaction) AddResolver ¶
func (tx *Transaction) AddResolver(r rules.Resolver)
AddResolver registers a source of embedder-supplied values for this request.
A resolver is how a signal gwaf deliberately does not compute reaches a rule: an IP reputation score, a JA4 fingerprint, a bot score, a tenant identifier. gwaf consumes the value; it never fetches, computes, or remembers one.
tx.AddResolver(myReputation{score: score, asn: asn})
Per transaction rather than per WAF, because a resolver almost always closes over data specific to one request, and a WAF is shared by every goroutine.
The resolver is called only if a rule in the phase reads its name, and at most once per phase. Do the work inside Resolve rather than before registering: skipping the call entirely is the point, since a signal is usually out of gwaf's scope because it is expensive.
Example ¶
A Resolver is how a signal gwaf deliberately does not compute — reputation, a bot score, a tenant — reaches a rule. gwaf consumes the score; it never maintains one, because that would be state across requests.
package main
import (
"fmt"
"iter"
"github.com/gsoultan/gwaf"
"github.com/gsoultan/gwaf/rules"
"github.com/gsoultan/gwaf/rules/op"
"github.com/gsoultan/gwaf/types"
)
func main() {
waf, _ := gwaf.New(gwaf.WithRuleset(rules.Set{{
ID: 1_000_002,
Phase: types.PhaseRequestHeaders,
Targets: []types.Target{{Kind: types.TargetResolved, Name: "reputation.score"}},
Op: op.Equals("hostile"),
Actions: []rules.Action{rules.Block},
Severity: types.SeverityCritical,
Confidence: types.Certain,
Msg: "request from a client the embedder scored hostile",
}}))
tx := waf.NewTransaction()
defer tx.Close()
tx.AddResolver(reputationResolver{score: "hostile"})
tx.SetRequestLine("GET", "/", "HTTP/1.1")
fmt.Println("blocked:", tx.ProcessRequestHeaders().Blocked())
}
// reputationResolver is the embedder's own store, seen from gwaf's side. The
// work happens inside Resolve and only when a rule in the phase actually reads
// the collection, so registering one costs nothing on requests that never
// consult it.
type reputationResolver struct{ score string }
func (reputationResolver) Name() string { return "reputation" }
func (r reputationResolver) Resolve() iter.Seq2[string, []byte] {
return func(yield func(string, []byte) bool) {
yield("score", []byte(r.score))
}
}
Output: blocked: true
func (*Transaction) AddResponseHeader ¶
func (tx *Transaction) AddResponseHeader(name, value string)
AddResponseHeader records one response header.
func (*Transaction) BodyParseError ¶
func (tx *Transaction) BodyParseError() string
BodyParseError returns why a structured body could not be parsed, or empty.
A body that fell back to whole-document inspection is still analysed, but less precisely — schema validation cannot apply to fields that were never extracted. Surfacing it lets an operator notice a client sending malformed JSON rather than discovering it as a coverage gap later.
func (*Transaction) Close ¶
func (tx *Transaction) Close()
Close returns the transaction to its pool. It is safe to call more than once.
func (*Transaction) Decision ¶
func (tx *Transaction) Decision() Decision
Decision returns the decision reached so far. Before any phase has produced a terminal outcome it reports an allowing decision.
func (*Transaction) FuelSpent ¶
func (tx *Transaction) FuelSpent() types.Fuel
FuelSpent returns the work consumed so far.
func (*Transaction) Matches ¶
func (tx *Transaction) Matches() []Match
Matches returns every rule that fired in the phase most recently evaluated.
The slice is owned by the transaction and is invalidated by the next phase or by Close. Callers that retain matches must copy them.
func (*Transaction) ProcessRequestBody ¶
func (tx *Transaction) ProcessRequestBody() Decision
ProcessRequestBody evaluates the request-body phase.
func (*Transaction) ProcessRequestHeaders ¶
func (tx *Transaction) ProcessRequestHeaders() Decision
ProcessRequestHeaders evaluates the request-headers phase.
func (*Transaction) ProcessResponseBody ¶
func (tx *Transaction) ProcessResponseBody() Decision
ProcessResponseBody evaluates the response-body phase over everything WriteResponseBody was given.
func (*Transaction) ProcessResponseHeaders ¶
func (tx *Transaction) ProcessResponseHeaders() Decision
ProcessResponseHeaders evaluates the response-headers phase.
Call it after the upstream status and headers are known and before the body is written, so a leak detectable from headers alone stops the response before any of it reaches the client.
func (*Transaction) RulesEvaluated ¶
func (tx *Transaction) RulesEvaluated() int
RulesEvaluated returns how many operators have run. On benign traffic this must be zero; it is the leading indicator that the prefilter is working.
Example ¶
Ordinary traffic costs almost nothing: the prefilter decides what to evaluate before any rule runs, so the count is a small constant that does not grow with the ruleset. Ten rules and ten thousand rules evaluate the same number.
The two requests below show the shape of it. A path with no attack vocabulary reaches zero rules; adding a query string makes exactly one rule a candidate, whatever the query says. That constant is the honest number — "zero rules on benign traffic" is true of the no-query case and rounds the other one down.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
)
func main() {
waf, _ := gwaf.New()
for _, target := range []string{"/products", "/products?id=42"} {
tx := waf.NewTransaction()
// SetRequestLine parses the query string itself, so query parameters
// need no AddArgument -- that is for values the caller has already
// decoded, such as form fields.
tx.SetRequestLine("GET", target, "HTTP/1.1")
tx.ProcessRequestHeaders()
fmt.Printf("%-18s rules evaluated: %d\n", target, tx.RulesEvaluated())
tx.Close()
}
}
Output: /products rules evaluated: 0 /products?id=42 rules evaluated: 1
func (*Transaction) Score ¶
func (tx *Transaction) Score() int
Score returns the accumulated anomaly score.
func (*Transaction) SetRemoteAddr ¶
func (tx *Transaction) SetRemoteAddr(addr string)
SetRemoteAddr records the client address.
func (*Transaction) SetRequestBody ¶
func (tx *Transaction) SetRequestBody(b []byte)
SetRequestBody records the request body.
A body whose Content-Type gwaf can structure is parsed into fields, and each field is recorded separately. That is both faster and more accurate than inspecting the document whole:
- Faster, because detectors run over leaf values rather than the entire document, and because a field the schema constrains can be skipped.
- More accurate, because a JSON string is not its bytes. `{"c":"\u003cscript\u003e"}` contains no angle bracket on the wire and the origin's parser hands the application `<script>`.
A body gwaf cannot structure — or one that fails to parse — is recorded whole instead. That is slower and less precise but never less safe, and a parse failure is surfaced at the phase boundary rather than silently ignored.
func (*Transaction) SetRequestLine ¶
func (tx *Transaction) SetRequestLine(method, target, proto string)
SetRequestLine records the method, target, and protocol.
func (*Transaction) SetResponseStatus ¶
func (tx *Transaction) SetResponseStatus(status int)
SetResponseStatus records the upstream status code.
func (*Transaction) UndeclaredRoute ¶ added in v0.2.0
func (tx *Transaction) UndeclaredRoute() bool
UndeclaredRoute reports whether a schema is configured and this request matched none of its operations — a *shadow endpoint*, in API-security terms.
Why this is only half the feature, on purpose ¶
"You cannot protect APIs you do not know about" is the most common gap in API security programmes, and the endpoints nobody documented are where it lives. Finding them needs two things: noticing that one request went somewhere undeclared, and remembering that across requests to produce a list.
gwaf does the first and refuses the second. Aggregating is memory, and memory is the embedder's by the first ownership test (CLAUDE.md §1) — a WAF that kept a running inventory would need eviction, cardinality limits, and persistence, which is a database growing inside a request filter. So this reports one bit about one request and the embedder counts:
if tx.UndeclaredRoute() {
inventory.Observe(r.Method, r.URL.Path) // the embedder's map, not gwaf's
}
Relationship to Schema.Closed ¶
They are the two ends of the same observation. A closed schema *rejects* an undeclared route; this *reports* one. Discovery is what you run first — in an open schema, to learn which endpoints exist — and closing the schema is what you do once the inventory is complete. Reporting works in both modes, so the signal does not disappear at the moment enforcement starts.
It returns false when no schema is configured, because without one every route is undeclared and the answer would be noise rather than a finding.
Example ¶
Discovering shadow endpoints: gwaf reports that one request went somewhere the schema does not describe, and the embedder keeps the inventory. Aggregating is memory, and memory belongs to the embedder.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
"github.com/gsoultan/gwaf/schema"
)
func main() {
api, _ := schema.New(schema.Operation{Method: "GET", Path: "/api/v1/orders", NoBody: true})
waf, _ := gwaf.New(gwaf.WithSchema(api))
inventory := map[string]int{}
for _, path := range []string{"/api/v1/orders", "/internal/debug/config"} {
tx := waf.NewTransaction()
tx.SetRequestLine("GET", path, "HTTP/1.1")
if tx.UndeclaredRoute() {
inventory[path]++
}
tx.Close()
}
fmt.Println("shadow endpoints found:", len(inventory))
fmt.Println("which:", inventory)
}
Output: shadow endpoints found: 1 which: map[/internal/debug/config:1]
func (*Transaction) WriteResponseBody ¶
func (tx *Transaction) WriteResponseBody(chunk []byte) Decision
WriteResponseBody hands gwaf a chunk of the response body.
It may be called repeatedly, which is how an embedder that streams feeds gwaf without buffering the whole response itself. Chunks accumulate into the transaction arena up to MaxBodySize; past that the response is reported as exceeding the inspection limit rather than being partly inspected and called clean.
The returned Decision is terminal only when a limit was breached. Content analysis happens in ProcessResponseBody, once the body is complete.
type WAF ¶
type WAF struct {
// contains filtered or unexported fields
}
WAF is a compiled, ready-to-use firewall.
A WAF is safe for concurrent use by any number of goroutines. A Transaction obtained from it is not: each is owned by exactly one goroutine for its lifetime. That distinction is the most common misuse of every WAF library, so it is stated on both types.
A WAF holds no global state. Any number of independent instances may coexist in one process with different rulesets, which is what makes multi-tenant embedding and parallel tests work.
func New ¶
New compiles a WAF from options.
With no options it returns a working, blocking WAF: safe defaults, sensible limits, and no configuration files to find. Adding rules is additive.
Example ¶
A working, blocking firewall in one line and no configuration.
The core ruleset ships Certain and High confidence rules only, which is what makes blocking safe by default: a WAF that ships in detection-only mode protects nothing while telling the operator they are covered.
package main
import (
"fmt"
"github.com/gsoultan/gwaf"
)
func main() {
waf, err := gwaf.New()
if err != nil {
panic(err)
}
tx := waf.NewTransaction()
defer tx.Close()
tx.SetRequestLine("GET", "/products?id=1", "HTTP/1.1")
tx.AddArgument("id", "1' OR 1=1--")
d := tx.ProcessRequestHeaders()
fmt.Println("blocked:", d.Blocked())
fmt.Println("rule:", d.RuleID())
}
Output: blocked: true rule: 2010
func (*WAF) Compile ¶
Compile builds a ruleset using this WAF's confidence policy, for later use with SwapRuleset.
func (*WAF) Diagnostics ¶ added in v0.5.0
func (w *WAF) Diagnostics() []Diagnostic
Diagnostics returns the rules that compiled but cannot detect what they appear to, in ruleset order. An empty result is the healthy answer.
New logs the first of these once. It is returned as data because a log line is not an API: a control plane building a coverage view has to be able to ask (CLAUDE.md 2b -- every datum a UI would need is reachable programmatically).
for _, d := range waf.Diagnostics() {
log.Warn("coverage gap", "rule", d.ID, "reason", d.Reason, "fix", d.Fix)
}
The result is computed once at construction from the ruleset New compiled. It does not follow SwapRuleset, which takes an already-compiled ruleset.
func (*WAF) Limits ¶ added in v0.5.0
Limits reports the configured bounds.
Exported so an integration can bound what it reads. A middleware buffering a request body has to know the ceiling the WAF will apply or it allocates without one: io.ReadAll on a client-controlled body spent 572 MiB reaching a verdict the engine reaches at 1 MiB, which makes the firewall the denial of service it exists to prevent (CLAUDE.md §2, invariant 3).
func (*WAF) NewTransaction ¶
func (w *WAF) NewTransaction() *Transaction
NewTransaction begins analysing one request.
The returned Transaction is owned by the calling goroutine and must be closed with Close, which returns its buffers to the pool. Failing to close leaks nothing permanently but forfeits the pooling that keeps steady-state allocation at zero.
func (*WAF) SwapRuleset ¶
SwapRuleset atomically replaces the active plan.
Compilation and swap are separate on purpose: a ruleset is validated off the request path and the swap itself cannot fail, so a bad ruleset never goes live. In-flight transactions complete against the plan they started with.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package audit turns a gwaf decision into a record something else can store.
|
Package audit turns a gwaf decision into a record something else can store. |
|
Package calibrate measures what a rule's confidence claim is actually worth.
|
Package calibrate measures what a rule's confidence claim is actually worth. |
|
cmd
|
|
|
gwaf
command
Command gwaf is the build-time toolchain.
|
Command gwaf is the build-time toolchain. |
|
detect
|
|
|
graphql
Package graphql detects abusive GraphQL documents by reading their shape.
|
Package graphql detects abusive GraphQL documents by reading their shape. |
|
javaser
Package javaser detects Java attacks by reading invocation structure rather than by listing class names.
|
Package javaser detects Java attacks by reading invocation structure rather than by listing class names. |
|
ldapi
Package ldapi detects LDAP injection by reading filter syntax rather than by matching payload strings.
|
Package ldapi detects LDAP injection by reading filter syntax rather than by matching payload strings. |
|
nosqli
Package nosqli detects NoSQL injection by reading where a name sits in a document rather than by matching strings in a value.
|
Package nosqli detects NoSQL injection by reading where a name sits in a document rather than by matching strings in a value. |
|
phpi
Package phpi detects PHP injection by reading language structure rather than by listing function names.
|
Package phpi detects PHP injection by reading language structure rather than by listing function names. |
|
promptinjection
Package promptinjection detects attempts to override an LLM's instructions from inside its input.
|
Package promptinjection detects attempts to override an LLM's instructions from inside its input. |
|
shelli
Package shelli detects command injection by reading shell structure rather than by matching command names.
|
Package shelli detects command injection by reading shell structure rather than by matching command names. |
|
sqli
Package sqli detects SQL injection by parsing structure rather than matching strings.
|
Package sqli detects SQL injection by parsing structure rather than matching strings. |
|
ssti
Package ssti detects server-side template injection by reading what sits inside a template expression, never by the presence of one.
|
Package ssti detects server-side template injection by reading what sits inside a template expression, never by the presence of one. |
|
xss
Package xss detects cross-site scripting by reading markup structure rather than matching strings.
|
Package xss detects cross-site scripting by reading markup structure rather than matching strings. |
|
internal
|
|
|
bitset
Package bitset provides a fixed-capacity bitset used to carry candidate rule sets from the prefilter to the evaluator.
|
Package bitset provides a fixed-capacity bitset used to carry candidate rule sets from the prefilter to the evaluator. |
|
body
Package body extracts individual fields from a request body.
|
Package body extracts individual fields from a request body. |
|
budget
Package budget meters the work a single transaction is allowed to perform.
|
Package budget meters the work a single transaction is allowed to perform. |
|
engine
Package engine evaluates a compiled ruleset against transaction data.
|
Package engine evaluates a compiled ruleset against transaction data. |
|
interpret
Package interpret enumerates the plausible readings of an ambiguous value.
|
Package interpret enumerates the plausible readings of an ambiguous value. |
|
memz
Package memz owns all allocation on the request hot path.
|
Package memz owns all allocation on the request hot path. |
|
prefilter
Package prefilter implements the multi-pattern automaton that decides which rules are worth evaluating for a given input.
|
Package prefilter implements the multi-pattern automaton that decides which rules are worth evaluating for a given input. |
|
scan
Package scan covers a whole value with bounded work.
|
Package scan covers a whole value with bounded work. |
|
middleware
module
|
|
|
Package rules defines how detection rules are authored, validated, and compiled into an executable plan.
|
Package rules defines how detection rules are authored, validated, and compiled into an executable plan. |
|
op
Package op provides the built-in operators.
|
Package op provides the built-in operators. |
|
op/rx
Package rx provides a regular-expression operator.
|
Package rx provides a regular-expression operator. |
|
transform
Package transform provides the built-in value normalizations.
|
Package transform provides the built-in value normalizations. |
|
ruleset
|
|
|
core
Package core provides the first-party ruleset loaded by gwaf.New.
|
Package core provides the first-party ruleset loaded by gwaf.New. |
|
profiles
Package profiles holds per-platform exception sets.
|
Package profiles holds per-platform exception sets. |
|
Package schema turns a description of an API into both a validator and a compiler input.
|
Package schema turns a description of an API into both a validator and a compiler input. |
|
seclang
module
|
|
|
Package telemetry counts what a WAF operator needs to see.
|
Package telemetry counts what a WAF operator needs to see. |