Workstation WSL Proxy (WSLProxy) runs WAF Engine v2 on the OpenResty request path: eight first-class stages, governed signatures, binding precedence, and explainable 403s with support IDs. This article is a faithful technical walkthrough of the engine design reference for a Workstation audience. Companion blog: WSLProxy WAF Engine v2 ยท Product WAF: wslproxy.org/waf ยท Overview: /wsl-proxy. A dedicated WSLProxy docs site comes later; until then the canonical engine markdown remains in the upstream repo.
Watch: WSL Proxy WAF tour
Watch: youtu.be/r10XSonA5JE ยท Product: wslproxy.org ยท WAF: wslproxy.org/waf ยท Engine doc: WAF_ENGINE_V2.md
- Status: Implemented MVP in
api/waf_engine.lua,api/waf_stages.lua,api/waf_support.lua. - Path: rate-limit โ WAF inspect (fail-open) โ stages โ governed signatures โ anomaly.
- Modes: blocking | transparent; route override > server override > policy default.
- Explain: policy, binding, stage, violation code, signature ID,
X-Support-ID. - Roadmap: MVP = done today; Phase 2 / Phase 3 = future. Fail-closed body parse is P2.
1. Goals and design constraints
Engine v2 aims to be a production WAF that platform and security teams can bind per domain, per service label, and per route; run in blocking or transparent mode; govern by stable signature IDs; and operate with structured logs plus correlation IDs โ not a regex snippet. Positioning is inspired by F5 WAF for NGINX (App Protect) goals. This article does not claim F5 feature parity.
| Constraint | Meaning |
|---|---|
| In the NGINX path | LuaJIT, per-worker compiled-regex cache (o flag on ngx.re.find), shared dicts for velocity counters. No blocking I/O on the hot path. |
| Fail-open by default | Any engine or stage error logs and allows the request. A WAF bug must never take the site down. Parse-error fail-closed on untrusted bodies is a policy option on the roadmap (Phase 2), not the default. |
| Backward compatible | A v1 policy (just waf_rules + mode) keeps working unchanged; every v2 field is optional and additive. |
| Explainable | Every block names the policy, the binding that won, the stage, the violation code, the signature ID, and a support ID. |
Machine-readable policy shape lives in upstream docs/waf-policy.schema.json. The narrative source for this article is docs/WAF_ENGINE_V2.md.
2. Request-path architecture
Like a solutions playbook: match the route once, inspect under a fail-open wrapper, then proxy or protect.
rewrite_by_lua gateway_ack.lua select route rule
โ
โผ
gateway_pipeline.execute()
โ Phase 2 rate-limit โ Phase 3 WAF โ โฆ
โผ
waf_engine.inspect(server_config, profile_id) โโ fail-open wrapper
โ
โผ
_inspect_impl:
load policy (30s TTL cache)
resolve effective mode + winning binding
โโโ STAGE PIPELINE (waf_stages.PIPELINE) โโโโโโโโโโโโโโโ
โ 1 method allow-list VIOL_METHOD โ
โ 2 filetype deny VIOL_FILETYPE โ
โ 3 smuggling / desync VIOL_SMUGGLING โ
โ 4 ip lists โ geo VIOL_IP_DENY / VIOL_GEO โ
โ 5 jwt alg policy VIOL_JWT_ALG โ
โ 6 json body profile VIOL_JSON_SIZE/DEPTH โ
โ 7 brute-force velocity VIOL_BRUTE_FORCE โ
โ 8 openapi positive-sec VIOL_OPENAPI_PATH/METHOD โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโ SIGNATURE MATCHING (governed) โโโโโโโโโโโโโโโโโโโโโโ
โ per rule: disabled? set-disabled? staged? โ
โ match target โ block | alarm โ
โ anomaly score โฅ threshold โ VIOL_ANOMALY_SCORE โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ finding
block_request(): 403 + X-WAF-Block + X-WAF-Rule +
X-WAF-Violation + X-Support-ID + block page
Each stage is a pure function (policy, ctx) โ finding|nil. The engine โ not the stage โ decides block vs alarm from the effective enforcement mode, so the same rule set behaves differently per binding without duplication.
- Request arrives at a POP โ OpenResty accepts TLS and enters the Lua hot path.
- Match a routing rule โ
gateway_ack.luaselects the live route. - Pipeline โ rate-limit, then
waf_engine.inspect, then response action / balancer. - Inside inspect โ load policy, resolve binding, stages, then signatures and anomaly.
- On block โ 403 with WAF headers, support ID, and optional branded block page.
Two implementation details matter for capacity planning. Policy load uses a short TTL cache (about thirty seconds) so hot traffic does not re-read disk or remote config on every request. Signature matching leans on LuaJIT and a per-worker compiled-regex cache; velocity counters for brute-force live in shared dicts. That is the concrete meaning of โin the NGINX pathโ: the WAF must finish without blocking I/O that would stall the worker.
Fail-open wrapping sits around the inspect implementation. If a stage throws or the engine hits an unexpected error path, the request is allowed and the failure is logged. That availability bias is a deliberate product choice for MVP. Teams that want fail-closed behaviour on untrusted body parse errors should track Phase 2 โ it is not the default today.
2b. Stage-by-stage behaviour (MVP)
Before signatures run, eight first-class stages walk the request. Order is fixed in waf_stages.PIPELINE. Understanding each stage is how you design policy packs without treating the WAF as a black box.
Method allow-list (VIOL_METHOD). Only permitted HTTP methods proceed. A payments API that only needs GET, POST, HEAD, and OPTIONS can deny TRACE, PUT, and other unexpected verbs before any regex work runs. This is cheap positive security for verb surface.
Filetype deny (VIOL_FILETYPE). Sensitive extensions such as .env, .sql, .bak, .git, and .pem are blocked when present on the path. This catches common reconnaissance and misconfiguration probes that never look like SQLi.
HTTP smuggling / desync (VIOL_SMUGGLING). The smuggling stage enforces CL/TE desync guards: Content-Length plus Transfer-Encoding coexistence, obfuscated or duplicate Transfer-Encoding, and malformed Content-Length. Chunked transfer can be allowed when allowChunked is true. A companion signature (waf-rule-smuggling-001) covers body-embedded request lines. Together they address a class of attacks that signature-only WAFs often miss because the abuse is in framing, not payload keywords.
IP lists and geo (VIOL_IP_DENY / VIOL_GEO). Allow and deny CIDRs plus deny-country lists via an IP2Location-style database path on the policy. This is complementary to routing-rule geo matches elsewhere on the POP: WAF stages record violation codes and support IDs; routing rules may redirect or choose a different upstream without a WAF finding.
JWT algorithm policy (VIOL_JWT_ALG). Deny weak algorithms (including none and HS256 where your threat model requires it) and optionally require stronger algorithms such as RS256 or ES256. MVP checks algorithm policy from the Authorization header configuration โ full JWKS signature verification is Phase 2 (future).
JSON body profile (VIOL_JSON_SIZE / VIOL_JSON_DEPTH). Cap JSON depth and body size before the request reaches the origin. Oversized or deeply nested JSON is a common parser-abuse vector; stopping it early also saves signature regex cycles.
Brute-force velocity (VIOL_BRUTE_FORCE). Per-path sliding windows (for example /api/login with a sixty-second window and a max attempt count) keyed by IP via shared dicts. Action can block when thresholds trip. This is not behavioural L7 DoS (that is Phase 3); it is explicit velocity control on declared paths.
OpenAPI positive security (VIOL_OPENAPI_PATH / VIOL_OPENAPI_METHOD). Allow only declared path+method surfaces, including path templating. Unknown surface never reaches the backend when the policy is wired that way. Parameter and type validation from a full OpenAPI spec is Phase 2; MVP is the path+method allow-list surface.
Positive security and negative signatures are meant to work together. OpenAPI path+method shrinks the attack surface; signatures catch known patterns on what remains. JSON profiles stop parser abuse before expensive matching. Smuggling guards protect the framing layer that regex on body text cannot see cleanly.
3. Policy schema v2 (annotated)
High-level fields: enforcementMode (blocking|transparent), service label, anomaly_threshold, waf_rules[], signatureSets, signatures.disable / signatures.stage, methods, filetypes, smuggling, geo, ipLists, jwt, jsonProfile, bruteForce, routeOverrides, logging, blocked_response, whitelist. Schema file: upstream docs/waf-policy.schema.json.
Payments-hardened example adapted from the engine doc:
{
"id": "waf-policy-payments-hard",
"name": "Payments API โ Hardened",
"schema_version": 2,
"enabled": true,
"enforcementMode": "blocking",
"service": "payments",
"anomaly_threshold": 6,
"waf_rules": ["waf-rule-sqli-001", "..."],
"signatureSets": [
{ "id": "SET_SQLI", "block": true, "alarm": true }
],
"signatures": {
"disable": ["waf-rule-xss-005"],
"stage": [
{ "id": "waf-rule-openredirect-001", "until": "2026-12-31T00:00:00Z" }
]
},
"methods": { "allow": ["GET","POST","HEAD","OPTIONS"] },
"filetypes": { "deny": [".env",".sql",".bak",".git",".pem"] },
"smuggling": { "enforce": true, "allowChunked": true },
"geo": { "denyCountries": ["KP"], "db": "/tmp/IP2LOCATION-LITE-DB11.IPV6.BIN" },
"ipLists": { "allow": ["10.0.0.0/8"], "deny": ["5.6.7.0/24"] },
"jwt": {
"header": "Authorization",
"denyAlg": ["none","HS256"],
"requireAlg": ["RS256","ES256"]
},
"jsonProfile": { "maxDepth": 8, "maxBytes": 16384 },
"bruteForce": [{
"path": "/api/login",
"windowSec": 60,
"maxAttempts": 5,
"action": "block",
"keyBy": ["ip"]
}],
"routeOverrides": [
{ "path": "/preview", "enforcementMode": "transparent" }
],
"logging": { "profile": "verbose", "destination": "syslog" },
"blocked_response": {
"status_code": 403,
"content_type": "text/html",
"body_base64": "โฆ{{support_id}}โฆ"
},
"whitelist": { "ips": ["127.0.0.1"], "paths": ["/health"], "user_agents": [] }
}
enforcementMode is the v2 name; transparent aliases monitor-style behaviour. service appears in logs and binding context as a logical app label. routeOverrides are how you keep marketing preview paths in alarm while payment routes block.
Read the example as an operable pack, not a template to paste blindly. anomaly_threshold: 6 only makes sense after you have watched score distributions in transparent mode. Disabling waf-rule-xss-005 is the escape hatch for a known false positive; staging open-redirect until a hard timestamp is the controlled promotion path. Whitelist paths such as /health keep probes and load balancers out of the security noise. The blocked_response body can embed {{support_id}} so the human who hit the block page carries the same ID your on-call will search for.
Backward compatibility matters during migration. A v1 policy that only sets waf_rules and mode continues to work. You can add schema_version: 2 fields incrementally โ route overrides first, then smuggling, then OpenAPI โ without rewriting every virtual host on day one.
4. Signature schema โ stable IDs as the addressable unit
{
"id": "waf-rule-ssti-001",
"name": "SSTI โ Template Expression Injection",
"category": "ssti",
"signature_set": "SET_SSTI",
"severity": "high",
"target": "all",
"pattern": "(?:\\{\\{[^}]{0,120}?\\}\\}|โฆ)",
"pattern_type": "regex",
"action": "block",
"score": 8,
"tags": ["api-security","ssti"],
"references": ["CWE-1336","OWASP-A03"]
}
The stable id is what you disable, stage, or cite in tickets. Category maps to a default set id SET_<CATEGORY> unless signature_set is set explicitly. Target is one of url, args, body, headers, cookies, user_agent, or all. Pattern type is regex or string. Action is block or monitor (alarm-only). Score contributes to the anomaly total when enforcing. references (CWE/OWASP) are present on the schema; rendering them in events is Phase 2 (future).
Treating the signature ID as the addressable unit is what makes governance workable for humans and agents. You do not ask on-call to โturn off the SSTI regex somewhere in the pack.โ You ask them to stage waf-rule-ssti-001 until a timestamp, or to set SET_SSTI to block:false while you investigate. CI validation (tools/waf_validate.py) checks referential integrity so policy packs cannot point at missing signature IDs.
Do not invent Lua APIs or custom request headers beyond what the engine doc specifies. Operators should rely on the published response headers and wafsec fields. Pattern text in examples is illustrative; production signatures live in the upstream rule set and Admin UI, not in this articleโs HTML.
5. Binding resolution
Precedence, most specific wins:
route override > per-server (waf_mode_override) > policy default (enforcementMode)
mode = normalize(policy.enforcementMode or policy.mode) -- "block" | "monitor"
binding = "domain"
if server.waf_mode_override then
mode = server.waf_mode_override
binding = "server"
end
best = -1
for ro in policy.routeOverrides:
if methodMatches(ro, ctx.method)
and ctx.path startswith ro.path
and len(ro.path) > best then
best = len(ro.path)
mode = normalize(ro.enforcementMode or ro.mode)
binding = "route:" .. ro.path
end
return mode, binding
binding is recorded on every finding, so a log line answers which binding won. Longest-prefix wins makes /api/admin beat /api. A service is a logical label (server.waf_service or policy.service) that rides along in logs; service-level policy selection (a service โ policy map) is Phase 2 (future).
| Mode | Behaviour |
|---|---|
| Blocking | Violations return 403 with support ID and optional branded block page. |
| Transparent / monitor | Violations alarm and log only โ dry-run and staging. |
Binding is the property the engine brief calls out as critical for explainability. Without it, two hosts sharing a policy pack look identical in logs even when one server override flipped a path to transparent. With it, a single wafsec line tells you whether domain default, server override, or a specific route prefix won. That is what turns a 403 into an operable incident.
Practical pattern: keep the policy default in blocking for a hardened API host; add a transparent route override for /preview or a canary path; use a server-level override only when an entire virtual host must dry-run. Prefer route overrides for surgical changes โ they are the most specific and the easiest to reason about in tickets.
6. Signature governance and anomaly score
Three independent controls, evaluated per rule during matching:
| Control | Source | Effect |
|---|---|---|
| disable | signatures.disable: [id] | Rule skipped entirely. |
| stage | signatures.stage: [{id, until}] | Rule alarms only until the timestamp, then enforces โ the safe-rollout path. |
| set toggle | signatureSets: [{id, block}] | block:false downgrades a whole set to alarm-only. |
A staged or set-disabled signature alarms and does not contribute to the anomaly score, so staging can never cause a block indirectly. A ruleโs set is signature_set or SET_<CATEGORY>. When cumulative score of enforcing matches meets or exceeds anomaly_threshold, the engine raises VIOL_ANOMALY_SCORE.
That anomaly rule is easy to get wrong if staging still scored. Engine v2 forbids the foot-gun: staged and set-disabled matches alarm for visibility but leave the cumulative score alone. You can therefore soak a noisy set in production transparent bindings, or stage individual IDs under a blocking policy, without discovering later that โmonitor noiseโ quietly crossed the threshold.
Recommended promotion sequence for a new signature or set:
- Add or enable the signature with
stageuntil a known timestamp, or with setblock:false. - Watch
waf_monitoredandwafseclines for false positives on real traffic. - Disable individual IDs that are clearly wrong; keep the rest.
- Remove the stage window or flip the set to
block:trueon the highest-value routes first. - Only then lower or enable
anomaly_thresholdif your score model needs it.
7. Observability
- Correlation ID โ every block/alarm gets
WSL-<epoch>-<rand>, echoed asX-Support-IDand rendered into the block page ({{support_id}}). - Structured security log โ one JSON line per decision, tagged
wafsecfor syslog/OTel shipping:support_id,action,code,stage,signature_id,signature_set,category,severity,policy,service,binding,host,client_ip,method,uri,latency_us. Also retained in thewaf_eventsshared dict for the recent-events API. - Metrics โ Prometheus counters
waf_blocked,waf_monitored,waf_inspections,waf_latency,waf_errorsby host/category/severity. - Response headers โ
X-WAF-Block,X-WAF-Rule,X-WAF-Violation,X-Support-ID.
When a customer or internal user pastes a support ID into a ticket, look that ID up in events/logs. Do not guess which anonymous rule fired on another appliance.
On-call checklist for a WAF block:
- Collect
X-Support-ID(andX-WAF-Violation/X-WAF-Ruleif present). - Query recent events or
wafsecfor that support ID. - Confirm
policy,binding,stage,signature_id, andservice. - Decide: false positive โ disable or stage the ID; true positive โ keep blocking and fix the client or upstream.
- If the block was unexpected on a path that should be transparent, inspect route overrides and server
waf_mode_overridebefore changing signatures.
Prometheus series give you the fleet view: rising waf_errors means investigate fail-open paths and stage health; rising waf_blocked without a deploy may mean an attack or a bad promotion; waf_latency tells you whether inspection cost is acceptable on the hot path.
8. Violation code catalogue
| Code | Stage |
|---|---|
VIOL_METHOD | Method allow-list |
VIOL_FILETYPE | Filetype deny |
VIOL_SMUGGLING | HTTP request-smuggling / desync guard (CL+TE, obfuscated/duplicate Transfer-Encoding, malformed Content-Length) |
VIOL_IP_DENY / VIOL_GEO | IP lists / geo |
VIOL_JWT_ALG | JWT algorithm policy |
VIOL_JSON_SIZE / VIOL_JSON_DEPTH | JSON body profile |
VIOL_BRUTE_FORCE | Velocity control |
VIOL_OPENAPI_PATH / VIOL_OPENAPI_METHOD | OpenAPI positive security |
VIOL_ATTACK_SIGNATURE | Signature match |
VIOL_ANOMALY_SCORE | Cumulative score โฅ threshold |
9. MVP checklist (shipped) vs Phase 2 / Phase 3 (future)
MVP โ done today
- Policy bind per domain + per route
- Signature sets / per-ID enable-disable-stage
- Method & filetype allow/deny
- IP allow-deny + geo country deny
- JWT algorithm policy
- JSON body depth/size profile
- Brute-force velocity
- OpenAPI positive security (declared path+method allow-list, path templating)
- HTTP request-smuggling / desync guard (
smugglingstage โVIOL_SMUGGLING, pluswaf-rule-smuggling-001for body-embedded request lines) - Structured security log + support IDs
- Prometheus metrics
- Block page with support ID
- Golden tests (
examples/wslproxy-waf-demo/waf_features.py) - CI validation (
tools/waf_validate.py+waf-validateworkflow: Lua syntax + JSON Schema policy validation + signature referential integrity) - Admin UI (react-admin WafPolicies/WafRules forms cover every v2 field)
Phase 2 โ future
- Service โ policy binding map (logical app selection), not just a label
- OpenAPI parameter/type validation from a full spec (today: path+method surface)
- XML profile (DTD/entity off, depth/size) as a first-class stage (today XXE is caught by signatures)
- GraphQL depth/batch/introspection profile (today introspection is a signature)
- Cookie integrity / attribute enforcement; JWT JWKS signature verify
- Response Data Guard (PAN/SSN masking in the body_filter phase)
references(CWE/OWASP) rendered in logs and the events API- Configurable fail-closed on body parse errors in blocking mode
- Bot classes beyond UA (header/JA3 signals); threat-campaign pack channel
aegisctl-style CLI: compile | validate | test | bench; policy unit tests
Phase 3 โ future
- Behavioural L7 DoS (token bucket + per-object anomaly)
- IP-reputation feed adapter; MaxMind pluggable geo
- gRPC/protobuf malformed detection
- Ingress CRDs (
WAFPolicy,WAFBinding) + GitOps policy validation in CI - Hyperscan/Vectorscan matching backend when present (FFI), Aho-Corasick fallback
Do not blur Phase 2 or Phase 3 with the MVP. If a feature is not in the MVP list above, treat it as future unless your deployed build documents otherwise.
Phase 2 is mostly about deeper profiles and stronger identity on the same architecture: serviceโpolicy maps, richer OpenAPI, XML/GraphQL profiles, JWKS, Data Guard, fail-closed parse options, and better packaging for threat packs and CLI workflows. Phase 3 is mostly about heavier detection backends and Kubernetes-native policy objects. Neither phase is a licence to claim those capabilities on the Workstation site today.
Golden tests in examples/wslproxy-waf-demo/waf_features.py exist specifically to prove per-binding actions for the MVP surface. Prefer those fixtures over inventing catch-rate metrics. This article does not publish efficacy percentages, customer names, or ship dates for Phase 2 or Phase 3.
10. Non-goals (v1)
- Full RASP / in-process application instrumentation
- Copying proprietary signature databases
- In-path ML training
Heuristics and signatures first. That keeps the hot path honest and the explainability model tractable. Full RASP would move instrumentation into application runtimes โ a different product class. Cloning proprietary signature databases creates legal and operational risk without improving explainability. In-path ML training would violate the โno blocking I/O / keep the worker fastโ constraint that shaped Engine v2.
If your requirement is โtrain a model on live traffic inside the reverse proxy,โ Engine v2 is the wrong tool. If your requirement is โbind an explainable policy pack per route on OpenResty with support IDs,โ it is the right one.
11. How to operate today
Brief pointers only โ full runbooks belong with the product and future docs site:
- Admin UI โ WafPolicies / WafRules forms cover v2 fields: bind to servers, override mode, review events.
- CI validate โ run upstream
tools/waf_validate.py(Lua syntax, JSON Schema policies, signature referential integrity) rather than hand-editing production JSON on a live POP. - Golden tests โ
examples/wslproxy-waf-demo/waf_features.pyexercises per-binding actions where you run the demo pack. - Rollout practice โ bind in transparent/monitor first; stage noisy signatures; promote route by route; enable anomaly threshold only after you understand score distributions in monitor.
- Control plane siblings โ Swagger REST, MCP tools, and
wslproxy-cliforwaf_rules/waf_policiesremain part of the operable edge story described on the product page.
Fail-open remains the default for engine and stage errors. That is intentional availability bias.
Layered controls outside the WAF engine still matter on the same POP: per-server rate limiting before expensive origin work, CAPTCHA challenges on routing rules, and JWT/S3/cookie matches that deny before the backend sees the request. Design WAF packs with those siblings in mind. A signature-only mindset recreates the โregex snippetโ failure mode Engine v2 was built to replace. For the broader edge narrative โ gateway, CDN, and WAF together โ see the earlier Workstation WAF article and the product page; this piece stays focused on the engine design reference.
12. Related reading
- Companion blog: WSLProxy WAF Engine v2: Enterprise Enforcement You Can Explain
- Broader WAF narrative: WSL Proxy WAF ยท Explainable edge WAF article
- Product page: Workstation WSL Proxy
- Public WAF feature page: wslproxy.org/waf
- Engine design reference (GitHub): docs/WAF_ENGINE_V2.md
- Source: github.com/bwalia/wslproxy
13. Closing
Edge WAF earns trust when operators can explain every block, dry-run every change, and promote policy without waiting for a full POP reload. Workstation WSL Proxy WAF Engine v2 puts that workflow on the OpenResty hot path: stages first, governed signatures second, binding precedence you can log, and support IDs on the wire.
Docs website deferred โ this article points at GitHub docs/WAF_ENGINE_V2.md and wslproxy.org/waf. Watch the tour, start in transparent mode, and promote to block when your events stream says the policy is ready. Until the dedicated docs site exists, treat this Workstation article plus the upstream markdown as the public deep dive for Engine v2.
Published by Workstation.
Continue on the product site: https://wslproxy.org/waf/
