Skip to content

LOWAAF — Lua OpenResty Web Application and API Firewall

LOWAAF is a lightweight, high-performance web application and API firewall framework that runs inside OpenResty (the Nginx/Lua platform). It sits in front of web applications as a reverse proxy and enforces a strict, declarative security policy on every incoming HTTP request — validating the method, URI, headers, and request body before the request ever reaches the application.

Core technologies: OpenResty, Nginx, Lua, LuaJIT, HTTP, JSON validation

Engineering focus: defensive security, policy-as-code, request validation, reverse proxy architecture

Repository: github.com/helamonster/lowaaf — source, policies, and full documentation


The Positive Security Model

LOWAAF is built on a Positive Security Model: every request is denied by default. A request is only permitted if it explicitly matches a declared route and passes all configured validation checks. There is no blacklist, no signature database, and no pattern matching against known exploits.

Rather than asking "does this request look malicious?", it asks "is this request something my application should ever receive?" Every route, method, header, and request body field is explicitly declared in a policy file. If a request doesn't match the declared policy, it is denied — regardless of whether it looks like an attack.

This aligns with Zero Trust principles: no request is trusted by default, and every request must prove it belongs before it is forwarded to the application.

Why Not a Blacklist WAF?

Most popular WAFs — ModSecurity, Coraza, CrowdSec — operate on a blacklist model (e.g. the OWASP Core Rule Set). Blacklist protection has fundamental limitations:

  • It only blocks what it already knows — zero-day exploits and application-specific vulnerabilities are invisible until someone writes a rule, after the fact
  • It generates false positives — generic rules that don't understand your application block legitimate traffic, requiring constant tuning
  • It assumes the attack surface is fixed — widely-deployed apps accumulate new CVEs regularly, so a blacklist WAF is always playing catch-up
  • It provides no protection against application-specific logic flaws — unexpected parameters, out-of-range values, or request shapes the application mishandles

The practical effect of the positive model:

  • Unknown attack vectors are blocked automatically — an undeclared parameter, header, or endpoint is blocked with no knowledge of the vulnerability
  • The policy is readable and auditable — the security posture lives in a single, human-readable Lua file
  • False positives are near zero — legitimate traffic is defined by the policy itself
  • Protection is application-aware — deep inspection of type, length, format, and allowed values that generic WAFs cannot achieve

How It Works

LOWAAF runs as an access_by_lua_block handler inside an OpenResty location block. Before every request is proxied to the upstream application, the framework:

  1. Checks the HTTP method against the application's globally allowed methods
  2. Matches the request URI and method against the declared route table
  3. Validates the client IP against any per-route allowlists (e.g. restricting admin endpoints to private networks)
  4. Validates request headers — both the set of header names (allowlist) and their values (format validators)
  5. Validates the request body — size limits, JSON schema, or form field schema

If any check fails, the request is denied. block is the project default; a brand-new or heavily-revised policy deliberately starts in log mode so violations are logged but requests still pass through — letting you validate the policy against real traffic before flipping it to block.

-- Example: Vaultwarden policy wired into an OpenResty route
local core = require "waf.core"
local app  = require "waf.apps.vaultwarden"
core.run(app)

Framework Structure

waf/
  core.lua          # Engine — validates requests against an app policy
  types.lua         # Validator factory library — composable building blocks
  apps/
    vaultwarden.lua # Policy definition for Vaultwarden
    jellyfin.lua    # Policy definition for Jellyfin
    mediawiki.lua   # Policy for MediaWiki (+ generated modules)
    gitea.lua       # Policy for Gitea (+ generated modules)

test/
  runner.lua        # Test suite — offline (mock_ngx) or online (http_client)
  gen.lua           # Test-case generator — derives invalid variants from valid schema
  mock_ngx.lua      # Stub for the OpenResty ngx API (offline mode)
  http_client.lua   # Cosocket HTTP client (online mode)

docker/
  docker-compose.yml  # OpenResty + Vaultwarden + MediaWiki test stack
  nginx.conf          # Nginx config with a WAF-gated listener per app

types.lua — Validator Factories

types.lua provides composable validator factories. Each returns a function(value, path) -> true | false, err usable anywhere in a policy definition.

local T = require "waf.types"

-- primitives
T.string({ min=1, max=256, match=[[^[a-z]+$]] })
T.number({ integer=true, min=0, max=100 })
T.boolean()
T.uuid()
T.email()

-- composites
T.nullable(T.uuid())
T.array(T.uuid(), { max=200 })
T.object({ name = T.string({ max=256 }), active = T.boolean() })

