Files
proxy/ops/nginx_conf/hostfeatures.lua
T
wmantly 3e5590288a Per-host HTTP basic auth (#57)
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>
2026-07-11 11:47:08 -04:00

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