Initial release
This commit is contained in:
parent
58aacd071a
commit
75f529b346
5 changed files with 535 additions and 1 deletions
362
captcha.lua
Normal file
362
captcha.lua
Normal 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("&","&"):gsub("<","<"):gsub(">",">")
|
||||
:gsub('"',"""):gsub("'","'"))
|
||||
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))
|
||||
.. "×tamp=".. 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
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue