Initial release

This commit is contained in:
Frans Veldman 2026-06-12 08:19:31 +00:00
commit 75f529b346
5 changed files with 535 additions and 1 deletions

362
captcha.lua Normal file
View file

@ -0,0 +1,362 @@
-- /etc/nginx/lua/captcha.lua
local cjson = require("cjson.safe")
local M = {}
local COOKIE_NAME = "__ts_verified"
local COOKIE_TTL = 60 * 60 * 24 * 90 -- 90 days
local VERIFY_PATH = "/__ts/verify"
local WALKAWAY_THRESHOLD = 10
local WALKAWAY_TTL = 7200 -- seconds; counter resets after 2h idle
local REPORT_COOLDOWN = 15 * 60 -- never re-report same IP within 15 min
-- ---------- config: load ONCE, at module load time (in master/root) ----------
local cfg = { site_key = "", secret_key = "", cookie_secret = "", abuseipdb_key = "", cap_url="", cap_site_key="", cap_secret_key="" }
do
local f, err = io.open("/etc/nginx/captcha.env", "r")
if not f then
ngx.log(ngx.ERR, "captcha: cannot open env file: ", err)
else
for line in f:lines() do
local k, v = line:match("^%s*([A-Z_]+)%s*=%s*(.-)%s*$")
if k == "TURNSTILE_SITE_KEY" then cfg.site_key = v
elseif k == "TURNSTILE_SECRET_KEY" then cfg.secret_key = v
elseif k == "COOKIE_SECRET" then cfg.cookie_secret = v
elseif k == "ABUSEIPDB_API_KEY" then cfg.abuseipdb_key = v
elseif k == "CAP_API_URL" then cfg.cap_url = (v or ""):gsub("/+$","")
elseif k == "CAP_SITE_KEY" then cfg.cap_site_key = v
elseif k == "CAP_SECRET_KEY" then cfg.cap_secret_key = v
end
end
f:close()
end
end
local function provider()
if cfg.cap_site_key ~= "" and cfg.cap_secret_key ~= "" and cfg.cap_url ~= "" then
return "cap"
end
return "turnstile"
end
ngx.log(ngx.NOTICE, "captcha: config loaded, provider=", provider(),
(provider() == "cap" and (", cap_url=" .. cfg.cap_url) or ""))
-- ---------- helpers ----------
local function b64url(s)
return (ngx.encode_base64(s):gsub("+","-"):gsub("/","_"):gsub("=",""))
end
local function html_escape(s)
return (tostring(s)
:gsub("&","&amp;"):gsub("<","&lt;"):gsub(">","&gt;")
:gsub('"',"&quot;"):gsub("'","&#39;"))
end
local function ct_eq(a, b)
if type(a) ~= "string" or type(b) ~= "string" or #a ~= #b then return false end
local diff = 0
for i = 1, #a do
local d = string.byte(a, i) - string.byte(b, i)
diff = diff + d * d
end
return diff == 0
end
local function sign_cookie(expires)
local msg = tostring(expires)
return msg .. "." .. b64url(ngx.hmac_sha1(cfg.cookie_secret, msg))
end
local function cookie_is_valid(val)
if not val then return false end
local exp, sig = val:match("^(%d+)%.([A-Za-z0-9_%-]+)$")
if not exp then return false end
exp = tonumber(exp)
if not exp or exp < ngx.time() then return false end
local expected = b64url(ngx.hmac_sha1(cfg.cookie_secret, tostring(exp)))
return ct_eq(sig, expected)
end
local function safe_redirect(r)
if type(r) ~= "string" or r == "" then return "/" end
if r:sub(1,1) ~= "/" or r:sub(1,2) == "//" or r:sub(1,2) == "/\\" then
return "/"
end
return r
end
-- ---------- URL parser + generic HTTP(S) POST via cosocket ----------
local function parse_url(url)
local scheme, rest = url:match("^(https?)://(.*)$")
if not scheme then return nil end
local hostport, path = rest:match("^([^/]+)(.*)$")
if not hostport then return nil end
if path == "" then path = "/" end
local host, p = hostport:match("^([^:]+):(%d+)$")
if host then return { scheme=scheme, host=host, port=tonumber(p), path=path } end
return { scheme=scheme, host=hostport,
port=(scheme == "https" and 443 or 80), path=path }
end
local function http_post(url, body, content_type, extra_headers)
local u = parse_url(url)
if not u then return nil, "bad url: " .. tostring(url) end
local sock = ngx.socket.tcp()
sock:settimeout(5000)
local ok, err = sock:connect(u.host, u.port)
if not ok then return nil, "connect: " .. (err or "?") end
if u.scheme == "https" then
local sess; sess, err = sock:sslhandshake(false, u.host, true)
if not sess then sock:close(); return nil, "ssl: " .. (err or "?") end
end
local lines = {
"POST " .. u.path .. " HTTP/1.1",
"Host: " .. u.host,
"User-Agent: nginx-turnstile/1.0",
"Accept: application/json",
"Content-Type: " .. content_type,
"Content-Length: " .. #body,
"Connection: close",
}
for k, v in pairs(extra_headers or {}) do
lines[#lines+1] = k .. ": " .. v
end
lines[#lines+1] = ""
lines[#lines+1] = body
local _, serr = sock:send(table.concat(lines, "\r\n"))
if serr then sock:close(); return nil, "send: " .. serr end
local status_line, rerr = sock:receive("*l")
if not status_line then sock:close(); return nil, "recv status: " .. (rerr or "?") end
local status = tonumber(status_line:match("HTTP/1%.[01]%s+(%d+)"))
local content_length, chunked
while true do
local line; line, rerr = sock:receive("*l")
if not line then sock:close(); return nil, "recv header: " .. (rerr or "?") end
if line == "" then break end
local lc = line:lower()
local cl = lc:match("^content%-length:%s*(%d+)")
if cl then content_length = tonumber(cl) end
if lc:match("^transfer%-encoding:.*chunked") then chunked = true end
end
local resp = ""
if chunked then
while true do
local size_line = sock:receive("*l")
if not size_line then break end
local size = tonumber((size_line:match("^([0-9a-fA-F]+)") or ""), 16)
if not size or size == 0 then break end
local chunk = sock:receive(size); if not chunk then break end
resp = resp .. chunk
sock:receive(2)
end
elseif content_length then
resp = sock:receive(content_length) or ""
else
resp = sock:receive("*a") or ""
end
sock:close()
return status, resp
end
-- ---------- provider-specific verifiers ----------
local function turnstile_verify(token, remote_ip)
local body = "secret=" .. ngx.escape_uri(cfg.secret_key)
.. "&response=" .. ngx.escape_uri(token)
.. "&remoteip=" .. ngx.escape_uri(remote_ip or "")
return http_post("https://challenges.cloudflare.com/turnstile/v0/siteverify",
body, "application/x-www-form-urlencoded")
end
local function cap_verify(token)
local body = cjson.encode({ secret = cfg.cap_secret_key, response = token })
local url = cfg.cap_url .. "/" .. cfg.cap_site_key .. "/siteverify"
return http_post(url, body, "application/json")
end
-- ---------- AbuseIPDB reporter ----------
local function report_to_abuseipdb(ip, count)
if cfg.abuseipdb_key == "" then return end
local body = "ip=" .. ngx.escape_uri(ip)
.. "&categories=19"
.. "&comment=" .. ngx.escape_uri(string.format(
"Fetched browser challenge page %d times in <2h without solving. Likely bad bot.", count))
.. "&timestamp=".. ngx.escape_uri(os.date("!%Y-%m-%dT%H:%M:%S+00:00"))
local status, resp = http_post("https://api.abuseipdb.com/api/v2/report",
body, "application/x-www-form-urlencoded",
{ ["Key"] = cfg.abuseipdb_key })
if status == 200 or status == 201 then
ngx.log(ngx.ERR, "captcha: reported ", ip, " to AbuseIPDB after ", count, " walk-aways")
else
ngx.log(ngx.WARN, "captcha: abuseipdb returned ", tostring(status), ": ", tostring(resp))
end
end
local function note_walkaway(ip)
if not ip or ip == "" or cfg.abuseipdb_key == "" then return end
local counts = ngx.shared.ts_walkaway; if not counts then return end
local n = counts:incr(ip, 1, 0, WALKAWAY_TTL)
if not n then return end
if n >= WALKAWAY_THRESHOLD then
local reported = ngx.shared.ts_reported; if not reported then return end
if reported:add(ip, 1, REPORT_COOLDOWN) then
ngx.header["X-Turnstile"] = "reported"
local ok, terr = ngx.timer.at(0, function(prem, ip_arg, n_arg)
if prem then return end
report_to_abuseipdb(ip_arg, n_arg)
end, ip, n)
if not ok then ngx.log(ngx.ERR, "ts timer: ", terr); reported:delete(ip) end
end
end
end
local function clear_walkaway(ip)
if not ip or ip == "" then return end
local counts = ngx.shared.ts_walkaway
if counts then counts:delete(ip) end
end
-- ---------- challenge page (provider-dispatched widget block) ----------
local function widget_turnstile(redirect)
return table.concat({
'<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>',
'<form id="ts-form" action="', VERIFY_PATH, '" method="POST">',
'<input type="hidden" name="redirect" value="', redirect, '">',
'<div class="cf-turnstile" data-sitekey="', html_escape(cfg.site_key),
'" data-callback="onTsSuccess" data-theme="auto"></div>',
'</form>',
'<script>function onTsSuccess(){document.getElementById("ts-form").submit();}</script>',
})
end
local function widget_cap(redirect)
local endpoint = html_escape(cfg.cap_url .. "/" .. cfg.cap_site_key .. "/")
return table.concat({
'<script src="https://cdn.jsdelivr.net/npm/cap-widget" async></script>',
'<form id="ts-form" action="', VERIFY_PATH, '" method="POST">',
'<input type="hidden" name="redirect" value="', redirect, '">',
'<cap-widget id="cap" data-cap-api-endpoint="', endpoint, '"></cap-widget>',
'</form>',
'<script>',
'document.addEventListener("DOMContentLoaded",function(){',
' var w=document.getElementById("cap");if(!w)return;',
' w.addEventListener("solve",function(){document.getElementById("ts-form").submit();});',
'});',
'</script>',
})
end
local function serve_challenge()
local redirect = html_escape(ngx.var.request_uri or "/")
local widget = (provider() == "cap") and widget_cap(redirect) or widget_turnstile(redirect)
local html = table.concat({
'<!doctype html><html lang="en"><head>',
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width,initial-scale=1">',
'<title>Checking your browser…</title>',
'<style>html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f5f6f8;color:#222}',
'.wrap{min-height:100%;display:flex;align-items:center;justify-content:center;padding:1rem}',
'.card{background:#fff;border-radius:10px;box-shadow:0 4px 20px rgba(0,0,0,.08);padding:2rem 2.25rem;max-width:420px;text-align:center}',
'h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.25rem 0 1.25rem;color:#555;font-size:.95rem}',
'.cf-turnstile,cap-widget{display:flex;justify-content:center;margin:0 auto}</style></head><body>',
'<div class="wrap"><div class="card">',
'<h1>One quick check…</h1>',
'<p>Please complete the security check to continue.</p>',
widget,
'</div></div></body></html>',
})
ngx.header["X-Turnstile"] = "challenge"
ngx.header["Content-Type"] = "text/html; charset=utf-8"
ngx.header["Cache-Control"] = "no-store"
ngx.header["X-Robots-Tag"] = "noindex"
ngx.status = 200
ngx.print(html)
return ngx.exit(200)
end
-- ---------- public: gate ----------
function M.guard()
if ngx.var.uri == VERIFY_PATH then return end
if cookie_is_valid(ngx.var["cookie_" .. COOKIE_NAME]) then return end
-- (re-add any monitoring bypasses you set up earlier here)
local accept = ngx.var.http_accept or ""
if ngx.req.get_method() ~= "GET" or not accept:find("text/html", 1, true) then
ngx.status = 403
ngx.header["Content-Type"] = "text/plain"
ngx.say("Forbidden: browser verification required")
return ngx.exit(403)
end
note_walkaway(ngx.var.remote_addr)
return serve_challenge()
end
-- ---------- public: verify endpoint ----------
function M.verify()
if ngx.req.get_method() ~= "POST" then
ngx.status = 405; ngx.header["Allow"] = "POST"; return ngx.exit(405)
end
ngx.req.read_body()
local args = ngx.req.get_post_args() or {}
local back = safe_redirect(args["redirect"])
local prov = provider()
local token = (prov == "cap") and args["cap-token"] or args["cf-turnstile-response"]
if not token or token == "" then
ngx.status = 400; ngx.say("Missing CAPTCHA token"); return ngx.exit(400)
end
local status, body
if prov == "cap" then
status, body = cap_verify(token)
else
status, body = turnstile_verify(token, ngx.var.remote_addr)
end
if not status or status ~= 200 then
ngx.log(ngx.ERR, "captcha: ", prov, " siteverify error: ", tostring(body))
ngx.status = 502; ngx.say("Verification service unavailable"); return ngx.exit(502)
end
local data = cjson.decode(body or "")
if not data or data.success ~= true then
ngx.log(ngx.WARN, "captcha: ", prov, " rejected: ", body or "")
ngx.status = 403; ngx.say("Verification failed. Please try again."); return ngx.exit(403)
end
clear_walkaway(ngx.var.remote_addr)
local expires = ngx.time() + COOKIE_TTL
ngx.header["Set-Cookie"] = string.format(
"%s=%s; Path=/; Expires=%s; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
COOKIE_NAME, sign_cookie(expires), ngx.cookie_time(expires), COOKIE_TTL
)
ngx.header["Cache-Control"] = "no-store"
ngx.header["X-Turnstile"] = "verified"
return ngx.redirect(back, 302)
end
return M