mirror of https://github.com/interlegis/sapl.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
85 lines
3.2 KiB
85 lines
3.2 KiB
-- blocklist.lua: early-reject blocked IPs before reaching Gunicorn.
|
|
--
|
|
-- Checks (in order, cheapest first):
|
|
-- 1. User-Agent in bot UA list — nginx map variable, no Redis
|
|
-- 2. ASN in datacenter deny list — lua-resty-maxminddb (MaxMind ASN DB)
|
|
-- 3. ngx.shared.ip_prefix_blocked membership — in-process cache refreshed every 60s
|
|
-- 4. GET rl:ip:{ip}:blocked — global IP block (Redis DB 1)
|
|
-- 5. GET rl:api:ns:{ns}:ip:{ip}:blocked — per-tenant API block (/api/ only, Redis DB 1)
|
|
--
|
|
-- Checks 4+5 are pipelined in one Redis round trip.
|
|
-- On Redis failure: fail-open (request passes to Django).
|
|
|
|
-- Parse REDIS_URL (redis://host:port or redis://host:port/db).
|
|
local redis_url = os.getenv("REDIS_URL") or "redis://127.0.0.1:6379"
|
|
local REDIS_HOST, port_str = redis_url:match("redis://([^:/]+):(%d+)")
|
|
if not REDIS_HOST then REDIS_HOST = redis_url:match("redis://([^:/]+)") or "127.0.0.1" end
|
|
local REDIS_PORT = tonumber(port_str) or 6379
|
|
|
|
local POD_NS = os.getenv("POD_NAMESPACE") or ""
|
|
local ip = ngx.var.remote_addr
|
|
local is_api = ngx.var.uri:sub(1, 5) == "/api/"
|
|
|
|
local function return_429()
|
|
ngx.status = 429
|
|
ngx.header["Retry-After"] = "300"
|
|
ngx.header["Content-Type"] = "application/json"
|
|
ngx.say('{"detail":"Too Many Requests"}')
|
|
return ngx.exit(429)
|
|
end
|
|
|
|
-- 1. Bot UA check (nginx map variable — no I/O).
|
|
if ngx.var.bot_ua_blocked == "1" then return return_429() end
|
|
|
|
-- 2. ASN check via lua-resty-maxminddb (shared DB handle opened in init_by_lua_block).
|
|
local BLOCKED_ASNS = {
|
|
[16509] = true, -- Amazon AWS
|
|
[14618] = true, -- Amazon AWS us-east
|
|
[8075] = true, -- Microsoft Azure
|
|
[396982]= true, -- Google Cloud
|
|
[20473] = true, -- Vultr
|
|
[24940] = true, -- Hetzner
|
|
[16276] = true, -- OVH
|
|
[36352] = true, -- ColoCrossing
|
|
[63949] = true, -- Linode / Akamai
|
|
}
|
|
local ok_mmdb, mmdb = pcall(require, "resty.maxminddb")
|
|
if ok_mmdb and mmdb.initted() then
|
|
local result = mmdb.lookup(ip)
|
|
if result and BLOCKED_ASNS[result.autonomous_system_number] then
|
|
return return_429()
|
|
end
|
|
end
|
|
|
|
-- Build 4 candidates for prefix check: three trailing-dot prefixes + exact IP.
|
|
-- Mirrors Django's _is_ip_prefix_blocked normalisation and _refresh_ip_prefix_blocklist.
|
|
local parts = {}
|
|
for p in ip:gmatch("[^.]+") do parts[#parts+1] = p end
|
|
local p1 = parts[1] .. "."
|
|
local p2 = parts[1] .. "." .. parts[2] .. "."
|
|
local p3 = parts[1] .. "." .. parts[2] .. "." .. parts[3] .. "."
|
|
|
|
-- 3. IP prefix check (in-process shared dict — no Redis I/O per request).
|
|
local dict = ngx.shared.ip_prefix_blocked
|
|
if dict:get(p1) or dict:get(p2) or dict:get(p3) or dict:get(ip) then
|
|
return return_429()
|
|
end
|
|
|
|
-- 4+5. Pipeline both STRING block checks in one Redis round trip.
|
|
local red = require("resty.redis"):new()
|
|
red:set_timeout(200)
|
|
local ok = red:connect(REDIS_HOST, REDIS_PORT)
|
|
if not ok then return end -- fail-open
|
|
|
|
red:select(1)
|
|
|
|
red:init_pipeline()
|
|
red:get("rl:ip:" .. ip .. ":blocked")
|
|
red:get("rl:api:ns:" .. POD_NS .. ":ip:" .. ip .. ":blocked")
|
|
local res = red:commit_pipeline()
|
|
red:set_keepalive(10000, 1)
|
|
|
|
if not res then return end -- fail-open on pipeline error
|
|
|
|
if res[1] == "1" then return return_429() end
|
|
if is_api and res[2] == "1" then return return_429() end
|
|
|