From: Vsevolod Stakhov Date: Thu, 23 Jul 2026 13:13:18 +0000 (+0100) Subject: [Feature] lua_http: forbid_local option to block requests to local networks X-Git-Tag: 4.1.3~30 X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=e57fb647251e4ffb02c20195eef721982fd18f74;p=thirdparty%2Frspamd.git [Feature] lua_http: forbid_local option to block requests to local networks rspamd_http.request() connects to whatever the URL or its DNS resolution yields, including loopback, link-local and RFC1918 destinations. Add a forbid_local option enforced at the single connection chokepoint (after keepalive, upstream, numeric and DNS address selection) using rspamd_ip_is_local_cfg, i.e. the address class check plus the configurable local_addrs radix. Enable it by default in url_redirector: message URLs and their redirect targets are attacker controlled, and hop limits alone do not stop a crafted redirect from probing cloud metadata endpoints or internal services. Operators resolving internal redirectors can set forbid_local = false. --- diff --git a/src/lua/lua_http.c b/src/lua/lua_http.c index 2ea48698ba..20faabc621 100644 --- a/src/lua/lua_http.c +++ b/src/lua/lua_http.c @@ -170,6 +170,7 @@ static const struct luaL_reg httplib_m[] = { #define RSPAMD_LUA_HTTP_FLAG_RESOLVED (1 << 2) #define RSPAMD_LUA_HTTP_FLAG_KEEP_ALIVE (1 << 3) #define RSPAMD_LUA_HTTP_FLAG_YIELDED (1 << 4) +#define RSPAMD_LUA_HTTP_FLAG_NO_LOCAL (1 << 5) struct lua_http_cbdata { struct rspamd_http_connection *conn; @@ -535,6 +536,22 @@ lua_http_resume_handler(struct rspamd_http_connection *conn, static gboolean lua_http_make_connection(struct lua_http_cbdata *cbd) { + if (cbd->flags & RSPAMD_LUA_HTTP_FLAG_NO_LOCAL) { + struct rspamd_config *cfg = cbd->cfg; + + if (cfg == NULL && cbd->task) { + cfg = cbd->task->cfg; + } + + if (rspamd_ip_is_local_cfg(cfg, cbd->addr)) { + msg_info("forbid HTTP request to local address %s (host: %s) as 'forbid_local' is set", + rspamd_inet_address_to_string(cbd->addr), + cbd->host ? cbd->host : "unknown"); + + return FALSE; + } + } + rspamd_inet_address_set_port(cbd->addr, cbd->msg->port); unsigned http_opts = RSPAMD_HTTP_CLIENT_SIMPLE; @@ -827,6 +844,7 @@ lua_http_push_headers(lua_State *L, struct rspamd_http_message *msg) * @param {resolver} resolver to perform DNS-requests. Usually got from either `task` or `config` * @param {boolean} gzip if true, body of the requests will be compressed * @param {boolean} no_ssl_verify disable SSL peer checks + * @param {boolean} forbid_local refuse to connect when the destination resolves to a loopback, link-local or `local_addrs` address (SSRF protection for requests to untrusted URLs) * @param {boolean} keepalive enable keep-alive pool * @param {string} user for HTTP authentication * @param {string} password for HTTP authentication, only if "user" present @@ -1185,6 +1203,15 @@ lua_http_request(lua_State *L) lua_pop(L, 1); + lua_pushstring(L, "forbid_local"); + lua_gettable(L, 1); + + if (!!lua_toboolean(L, -1)) { + flags |= RSPAMD_LUA_HTTP_FLAG_NO_LOCAL; + } + + lua_pop(L, 1); + lua_pushstring(L, "keepalive"); lua_gettable(L, 1); diff --git a/src/plugins/lua/url_redirector.lua b/src/plugins/lua/url_redirector.lua index 61b3e91058..3d9ca34eb7 100644 --- a/src/plugins/lua/url_redirector.lua +++ b/src/plugins/lua/url_redirector.lua @@ -154,6 +154,11 @@ local settings = { --proxy = "http://example.com:3128", -- send request through proxy, not yet implemented key_prefix = 'rdr:', -- default hash name check_ssl = false, -- check ssl certificates + -- SSRF protection: message URLs and their redirect targets are attacker + -- controlled, so never follow them into loopback, link-local or + -- local_addrs networks (e.g. cloud metadata services). Disable only if + -- internal redirectors must be resolved. + forbid_local = true, max_urls = 5, -- how many urls to check (CTA checked in first place) max_size = 10 * 1024, -- maximum body to process -- Optional operator override. When set (a string, or a list of @@ -741,6 +746,7 @@ http_walk = function(task, orig_url, url, ntries, chain, seen) max_size = settings.max_size, opaque_body = true, no_ssl_verify = not settings.check_ssl, + forbid_local = settings.forbid_local, callback = http_callback, } @@ -775,7 +781,13 @@ http_walk = function(task, orig_url, url, ntries, chain, seen) end apply_http_timeout(http_params) - rspamd_http.request(http_params) + if not rspamd_http.request(http_params) then + -- Synchronous refusal (e.g. forbid_local blocked a numeric-IP URL): + -- the callback will never fire, so terminate the walk here + rspamd_logger.infox(task, 'cannot start HTTP request for %s', url) + chain_append(chain, url) + finalize_chain(task, chain, nil) + end end -- Top-level entry: walk the cached chain from orig_url, then either diff --git a/test/functional/cases/220_http.robot b/test/functional/cases/220_http.robot index 1e2a08451f..2f14f23c04 100644 --- a/test/functional/cases/220_http.robot +++ b/test/functional/cases/220_http.robot @@ -63,6 +63,11 @@ SSL Large HTTP request ... Settings={symbols_enabled = [LARGE_HTTP_TEST]} Expect Symbol HTTP_SSL_LARGE +Forbid local addresses + Scan File ${MESSAGE} Url=/request Method=get + ... Settings={symbols_enabled = [FORBID_LOCAL_TEST]} + Expect Symbols HTTP_FORBID_NUMERIC_DENIED HTTP_FORBID_DNS_ERROR + *** Keywords *** Http Setup Run Dummy Http diff --git a/test/functional/configs/url_redirector.conf b/test/functional/configs/url_redirector.conf index e6009ba665..8bc45f419a 100644 --- a/test/functional/configs/url_redirector.conf +++ b/test/functional/configs/url_redirector.conf @@ -5,4 +5,6 @@ redis { } url_redirector { redirector_hosts_map = "{= env.TESTDIR =}/configs/maps/redir.map"; + # Test redirect targets live on 127.0.0.1 + forbid_local = false; } diff --git a/test/functional/configs/url_redirector_chain.conf b/test/functional/configs/url_redirector_chain.conf index 108d033866..926ba2fc96 100644 --- a/test/functional/configs/url_redirector_chain.conf +++ b/test/functional/configs/url_redirector_chain.conf @@ -6,6 +6,8 @@ redis { url_redirector { redirector_hosts_map = "{= env.TESTDIR =}/configs/maps/redir.map"; + # Test redirect targets live on 127.0.0.1 + forbid_local = false; save_intermediate_redirs = { redirectors = false; non_redirectors = true; diff --git a/test/functional/configs/url_redirector_no_intermediate.conf b/test/functional/configs/url_redirector_no_intermediate.conf index b3276ea532..cfa43dbb58 100644 --- a/test/functional/configs/url_redirector_no_intermediate.conf +++ b/test/functional/configs/url_redirector_no_intermediate.conf @@ -6,6 +6,8 @@ redis { url_redirector { redirector_hosts_map = "{= env.TESTDIR =}/configs/maps/redir.map"; + # Test redirect targets live on 127.0.0.1 + forbid_local = false; save_intermediate_redirs = { redirectors = true; non_redirectors = false; diff --git a/test/functional/configs/url_redirector_phishing.conf b/test/functional/configs/url_redirector_phishing.conf index 5feefe14a4..4501ec2567 100644 --- a/test/functional/configs/url_redirector_phishing.conf +++ b/test/functional/configs/url_redirector_phishing.conf @@ -6,6 +6,8 @@ redis { url_redirector { redirector_hosts_map = "{= env.TESTDIR =}/configs/maps/redir.map"; + # Test redirect targets live on 127.0.0.1 + forbid_local = false; redirector_symbol = "URL_REDIRECTOR"; } diff --git a/test/functional/lua/http.lua b/test/functional/lua/http.lua index 2fc8d977c4..c5e2df8ed9 100644 --- a/test/functional/lua/http.lua +++ b/test/functional/lua/http.lua @@ -158,6 +158,50 @@ rspamd_config:register_symbol({ flags = 'coro' }) +local function http_forbid_local_symbol(task) + -- Numeric loopback destination: denied synchronously, the callback + -- never fires and request() returns false + local ret = rspamd_http.request({ + url = string.format('http://127.0.0.1:%d/request', http_port), + task = task, + method = 'get', + callback = function(err, code) + if err then + task:insert_result('HTTP_FORBID_NUMERIC_ERROR', 1.0, err) + else + task:insert_result('HTTP_FORBID_NUMERIC_' .. code, 1.0) + end + end, + timeout = 1, + forbid_local = true, + }) + if ret == false then + task:insert_result('HTTP_FORBID_NUMERIC_DENIED', 1.0) + end + + -- DNS-resolved loopback destination: denied after resolution, surfaces + -- as an asynchronous error + local err, response = rspamd_http.request({ + url = string.format('http://site.resolveme:%d/request', http_port), + task = task, + method = 'get', + timeout = 1, + forbid_local = true, + }) + if err then + task:insert_result('HTTP_FORBID_DNS_ERROR', 1.0, err) + else + task:insert_result('HTTP_FORBID_DNS_' .. response.code, 1.0) + end +end +rspamd_config:register_symbol({ + name = 'FORBID_LOCAL_TEST', + score = 1.0, + callback = http_forbid_local_symbol, + no_squeeze = true, + flags = 'coro' +}) + rspamd_config:register_finish_script(finish) rspamd_config:add_on_load(function(cfg, ev_base, worker)