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

24
00-captcha-init.conf Normal file
View file

@ -0,0 +1,24 @@
# /etc/nginx/conf.d/00-captcha-init.conf
lua_package_path "/etc/nginx/lua/?.lua;;";
# A resolver is required by ngx.location.capture -> proxy_pass to Cloudflare.
resolver 1.1.1.1 1.0.0.1 8.8.8.8 ipv6=off valid=300s;
resolver_timeout 5s;
# Trust store + protocols for cosocket TLS (used to call siteverify)
lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
lua_ssl_verify_depth 3;
lua_ssl_protocols TLSv1.2 TLSv1.3;
lua_shared_dict ts_walkaway 10m; # per-IP challenge counters
lua_shared_dict ts_reported 1m; # IPs reported recently (15-min cooldown)
# Pre-load the module so the env file is read once per worker.
init_by_lua_block {
require("captcha")
}
log_format ts '$remote_addr - $host "$request" $status $body_bytes_sent '
'ts=$sent_http_x_turnstile '
'rt=$request_time '
'ua="$http_user_agent" ref="$http_referer"';

133
README.md
View file

@ -1,3 +1,136 @@
# SearxNG-Captcha
Instructions and code to add a 90-day captcha cookie to SearxNG. The captcha engine can be self hosted. This setup eliminated the abuse by bots of my instance almost completely.
# Introduction
My SearxNG instance on [searx.thefloatinglab.world](https://searx.thefloatinglab.world) suffered so badly from bots that it became unusable. Looking around at fellow SearxNG instances learned that they all suffer from the same problem.
Despite the deployment of botlists, limiters, and manually blocking the most obvious bots, it still remained an endless battle. Most of the time my instance was useless. So either I had to give up on this project, or find a way to block the bots and let the genuine users through. A captcha system might not be popular, but on the other hand, a useless site is well, pretty useless.
By combining the captcha system with a cookie, only once in 90 days the user has to solve the captcha. A small price to pay for access to a wonderfull instance!
# Features
- No modification of the SearxNG code is necessary.
- The captcha must be solved the first time and stays valid for 90 days.
- No puzzles to solve, just a confirmation click.
- It is possible and encouraged to self host the captcha system, so no information leaks to the outside world.
- The privacy and security of SearxNG are maintained if a self hosted captcha system is used.
- Optional automatic reporting to AbuseIPDB.
- Optionally, Cloudflare Turnstile can be used as captcha provider.
- Everything is script based, no compilation is necessary.
- In an emergency, all existing cookies can be invalidated at once.
# Installation
This guide is not a complete walk through. I show you my code and how I have done it. Some tweaking might be necessary, and the locations of files on your system might be different than on mine.
I have not made an attempt to add subdirectories to this git, so you have to download the files and place them manually in the correct locations.
## Prerequisites
- Nginx is used for the reverse proxying
- Lua and some dependencies needs to be installed (apt install lua)
- It is recommended to self host the captcha engine, see: [github.com/tiagozip/cap](https://github.com/tiagozip/cap).
- A site/secret key set and URL/API-key for the captcha engine.
- Optionally, an API key at AbuseIPDB.
## 00-captcha-init.conf
*This file resides on my system in "/etc/nginx/conf.d".*
It configures lua and also creates an extended log format. This log format is optional but it allows you to see in the log file whether someone got passed the captcha. Most bots will not progress beyond the "challenge" state.
## captcha.env
*This file is in my /etc/nginx directory.*
The COOKIE_SECRET must be generated with "openssl rand -hex 32"
COOKIE_SECRET=<generate your own key>
ABUSEIPDB_API_KEY=<*Optional! obtain a key at abuseipdb for automated bot reporting, or leave empty for no bot reporting*>
CAP_API_URL=https://captcha.thefloatinglab.world # *self hosted site*
CAP_SITE_KEY=1a9933aa22
CAP_SECRET_KEY=sk-TF8Gn4KKMSC0h46j83AqZWNnga6nlc5v4hoHwn7nE
\# *Leave the CAP entries empty to use the Turnstile captcha.*
TURNSTILE_SITE_KEY=0x4AAAAAADisco1ig4Qu4hPJ
TURNSTILE_SECRET_KEY=0x4AAAAAADisca-OEq9hnPskVM6G57pTXsM
## captcha.conf
*This file is in my /etc/nginx/snippets directory.*
Do not modify this file.
## captcha.lua
*This file is in my /etc/nginx/lua directory.*
This file contains the core of the code.
## SearxNG vhost
You have to modify your nginx searxng vhost file to run the captcha.
The lines in bold are the additions.
You likely have no "location" for "/searxng/stats" yet, but if you use a health checker or monitor bot on the /stats directory, you can add it without the reference to the captcha system, so it remains accessible without the need for solving the captcha first.
Most bots search by using "/?q=" but some also from "/searxng/?q=". So both locations must be listed here.
~~~
**access_log /var/log/nginx/searx.access.log ts**; # <-- Optional! The "ts" suffix indicates the extended log format so captcha status is shown.
**include snippets/captcha.conf;** # <-- REQUIRED!
# Add this location if you want to keep /searxng/stats captcha free. A reason to do this is that you might have it checked by a monitor bot (uptime).
location = /searxng/stats {
proxy_pass http://127.0.0.1:8886;
proxy_set_header Host $host;
proxy_set_header Connection $http_connection;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Script-Name /searxng;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# You need to mention this location specifically to catch the bots that do not search via the root but via /searxng.
location /searxng/ {
**access_by_lua_block { require("captcha").guard() }** # <-- Add this!
proxy_pass http://127.0.0.1:8886;
proxy_set_header Host $host;
proxy_set_header Connection $http_connection;
proxy_set_header X-Scheme $scheme;
proxy_set_header X-Script-Name /searxng;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
**access_by_lua_block { require("captcha").guard() }** # <-- Add this!
proxy_pass http://127.0.0.1:8886;
proxy_set_header Host $host;
proxy_set_header Connection $http_connection;
~~~
# Things worth knowing
- Single-use tokens + your 90-day cookie. Both providers issue tokens that are good for one verify call, after which your cookie carries the user. The cookie is provider-agnostic, so an existing __ts_verified cookie continues to work after you switch providers — if the same COOKIE_SECRET is still in the env file. Rotating that secret invalidates all passes regardless of who issued them.
- I'm not affiliated in any way with the CAP self hosted captcha provider, but it looks like a sound project to me. You can fall back on Cloudflare Turnstile if you have more confident in them, but beware that they do some logging and analysis which partly defeats the purpose of SearxNG.
## Logging
- You will not see everything in your logs! Bots are immediately redirected to the captcha system, before an entry in the nginx log is made. Many bots are not even capable of properly interfacing with this redirection and simply nevere make it to the captcha, and vanish without leaving a trail.
- You will see a sharp decline in bots. This is not a malfunction but the intention. Some bots learn quickly, and getting listed in AbuseIPDB doesn't encourage them. It looks like they are coded to detect reporting, or some bot owners might receive automated notifications if they get listed, but one way or the other, they avoid sites that put and keep them on public blacklists.
## Self hosted CAP
- Token format / field name. Cap auto-injects a hidden cap-token field on solve (default name), and the widget docs note tokens are single-use — so don't be alarmed if reloading the verify endpoint with a stale POST body fails.
- Server-to-server reachability. The verify endpoint runs in your Nginx workers, which now make an outbound HTTPS call to your self hosted captcha provider. If you've firewalled outbound or are running both Nginx and Cap on the same host, you may save a hop by setting CAP_API_URL=http://127.0.0.1:3000 (Cap's default port). The parse_url helper handles plain http:// automatically — no TLS handshake performed in that case. The widget still needs the public URL though, so you'd typically keep the public HTTPS URL for the widget and only flip to localhost for verification by setting them separately. If you want that split, easiest is to add a fourth env var like CAP_VERIFY_URL that defaults to CAP_API_URL — I leave it as an exercise to the reader.
- TLS trust. The same lua_ssl_trusted_certificate directive that lets us reach Cloudflare also covers your Cap instance, assuming it's using a publicly-trusted cert (Let's Encrypt etc.). If Cap is on a private CA, point that directive at a bundle that includes your CA.
- Falling back to Turnstile. Comment out or delete the CAP_SITE_KEY line in /etc/nginx/captcha.env and restart. Provider auto-flips back to Turnstile.
- CSP, if you have one. Cap loads its widget script from cdn.jsdelivr.net and its WASM from the same CDN by default. If you've added a strict CSP, you'll need script-src 'self' cdn.jsdelivr.net 'wasm-unsafe-eval'. For pinning to a specific version, replace cap-widget in the script src with cap-widget@<version> — check the latest release on the project's GitHub.
- Performance & UX. Cap's PoW is invisible-style: the user clicks one checkbox, then watches a brief spinner. Solve time depends on the client device (Cap reports a default-difficulty solve at roughly 23s on modern hardware) — much snappier than image puzzles, but slightly more "interactive" than Turnstile's typical zero-click case.
## AbuseIPDB
- Threshold of 10 per two hours** is a reasonable default but tweak WALKAWAY_THRESHOLD and WALKAWAY_TTL to taste. With WALKAWAY_TTL = 3600, the counter auto-expires after an hour of silence, so a slow trickle never builds up.
- One report per IP per 15 minutes. The ts_reported:add() with REPORT_COOLDOWN makes sure you don't spam AbuseIPDB if a botnet member keeps hitting you. Free tier caps at 1000 reports/day; with this design you'd need ~700 distinct repeat-offender IPs/day to come close.
- Behind a CDN / reverse proxy? ngx.var.remote_addr would be the proxy's IP, not the client's. Either configure ngx_http_realip_module (set_real_ip_from/real_ip_header X-Forwarded-For) so $remote_addr reflects the real client, or change the calls to ngx.var.http_x_forwarded_for (and parse out the first hop yourself). Don't ship to AbuseIPDB without verifying which IP you're sending — reporting your CDN's IP would be embarrassing.
- Reset on solve is per-IP. If a real human eventually solves from the same NAT/IP, the counter clears for the whole address. Good for shared exits.
- Don't report your own monitoring. If you have uptime checks (UptimeRobot, Pingdom, internal Prometheus blackbox) hitting an HTML path without ever solving the challenge, they'll trigger this. Either point them at a path that bypasses the gate, give them a static __ts_verified cookie minted manually with a far-future expiry, or whitelist their IPs in note_walkaway.
- Tor and shared VPNs. Mass reports against shared exit nodes are controversial. If your searx instance is publicly listed, expect a chunk of legitimate traffic from Tor exits — consider raising the threshold to 2550, or excluding well-known shared-exit ASNs.
- Where the counters live. lua_shared_dict is per-Nginx-instance, in shared memory across all workers, and lost on restart. That's fine here — we don't need persistence; a bot will simply rebuild its score within an hour. The dict is sized 10 MB, which fits ~100k IPs comfortably.
- Async timing. ngx.timer.at(0, …) returns immediately, so the user's HTTP response (the 200 challenge page or the 302 redirect) is never delayed by the AbuseIPDB call. The report happens in a background light-thread inside the same worker.
- Audit trail. Every report logs to error.log at notice level with the IP and the count, so you can grep 'reported.*AbuseIPDB' /var/log/nginx/error.log | wc -l for a daily tally. If you want richer accounting (which paths the bot hit, user-agent, ASN), you can pass them through to the timer and stitch them into the comment field — AbuseIPDB shows the comment verbatim on the IP's public page.
# License
See the license file.
The original of this project can be found at [git.thefloatinglab.world/TheFloatingLab/SearxNG-Captcha](https://git.thefloatinglab.world/TheFloatingLab/SearxNG-Captcha) which is part of [www.thefloatinglab.world](https://www.thefloatinglab.world)

7
captcha.conf Normal file
View file

@ -0,0 +1,7 @@
# /etc/nginx/snippets/captcha.conf
# Include this inside every `server { ... }` you want to protect.
location = /__ts/verify {
content_by_lua_block { require("captcha").verify() }
}

8
captcha.env Normal file
View file

@ -0,0 +1,8 @@
# Generate with: openssl rand -hex 32
COOKIE_SECRET=43f0cb791252aab8a4fa454545112233445566778899781c6206d61617b761e1
ABUSEIPDB_API_KEY=f4cb4d93911223344556677889900297032b7b3fd8d019c355a5a24df1bc454cb47f8c5214690710
CAP_API_URL=https://captcha.thefloatinglab.world
CAP_SITE_KEY=1a9933aa22
CAP_SECRET_KEY=sk-TF8Gn4KKMSC0h46j83AqZWNnga6nlc5v4hoHwn7nE
TURNSTILE_SITE_KEY=0x4AAAAAADisco1ig4Qu4hPJ
TURNSTILE_SECRET_KEY=0x4AAAAAADisca-OEq9hnPskVM6G57pTXsM

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