]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] lua_http: forbid_local option to block requests to local networks
authorVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 23 Jul 2026 13:13:18 +0000 (14:13 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 23 Jul 2026 13:13:18 +0000 (14:13 +0100)
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.

src/lua/lua_http.c
src/plugins/lua/url_redirector.lua
test/functional/cases/220_http.robot
test/functional/configs/url_redirector.conf
test/functional/configs/url_redirector_chain.conf
test/functional/configs/url_redirector_no_intermediate.conf
test/functional/configs/url_redirector_phishing.conf
test/functional/lua/http.lua

index 2ea48698bab45506559105c7eb8b6cc00f501b6d..20faabc621fdf6161a6c96cb8a933fb254934046 100644 (file)
@@ -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);
 
index 61b3e910585bcfb7931deb2ca4592744027ceb57..3d9ca34eb75e547ec771ed222b95d07e8ea50344 100644 (file)
@@ -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
index 1e2a08451f39daec638585d1f947b4b255c29621..2f14f23c04e90ccd16ae21c80b39011729a01adf 100644 (file)
@@ -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
index e6009ba665a573d14ab92070e78ad01a374a1361..8bc45f419a35106d4c07289adcbbf87275b1d630 100644 (file)
@@ -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;
 }
index 108d033866a4842401b48e4a89f121e859db6df1..926ba2fc96d3eaafcd4b694fb61feda918c62708 100644 (file)
@@ -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;
index b3276ea5320c6c79ce57c87d922f15ce554b33e3..cfa43dbb585b322446fe01b27c8f07fdee988034 100644 (file)
@@ -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;
index 5feefe14a46ec85c1d6fd8af06632965110dc915..4501ec25676c37c4b24ad329ca2c31da5ec15ade 100644 (file)
@@ -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";
 }
 
index 2fc8d977c4c6b01f246a76d426590faeec7233dc..c5e2df8ed98932d109746edf989b1be7e457f3a2 100644 (file)
@@ -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)