-- HTTP helpers
T.common_request_headers()   -- standard browser header name list
T.bearer_token()             -- Authorization: Bearer format

App Policy Files — Declarative and Readable

Each application firewall is a single Lua file returning a policy table. It contains no parsing logic — only declarations:

return {
  name = "myapp",
  mode = "log",   -- switch to "block" after validating against real traffic

  defaults = {
    max_body        = 1024 * 1024,
    allowed_methods = { GET=true, POST=true, PUT=true, DELETE=true },
    allowed_headers = T.common_request_headers(),
  },

  routes = {
    {
      name   = "login",
      method = "POST",
      path   = [[^/api/login$]],
      content_type = "application/json",
      json = T.object({
        username = T.email(),
        password = T.string({ min=8, max=1024 }),
      }),
    },
  }
}

For small applications, a hand-written file covers the whole policy. For large ones (MediaWiki, Gitea), policies are largely generated from the upstream application's own source — a live introspection API for MediaWiki's api.php, a checked-in Swagger spec for Gitea's REST API. The generated pieces return the exact same declarative shape; nothing in core.lua or types.lua changes.


Testing

The test suite covers every declared route and generates thousands of cases automatically — valid requests that must be allowed, and invalid variants that must be denied.

Offline Tests (no Docker required)

A mock of the OpenResty ngx API runs tests entirely in resty. For each route the suite verifies: valid baseline allowed, wrong method denied, unknown headers denied, every declared header validator's invalid variants, IP allowlists on admin routes, body-size enforcement, body on no_body routes, JSON/form schema invalids, and malformed bodies.

Across all four policies the offline suite generates ~149,000 test cases covering 1,112 routes:

App Routes Test cases
MediaWiki 204 ~108,000 (mostly generated)
Vaultwarden 270 ~25,000
Gitea (REST API v1) 482 ~11,800
Jellyfin 156 ~3,600

Online Tests (live Docker stack)

The same cases run against the actual WAF inside OpenResty, sending real HTTP requests and inspecting the X-WAF: block response header.

Running both suites together catches bug classes neither catches alone: - Logic bugs — caught offline with full schema coverage - Infrastructure bugs — caught online, e.g. large bodies spilling to nginx temp files (bodies > 8 KB were read as empty), nginx rejecting TRACE with a bare 405 before Lua runs, and array-valued query parameters serializing incorrectly over real HTTP

CLI Integration Tests

End-to-end tests using the bw CLI client exercise the full Vaultwarden authentication and vault flow against the Docker stack.


Current Application Policies

Application Status Policy Notes
Vaultwarden block mode vaultwarden.lua 270 routes; cipher, login, send, admin schemas; UA restriction
MediaWiki block mode mediawiki.lua 204 routes; index/api/load.php, mostly generated from action=paraminfo introspection
Jellyfin block mode jellyfin.lua 156 routes; library, playback, session, admin schemas
Gitea log mode — Phase 1 (REST API v1) gitea.lua 482 routes, generated from Gitea's Swagger spec; web UI and git smart-HTTP/LFS are later phases

Policy Files as API Documentation

A LOWAAF policy serves as the authoritative, executable specification of an application's API. Every route, method, header, and request body field is declared explicitly in one human-readable place. In block mode, that declaration isn't just documentation — it's the enforced contract between the outside world and the application.

  • Existing applications — writing a policy forces a complete audit of the actual API surface; any endpoint that can't be declared precisely is an endpoint that isn't understood, and that's where vulnerabilities hide
  • New applications — the policy should be written alongside the API as a first-class development artifact, preventing scope creep and accidental exposure
  • Third-party apps — when upstream ships an update, the policy is reviewed against the changelog to find new routes or parameters to declare

Engineering Focus

Defensive security — a positive-security, deny-by-default posture that blocks unknown vectors including zero-days, with no signature database to maintain.

Policy-as-code — the entire security posture of an application is a readable, auditable, version-controlled declaration, separated cleanly from enforcement logic.

Request validation — deep, application-aware inspection of method, URI, headers, query string, and JSON/form bodies.

Reverse proxy architecture — validation executes at the edge, before requests reach the application, reducing the effective attack surface.

Reliability through testing — ~149,000 offline and online test cases across four real applications, catching both logic and infrastructure bugs.


Roadmap

  • Gitea web UI + git smart-HTTP/LFS (Phase 1 of REST API v1 is done)
  • More application policies — WordPress, Nextcloud, and others
  • Stricter schemas from real-traffic log analysis
  • Per-route and per-client rate limiting
  • IPv6 CIDR support for IP allowlists
  • Optional response filtering / sanitization
  • More T.* validator factories (JWT, API key formats, common header shapes)