3e5590288a
Adds opt-in basic auth per Host, following the existing per-host controls
pattern:
- Host fields basicauth_enabled / basicauth_realm / basicauth_users
({user: base64(sha1(pw))}). Credentials are parsed to plaintext by the pure
host_features normalizer and hashed at the route layer (utils/basicauth.js),
so plaintext never reaches Redis.
- ops/nginx_conf/hostfeatures.lua enforces it in access phase: verifies the
Authorization header against base64(sha1(password)), fails closed with a 401
WWW-Authenticate challenge.
- hosts.ejs gains an enable toggle, realm, and a username:password textarea
(passwords never echoed back; blank keeps the current set).
Unit tests cover hashing (matches the htpasswd {SHA} vector), credential
parsing, and normalization. Note: the Lua path needs verification on a live
OpenResty box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
5.4 KiB
Lua
169 lines
5.4 KiB
Lua
-- Per-host reverse-proxy controls, enforced from the shared location's Lua
|
|
-- phases. The Host record (a Redis hash) is resolved by targetinfo.lua and
|
|
-- passed in here as `res`; targetinfo also stashes it in ngx.ctx.targetInfo so
|
|
-- the header-filter phase can re-read it.
|
|
--
|
|
-- Fields consumed (see nodejs/models/host.js):
|
|
-- ratelimit_enabled / ratelimit_rate / ratelimit_burst
|
|
-- respcache_enabled
|
|
-- hsts_enabled
|
|
-- req_headers (JSON object) -- added to the upstream request
|
|
-- resp_headers (JSON object) -- added to the client response
|
|
-- ip_allow / ip_deny (JSON arrays of CIDRs)
|
|
-- basicauth_enabled / basicauth_realm
|
|
-- basicauth_users (JSON object {username: base64(sha1(password))})
|
|
|
|
local cjson = require "cjson.safe"
|
|
|
|
local M = {}
|
|
|
|
-- cjson.safe returns nil (not an error) on bad input; treat non-tables as empty.
|
|
local function decode_table(str)
|
|
if not str or str == "" then return nil end
|
|
local ok = cjson.decode(str)
|
|
if type(ok) == "table" then return ok end
|
|
return nil
|
|
end
|
|
|
|
-- resty.ipmatcher wants a plain array of CIDR strings; build a matcher or nil.
|
|
local function build_matcher(str)
|
|
local list = decode_table(str)
|
|
if not list or #list == 0 then return nil end
|
|
|
|
local ipmatcher = require "resty.ipmatcher"
|
|
local m, err = ipmatcher.new(list)
|
|
if not m then
|
|
ngx.log(ngx.ERR, "hostfeatures: bad ip list ", err)
|
|
return nil
|
|
end
|
|
return m
|
|
end
|
|
|
|
-- IP allow/deny. deny wins; a non-empty allow list is default-deny.
|
|
local function apply_ip_access(res, ip)
|
|
local deny = build_matcher(res["ip_deny"])
|
|
if deny and deny:match(ip) then
|
|
return ngx.exit(403)
|
|
end
|
|
|
|
local allow = build_matcher(res["ip_allow"])
|
|
if allow and not allow:match(ip) then
|
|
return ngx.exit(403)
|
|
end
|
|
end
|
|
|
|
-- Per-host, per-client token bucket via lua-resty-limit-traffic (bundled with
|
|
-- OpenResty). Uses the shared dict "ratelimit" declared in nginx.conf.
|
|
local function apply_ratelimit(res, host, ip)
|
|
if res["ratelimit_enabled"] ~= "true" then return end
|
|
|
|
local rate = tonumber(res["ratelimit_rate"]) or 10
|
|
local burst = tonumber(res["ratelimit_burst"]) or 0
|
|
|
|
local limit_req = require "resty.limit.req"
|
|
local lim, err = limit_req.new("ratelimit", rate, burst)
|
|
if not lim then
|
|
-- Fail open on a misconfigured limiter rather than 500 every request.
|
|
ngx.log(ngx.ERR, "hostfeatures: failed to make limiter ", err)
|
|
return
|
|
end
|
|
|
|
local delay, derr = lim:incoming(host .. ":" .. ip, true)
|
|
if not delay then
|
|
if derr == "rejected" then
|
|
return ngx.exit(429)
|
|
end
|
|
ngx.log(ngx.ERR, "hostfeatures: limiter error ", derr)
|
|
return
|
|
end
|
|
|
|
if delay > 0 then
|
|
ngx.sleep(delay)
|
|
end
|
|
end
|
|
|
|
-- Per-host HTTP basic auth. Credentials are stored as {user: base64(sha1(pw))}
|
|
-- (htpasswd "{SHA}" scheme; hashed server-side in nodejs). Fails closed: any
|
|
-- misconfig or bad credential returns 401 with a WWW-Authenticate challenge.
|
|
local function apply_basicauth(res)
|
|
if res["basicauth_enabled"] ~= "true" then return end
|
|
|
|
local realm = res["basicauth_realm"]
|
|
if not realm or realm == "" then realm = "Restricted" end
|
|
realm = realm:gsub('[\r\n"]', "") -- defense in depth for the header
|
|
|
|
local function deny()
|
|
ngx.header["WWW-Authenticate"] = 'Basic realm="' .. realm .. '"'
|
|
return ngx.exit(401)
|
|
end
|
|
|
|
local users = decode_table(res["basicauth_users"])
|
|
if not users then return deny() end -- enabled but no users -> deny all
|
|
|
|
local header = ngx.var.http_authorization
|
|
if not header then return deny() end
|
|
local b64 = header:match("^%s*[Bb]asic%s+(%S+)%s*$")
|
|
if not b64 then return deny() end
|
|
|
|
local decoded = ngx.decode_base64(b64)
|
|
if not decoded then return deny() end
|
|
local user, pass = decoded:match("^([^:]*):(.*)$")
|
|
if not user or user == "" then return deny() end
|
|
|
|
local stored = users[user]
|
|
if not stored then return deny() end
|
|
|
|
local sha1 = require "resty.sha1"
|
|
local hasher = sha1:new()
|
|
if not hasher then return deny() end
|
|
hasher:update(pass or "")
|
|
local computed = ngx.encode_base64(hasher:final())
|
|
|
|
if computed ~= stored then return deny() end
|
|
-- authenticated: fall through to the rest of the request
|
|
end
|
|
|
|
-- Extra request headers sent to the upstream.
|
|
local function apply_req_headers(res)
|
|
local headers = decode_table(res["req_headers"])
|
|
if not headers then return end
|
|
for name, value in pairs(headers) do
|
|
ngx.req.set_header(name, value)
|
|
end
|
|
end
|
|
|
|
-- access_by_lua entry point. Runs after targetinfo.get resolved `res`.
|
|
function M.access(ngx_, res)
|
|
if not res then return end
|
|
local ip = ngx.var.remote_addr
|
|
local host = ngx.var.host
|
|
|
|
apply_ip_access(res, ip)
|
|
apply_ratelimit(res, host, ip)
|
|
apply_basicauth(res)
|
|
apply_req_headers(res)
|
|
|
|
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.
|
|
ngx.var.skip_cache = (res["respcache_enabled"] == "true") and "0" or "1"
|
|
end
|
|
|
|
-- header_filter_by_lua entry point. Reads the record stashed in ngx.ctx.
|
|
function M.header(ngx_)
|
|
local res = ngx.ctx.targetInfo
|
|
if not res then return end
|
|
|
|
local headers = decode_table(res["resp_headers"])
|
|
if headers then
|
|
for name, value in pairs(headers) do
|
|
ngx.header[name] = value
|
|
end
|
|
end
|
|
|
|
if res["hsts_enabled"] == "true" then
|
|
ngx.header["Strict-Transport-Security"] =
|
|
"max-age=31536000; includeSubDomains"
|
|
end
|
|
end
|
|
|
|
return M
|