Commit 8954f1948 for llama.cpp

commit 8954f194837d2ec4d5f67f20c81b84abe63883ba
Author: Xuan-Son Nguyen <son@huggingface.co>
Date:   Thu Nov 12 12:33:48 2026 +0100

    server: fix SSRF for CORS proxy and media URLs (#32115)

diff --git a/common/arg.cpp b/common/arg.cpp
index 8527c8a82..2b968c735 100644
--- a/common/arg.cpp
+++ b/common/arg.cpp
@@ -3417,7 +3417,7 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
     add_opt(common_arg(
         {"--ui-mcp-proxy", "--webui-mcp-proxy"},
         {"--no-ui-mcp-proxy", "--no-webui-mcp-proxy"},
-        "experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)\n"
+        "experimental: whether to enable MCP CORS proxy, for the Web UI to reach public MCP servers without CORS headers; local and private addresses are refused - do not enable in untrusted environments (default: disabled)\n"
         "note: for security reasons, this will limit --cors-origins to localhost by default",
         [](common_params & params, bool value) {
             params.ui_mcp_proxy = value;
@@ -3676,6 +3676,13 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
             }
         }
     ).set_examples({LLAMA_EXAMPLE_SERVER}));
+    add_opt(common_arg(
+        {"--media-allow-private-urls"},
+        "allow http(s) media URLs (e.g. image_url) that point to local or private addresses (default: disabled, only public addresses)",
+        [](common_params & params) {
+            params.media_allow_private_urls = true;
+        }
+    ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_MEDIA_ALLOW_PRIVATE_URLS"));
     add_opt(common_arg(
         {"--models-dir"}, "PATH",
         "directory containing models for the router server (default: disabled)",
diff --git a/common/common.h b/common/common.h
index 2b51a36b3..dc34d31cc 100644
--- a/common/common.h
+++ b/common/common.h
@@ -723,6 +723,7 @@ struct common_params {

     std::string slot_save_path;
     std::string media_path; // path to directory for loading media files
+    bool media_allow_private_urls = false; // allow http(s) media URLs on local or private addresses

     float slot_prompt_similarity = 0.1f;

diff --git a/common/download.cpp b/common/download.cpp
index fbd192721..682fd6e3e 100644
--- a/common/download.cpp
+++ b/common/download.cpp
@@ -461,38 +461,55 @@ static int common_download_file_single_online(const std::string & url,
     return finish(success ? head->status : -1);
 }

-std::pair<long, std::vector<char>> common_remote_get_content(const std::string          & url,
+std::pair<long, std::vector<char>> common_remote_get_content(const std::string          & url_in,
                                                              const common_remote_params & params) {
-    auto [cli, parts] = common_http_client(url);
+    static constexpr int max_redirects = 5;
+    std::string url = url_in;
+    for (int n_redirects = 0; ; n_redirects++) {
+        auto [cli, parts] = common_http_client(url);
+
+        httplib::Headers headers;
+        for (const auto & h : params.headers) {
+            headers.emplace(h.first, h.second);
+        }
+        if (headers.find("User-Agent") == headers.end()) {
+            headers.emplace("User-Agent", "llama-cpp/" + std::string(llama_build_info()));
+        }

-    httplib::Headers headers;
-    for (const auto & h : params.headers) {
-        headers.emplace(h.first, h.second);
-    }
-    if (headers.find("User-Agent") == headers.end()) {
-        headers.emplace("User-Agent", "llama-cpp/" + std::string(llama_build_info()));
-    }
+        if (params.timeout > 0) {
+            cli.set_read_timeout(params.timeout, 0);
+            cli.set_write_timeout(params.timeout, 0);
+        }
+        if (params.resolve_host) {
+            // pin checked address, follow redirects manually to check each hop
+            cli.set_direct_hostname_addr_map({{parts.host, params.resolve_host(parts.host)}});
+            cli.set_follow_location(false);
+        }

-    if (params.timeout > 0) {
-        cli.set_read_timeout(params.timeout, 0);
-        cli.set_write_timeout(params.timeout, 0);
-    }
+        std::vector<char> buf;
+        auto res = cli.Get(parts.path, headers,
+            [&](const char *data, size_t len) {
+                buf.insert(buf.end(), data, data + len);
+                return params.max_size == 0 ||
+                       buf.size() <= static_cast<size_t>(params.max_size);
+            },
+            nullptr
+        );
+
+        if (!res) {
+            throw std::runtime_error("error: cannot make GET request");
+        }

-    std::vector<char> buf;
-    auto res = cli.Get(parts.path, headers,
-        [&](const char *data, size_t len) {
-            buf.insert(buf.end(), data, data + len);
-            return params.max_size == 0 ||
-                   buf.size() <= static_cast<size_t>(params.max_size);
-        },
-        nullptr
-    );
+        if (params.resolve_host && res->status >= 300 && res->status < 400 && res->has_header("Location")) {
+            if (n_redirects >= max_redirects) {
+                throw std::runtime_error("error: too many redirects");
+            }
+            url = common_http_resolve_location(url, res->get_header_value("Location"));
+            continue;
+        }

-    if (!res) {
-        throw std::runtime_error("error: cannot make GET request");
+        return { res->status, std::move(buf) };
     }
-
-    return { res->status, std::move(buf) };
 }

 int common_download_file_single(const std::string & url,
diff --git a/common/download.h b/common/download.h
index dd09d68ee..c0ff5da2f 100644
--- a/common/download.h
+++ b/common/download.h
@@ -31,6 +31,8 @@ struct common_remote_params {
     common_header_list headers;
     long timeout  = 0;           // in seconds, 0 means no timeout
     long max_size = 0;           // unlimited if 0
+    // if set: called per hop (redirects too), returns the address to connect to, throws to refuse
+    std::function<std::string(const std::string & host)> resolve_host;
 };

 // get remote file content, returns <http_code, raw_response_body>
diff --git a/common/http.h b/common/http.h
index 10d017f17..d8cfde262 100644
--- a/common/http.h
+++ b/common/http.h
@@ -99,6 +99,26 @@ static common_http_url common_http_parse_url(const std::string & url) {
     return parts;
 }

+// resolve a redirect Location against the URL it came from
+static std::string common_http_resolve_location(const std::string & base_url, const std::string & location) {
+    if (location.find("://") != std::string::npos) {
+        return location;
+    }
+    const common_http_url base = common_http_parse_url(base_url);
+    if (location.compare(0, 2, "//") == 0) {
+        return base.scheme + ":" + location;
+    }
+    const std::string origin = base.scheme + "://" + common_http_format_host(base.host) + ":" + std::to_string(base.port);
+    const std::string path = base.path.substr(0, base.path.find('?'));
+    if (!location.empty() && location[0] == '/') {
+        return origin + location;
+    }
+    if (!location.empty() && location[0] == '?') {
+        return origin + path + location;
+    }
+    return origin + path.substr(0, path.rfind('/') + 1) + location;
+}
+
 // match a loopback destination: a remote proxy can never reach the client machine's own
 // loopback, so these hosts are always contacted directly, before NO_PROXY is consulted
 // (similar to Go's net/http)
diff --git a/tools/server/README.md b/tools/server/README.md
index a160342fe..03439c5e0 100644
--- a/tools/server/README.md
+++ b/tools/server/README.md
@@ -203,7 +203,7 @@ For the full list of features, please refer to [server's changelog](https://gith
 | `--api-prefix PREFIX` | prefix path the server serves from, without the trailing slash (default: )<br/>(env: LLAMA_ARG_API_PREFIX) |
 | `--ui-config, --webui-config JSON` | JSON that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG) |
 | `--ui-config-file, --webui-config-file PATH` | JSON file that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG_FILE) |
-| `--ui-mcp-proxy, --webui-mcp-proxy, --no-ui-mcp-proxy, --no-webui-mcp-proxy` | experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_UI_MCP_PROXY) |
+| `--ui-mcp-proxy, --webui-mcp-proxy, --no-ui-mcp-proxy, --no-webui-mcp-proxy` | experimental: whether to enable MCP CORS proxy, for the Web UI to reach public MCP servers without CORS headers; local and private addresses are refused - do not enable in untrusted environments (default: disabled)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_UI_MCP_PROXY) |
 | `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)<br/>specify "all" to enable all tools<br/>available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_info<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_TOOLS) |
 | `--tools-runtime OPTION` | experimental: run tools in a separate runtime environment (default: none, use host environment)<br/>available options:<br/>  'docker:<image>', 'podman:<image>': spin up a new container and reuse it for all invocations, clean up on server exit<br/>  'docker-container:<id>', 'podman-container:<id>': use an existing container by ID, won't stop on server exit<br/>  'ssh:<target>': run tools on a remote POSIX host over SSH, key-based auth and a trusted host key are required<br/><br/>(env: LLAMA_ARG_TOOLS_RUNTIME) |
 | `--allow-unauthenticated-tools` | allow server tools, MCP servers or MCP proxy on a non-loopback address without an API key - only for trusted networks (default: disabled)<br/>(env: LLAMA_ARG_ALLOW_UNAUTHENTICATED_TOOLS) |
@@ -230,6 +230,7 @@ For the full list of features, please refer to [server's changelog](https://gith
 | `--slots, --no-slots` | expose slots monitoring endpoint (default: enabled)<br/>(env: LLAMA_ARG_ENDPOINT_SLOTS) |
 | `--slot-save-path PATH` | path to save slot kv cache (default: disabled)<br/>(env: LLAMA_ARG_SLOT_SAVE_PATH) |
 | `--media-path PATH` | directory for loading local media files; files can be accessed via file:// URLs using relative paths (default: disabled) |
+| `--media-allow-private-urls` | allow http(s) media URLs (e.g. image_url) that point to local or private addresses (default: disabled, only public addresses)<br/>(env: LLAMA_ARG_MEDIA_ALLOW_PRIVATE_URLS) |
 | `--models-dir PATH` | directory containing models for the router server (default: disabled)<br/>(env: LLAMA_ARG_MODELS_DIR) |
 | `--models-preset PATH` | path to INI file containing model presets for the router server (default: disabled)<br/>(env: LLAMA_ARG_MODELS_PRESET) |
 | `--models-max N` | for router server, maximum number of models to load simultaneously (default: 4, 0 = unlimited)<br/>(env: LLAMA_ARG_MODELS_MAX) |
@@ -393,7 +394,7 @@ Every server is spawned once at startup to list its tools, then stopped, and res

 The child process runs with the same privileges as the server, so only declare commands you trust. As with `--tools`, `--cors-origins` then defaults to `localhost`.

-Note: `--ui-mcp-proxy` is unrelated, it only lets the Web UI reach remote MCP servers from the browser.
+Note: `--ui-mcp-proxy` is unrelated, it only lets the Web UI reach public MCP servers that do not send CORS headers. It refuses local and private addresses (loopback, LAN, link-local); connect to those from the Web UI directly.

 Any server written against the [MCP specification](https://modelcontextprotocol.io) works as is, whether it uses an official SDK or not: the transport is one JSON-RPC message per line on stdio, so a script wrapping an existing program is a valid server too.

diff --git a/tools/server/server-common.cpp b/tools/server/server-common.cpp
index 5bf2ec12a..0013c09cd 100644
--- a/tools/server/server-common.cpp
+++ b/tools/server/server-common.cpp
@@ -9,6 +9,8 @@

 #include "server-common.h"

+#include <cpp-httplib/httplib.h>
+
 #include <algorithm>
 #include <cmath>
 #include <filesystem>
@@ -1215,10 +1217,55 @@ json oaicompat_completion_params_parse(const json & body) {
 // - file:// for local files (only allowed if media_path is set)
 // - data: for base64 encoded data with uri scheme (e.g. data:image/png;base64,...)
 // - raw base64 encoded data
+static bool ipv4_is_public(const unsigned char * b) {
+    return !(b[0] == 0 || b[0] == 10 || b[0] == 127 || b[0] >= 224 // this network, private, loopback, multicast, reserved
+        || (b[0] == 100 && (b[1] & 0xc0) == 64)                    // 100.64.0.0/10 CGNAT
+        || (b[0] == 169 && b[1] == 254)                            // link-local, cloud metadata
+        || (b[0] == 172 && (b[1] & 0xf0) == 16)                    // 172.16.0.0/12
+        || (b[0] == 192 && b[1] == 168)                            // 192.168.0.0/16
+        || (b[0] == 192 && b[1] == 0 && b[2] == 0)                 // 192.0.0.0/24
+        || (b[0] == 198 && (b[1] & 0xfe) == 18));                  // 198.18.0.0/15
+}
+
+bool server_addr_is_public(const std::string & addr) {
+    unsigned char b[16];
+    if (inet_pton(AF_INET, addr.c_str(), b) == 1) {
+        return ipv4_is_public(b);
+    }
+    if (inet_pton(AF_INET6, addr.c_str(), b) != 1) {
+        return false; // e.g. scoped address "fe80::1%eth0"
+    }
+    static const unsigned char v4_mapped[12] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff};
+    static const unsigned char nat64[12]     = {0, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0};
+    static const unsigned char zero[12]      = {};
+    if (memcmp(b, v4_mapped, 12) == 0 || memcmp(b, nat64, 12) == 0) {
+        return ipv4_is_public(b + 12);
+    }
+    return memcmp(b, zero, 12) != 0                 // ::/96: unspecified, loopback, IPv4-compatible
+        && (b[0] & 0xfe) != 0xfc                    // fc00::/7 unique local
+        && !(b[0] == 0xfe && (b[1] & 0x80) == 0x80) // fe80::/10 link-local, fec0::/10 site-local
+        && b[0] != 0xff;                            // multicast
+}
+
+std::string server_resolve_public_addr(const std::string & host, bool allow_private) {
+    std::vector<std::string> addrs;
+    httplib::hosted_at(host, addrs);
+    if (addrs.empty()) {
+        throw std::invalid_argument("cannot resolve host: " + host);
+    }
+    for (const auto & addr : addrs) {
+        if (!allow_private && !server_addr_is_public(addr)) {
+            throw std::invalid_argument("host is not a public address: " + host);
+        }
+    }
+    return addrs[0];
+}
+
 void handle_media(
         std::vector<raw_buffer> & out_files,
         const std::string & url,
-        const std::string & media_path) {
+        const std::string & media_path,
+        bool allow_private_urls) {
     if (!media_path.empty()) {
         // should already be enforced by arg.cpp, but checking just in case
         GGML_ASSERT(media_path.back() == DIRECTORY_SEPARATOR);
@@ -1230,6 +1277,9 @@ void handle_media(
         common_remote_params params;
         params.max_size = 1024 * 1024 * 10; // 10MB
         params.timeout  = 10; // seconds
+        params.resolve_host = [allow_private_urls](const std::string & host) {
+            return server_resolve_public_addr(host, allow_private_urls);
+        };
         SRV_INF("downloading image from '%s'\n", url.c_str());
         auto res = common_remote_get_content(url, params);
         if (200 <= res.first && res.first < 300) {
@@ -1296,7 +1346,7 @@ static void oaicompat_content_load_media(json & content, const server_chat_param

             json image_url = json_value(p, "image_url", json::object());
             std::string url = json_value(image_url, "url", std::string());
-            handle_media(out_files, url, opt.media_path);
+            handle_media(out_files, url, opt.media_path, opt.media_allow_private_urls);

             p["type"] = "media_marker";
             p["text"] = get_media_marker();
@@ -1311,7 +1361,7 @@ static void oaicompat_content_load_media(json & content, const server_chat_param
             json input_audio = json_value(p, "input_audio", json::object());
             std::string url  = json_value(input_audio, "data",
                                     json_value(input_audio, "url", std::string()));
-            handle_media(out_files, url, opt.media_path);
+            handle_media(out_files, url, opt.media_path, opt.media_allow_private_urls);

             p["type"] = "media_marker";
             p["text"] = get_media_marker();
@@ -1326,7 +1376,7 @@ static void oaicompat_content_load_media(json & content, const server_chat_param
             json input_video = json_value(p, type, json::object());
             std::string url  = json_value(input_video, "data",
                                     json_value(input_video, "url", std::string()));
-            handle_media(out_files, url, opt.media_path);
+            handle_media(out_files, url, opt.media_path, opt.media_allow_private_urls);

             p["type"] = "media_marker";
             p["text"] = get_media_marker();
diff --git a/tools/server/server-common.h b/tools/server/server-common.h
index ca870465a..05622849a 100644
--- a/tools/server/server-common.h
+++ b/tools/server/server-common.h
@@ -299,11 +299,19 @@ llama_tokens tokenize_mixed(const llama_vocab * vocab, const json & json_prompt,
 // if validate_utf8(text) == text.size(), then the whole text is valid utf8
 size_t validate_utf8(const std::string& text);

+// true if the address is publicly routable
+bool server_addr_is_public(const std::string & addr);
+
+// resolve host to an address to connect to; throws if not resolvable or not public (unless allow_private)
+std::string server_resolve_public_addr(const std::string & host, bool allow_private);
+
 // load a media file from an URL (http, file, data) or from raw base64 data
+// http(s) URLs must resolve to public addresses unless allow_private_urls
 void handle_media(
         std::vector<raw_buffer> & out_files,
         const std::string & url,
-        const std::string & media_path);
+        const std::string & media_path,
+        bool allow_private_urls = false);

 // process mtmd prompt, return the server_tokens containing both text tokens and media chunks
 // if is_placeholder is true, the media chunk will be treated as placeholder for counting tokens; the output tokens are not usable for actual inference (e.g. for submitting a task to server_queue)
@@ -315,7 +323,7 @@ server_tokens process_mtmd_prompt(
                                           bool is_placeholder = false);

 // decode one media url (http/file/data-uri) into out_files; throws on failure
-void handle_media(std::vector<raw_buffer> & out_files, const std::string & url, const std::string & media_path);
+void handle_media(std::vector<raw_buffer> & out_files, const std::string & url, const std::string & media_path, bool allow_private_urls);

 /**
  * break the input "prompt" object into multiple prompt if needed, then tokenize them
@@ -365,6 +373,7 @@ struct server_chat_params {
     int  reasoning_budget = -1;
     std::string reasoning_budget_message;
     std::string media_path;
+    bool media_allow_private_urls = false;
     bool force_pure_content = false;
 };

diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
index effa5bbda..a6059ab38 100644
--- a/tools/server/server-context.cpp
+++ b/tools/server/server-context.cpp
@@ -1739,6 +1739,7 @@ private:
                 /* reasoning_budget      */ params_base.sampling.reasoning_budget_tokens,
                 /* reasoning_budget_msg  */ params_base.sampling.reasoning_budget_message,
                 /* media_path            */ params_base.media_path,
+                /* media_allow_private   */ params_base.media_allow_private_urls,
                 /* force_pure_content    */ params_base.force_pure_content_parser
             };

@@ -6951,7 +6952,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_rerank_impl(const se
         auto add_image = [&](const std::string & url) {
             // marker is appended by format_prompt_rerank, one per decoded file
             try {
-                handle_media(out_files, url, params.media_path);
+                handle_media(out_files, url, params.media_path, params.media_allow_private_urls);
             } catch (const std::runtime_error & e) {
                 throw std::invalid_argument(e.what());
             }
diff --git a/tools/server/server-cors-proxy.h b/tools/server/server-cors-proxy.h
index 3600a33ec..41d007404 100644
--- a/tools/server/server-cors-proxy.h
+++ b/tools/server/server-cors-proxy.h
@@ -11,6 +11,7 @@
 #include <cctype>

 #include "server-http.h"
+#include "server-common.h"

 static std::string proxy_header_to_lower(std::string header) {
     std::transform(header.begin(), header.end(), header.begin(), [](unsigned char c) {
@@ -68,9 +69,20 @@ static server_http_res_ptr proxy_request(const server_http_req & req, std::strin
             req.files,
             req.should_stop,
             600, // timeout_read (default to 10 minutes)
-            600  // timeout_write (default to 10 minutes)
+            600, // timeout_write (default to 10 minutes)
+            // test-only escape hatch
+            server_resolve_public_addr(parsed_url.host, !common_get_env("LLAMA_SERVER_DEBUG_PROXY_ALLOW_PRIVATE").empty())
             );

+    // route redirects back through the proxy, so each hop is checked
+    if (proxy->status >= 300 && proxy->status < 400) {
+        for (auto & [key, value] : proxy->headers) {
+            if (proxy_header_to_lower(key) == "location") {
+                value = "?url=" + httplib::encode_uri_component(common_http_resolve_location(target_url, value));
+            }
+        }
+    }
+
     return proxy;
 }

diff --git a/tools/server/server-models.cpp b/tools/server/server-models.cpp
index 5d052d62b..5b7d34501 100644
--- a/tools/server/server-models.cpp
+++ b/tools/server/server-models.cpp
@@ -2656,7 +2656,8 @@ server_http_proxy::server_http_proxy(
         const std::map<std::string, uploaded_file> & files,
         const std::function<bool()> should_stop,
         int32_t timeout_read,
-        int32_t timeout_write
+        int32_t timeout_write,
+        const std::string & resolved_addr
         ) {
     // shared between reader and writer threads
     auto cli  = std::make_shared<httplib::ClientImpl>(host, port);
@@ -2671,7 +2672,11 @@ server_http_proxy::server_http_proxy(
     }

     // setup Client
-    cli->set_follow_location(true);
+    if (!resolved_addr.empty()) {
+        // pin the checked address; a redirect could lead to an unchecked one
+        cli->set_direct_hostname_addr_map({{host, resolved_addr}});
+    }
+    cli->set_follow_location(resolved_addr.empty());
     cli->set_connection_timeout(timeout_read, 0); // use --timeout value instead of hardcoded 5 s
     cli->set_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
     cli->set_read_timeout(timeout_write, 0);
diff --git a/tools/server/server-models.h b/tools/server/server-models.h
index 855e147b3..e18ae3a31 100644
--- a/tools/server/server-models.h
+++ b/tools/server/server-models.h
@@ -444,7 +444,8 @@ struct server_http_proxy : server_http_res {
                       const std::map<std::string, uploaded_file> & files,
                       const std::function<bool()> should_stop,
                       int32_t timeout_read,
-                      int32_t timeout_write
+                      int32_t timeout_write,
+                      const std::string & resolved_addr = "" // if set: connect to this address only, do not follow redirects
                       );
     ~server_http_proxy() {
         if (cleanup_pipes) {
diff --git a/tools/server/tests/unit/test_proxy.py b/tools/server/tests/unit/test_proxy.py
index cb439b7a4..58fd8c824 100644
--- a/tools/server/tests/unit/test_proxy.py
+++ b/tools/server/tests/unit/test_proxy.py
@@ -35,6 +35,7 @@ def test_mcp_proxy():
 def test_mcp_proxy_custom_port():
     global server
     server.ui_mcp_proxy = True
+    server.debug_proxy_allow_private = True
     server.start()

     # try getting the server's models API via the proxy
@@ -60,6 +61,7 @@ def test_mcp_proxy_no_content():
     try:
         global server
         server.ui_mcp_proxy = True
+        server.debug_proxy_allow_private = True
         server.start()

         res = server.make_request("POST", f"/cors-proxy?url=http://127.0.0.1:{target.server_port}/", data={})
@@ -69,3 +71,62 @@ def test_mcp_proxy_no_content():
         target.shutdown()
         target.server_close()

+
+
+@pytest.mark.parametrize("target", [
+    "http://127.0.0.1:{port}/models",
+    "http://localhost:{port}/models",
+    "http://[::1]:{port}/models",
+    "http://[::ffff:127.0.0.1]:{port}/models",
+    "http://10.0.0.1/",
+    "http://172.16.0.1/",
+    "http://192.168.1.1/",
+    "http://100.64.0.1/",
+    "http://169.254.169.254/latest/meta-data/", # cloud metadata
+    "http://0.0.0.0:{port}/models",
+])
+def test_mcp_proxy_refuses_non_public_targets(target: str):
+    global server
+    server.ui_mcp_proxy = True
+    server.start()
+    target = target.format(port=server.server_port)
+    res = server.make_request("GET", f"/cors-proxy?url={target}")
+    assert res.status_code == 400
+
+
+def test_mcp_proxy_redirect_goes_through_proxy():
+    class RedirectHandler(BaseHTTPRequestHandler):
+        def do_GET(self):
+            if self.path == "/start":
+                self.send_response(302)
+                self.send_header("Location", "/next")
+                self.end_headers()
+            else:
+                self.send_response(200)
+                self.end_headers()
+                self.wfile.write(b"final")
+
+        def log_message(self, format, *args):
+            pass
+
+    target = ThreadingHTTPServer(("127.0.0.1", 0), RedirectHandler)
+    target_thread = threading.Thread(target=target.serve_forever, daemon=True)
+    target_thread.start()
+
+    try:
+        global server
+        server.ui_mcp_proxy = True
+        server.debug_proxy_allow_private = True
+        server.start()
+
+        url = server.make_url(f"/cors-proxy?url=http://127.0.0.1:{target.server_port}/start")
+        # redirect points back to the proxy
+        res = requests.get(url, allow_redirects=False)
+        assert res.status_code == 302
+        assert res.headers["Location"] == f"?url=http%3A%2F%2F127.0.0.1%3A{target.server_port}%2Fnext"
+        res = requests.get(url)
+        assert res.status_code == 200
+        assert res.text == "final"
+    finally:
+        target.shutdown()
+        target.server_close()
diff --git a/tools/server/tests/unit/test_security.py b/tools/server/tests/unit/test_security.py
index eca78ea9d..ceec582ac 100644
--- a/tools/server/tests/unit/test_security.py
+++ b/tools/server/tests/unit/test_security.py
@@ -314,6 +314,7 @@ def test_cors_proxy_only_forwards_explicit_proxy_headers():
         server = ServerPreset.tinyllama2()
         server.api_key = TEST_API_KEY
         server.ui_mcp_proxy = True
+        server.debug_proxy_allow_private = True
         server.start()

         res = server.make_request("GET", f"/cors-proxy?url=http://127.0.0.1:{target.server_port}/capture", headers={
diff --git a/tools/server/tests/unit/test_vision_api.py b/tools/server/tests/unit/test_vision_api.py
index 96d340746..cad45ee9b 100644
--- a/tools/server/tests/unit/test_vision_api.py
+++ b/tools/server/tests/unit/test_vision_api.py
@@ -204,3 +204,41 @@ def test_vision_embeddings_oai_content():
     # same prompt and image in both formats
     assert data[0]["embedding"] == data[1]["embedding"]
     assert data[0]["embedding"] != data[2]["embedding"]
+
+
+@pytest.mark.parametrize("allow_private", [False, True])
+def test_vision_image_url_private_address(allow_private: bool):
+    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+    import threading
+    img = requests.get(get_img_url("IMG_URL_0")).content
+
+    class ImageHandler(BaseHTTPRequestHandler):
+        def do_GET(self):
+            self.send_response(200)
+            self.send_header("Content-Type", "image/png")
+            self.end_headers()
+            self.wfile.write(img)
+
+        def log_message(self, format, *args):
+            pass
+
+    target = ThreadingHTTPServer(("127.0.0.1", 0), ImageHandler)
+    threading.Thread(target=target.serve_forever, daemon=True).start()
+    try:
+        global server
+        server.media_allow_private_urls = allow_private
+        server.start()
+        res = server.make_request("POST", "/chat/completions", data={
+            "temperature": 0.0,
+            "top_k": 1,
+            "messages": [
+                {"role": "user", "content": [
+                    {"type": "text", "text": "What is this:\n"},
+                    {"type": "image_url", "image_url": {"url": f"http://127.0.0.1:{target.server_port}/img.png"}},
+                ]},
+            ],
+        })
+        assert res.status_code == (200 if allow_private else 400)
+    finally:
+        target.shutdown()
+        target.server_close()
diff --git a/tools/server/tests/utils.py b/tools/server/tests/utils.py
index 8009b4c1f..71470c2b6 100644
--- a/tools/server/tests/utils.py
+++ b/tools/server/tests/utils.py
@@ -122,6 +122,8 @@ class ServerProcess:
     no_cache_idle_slots: bool = False
     log_path: str | None = None
     ui_mcp_proxy: bool = False
+    debug_proxy_allow_private: bool = False # test-only: lets the CORS proxy reach local servers
+    media_allow_private_urls: bool = False
     allow_unauthenticated_tools: bool = False
     backend_sampling: bool = False
     gcp_compat: bool = False
@@ -149,6 +151,8 @@ class ServerProcess:
             **os.environ,
             "LLAMA_SERVER_DEBUG_FAKE_TIMING": "1",
         }
+        if self.debug_proxy_allow_private:
+            env["LLAMA_SERVER_DEBUG_PROXY_ALLOW_PRIVATE"] = "1"
         if self.llama_cache is not None:
             env["LLAMA_CACHE"] = self.llama_cache
         elif "LLAMA_CACHE" not in os.environ:
@@ -303,6 +307,8 @@ class ServerProcess:
             server_args.append("--no-cache-idle-slots")
         if self.ui_mcp_proxy:
             server_args.append("--ui-mcp-proxy")
+        if self.media_allow_private_urls:
+            server_args.append("--media-allow-private-urls")
         if self.allow_unauthenticated_tools:
             server_args.append("--allow-unauthenticated-tools")
         if self.server_tools: