Commit 2cbe0ec42 for llama.cpp

commit 2cbe0ec429b849a88664e0f62388fc92a5abd699
Author: Xuan-Son Nguyen <son@huggingface.co>
Date:   Wed Nov 11 21:17:46 2026 +0100

    server: fix --cors-origins handling + protect against DNS rebinding (#32098)

    * server: fix --cors-origins localhost handling

    * server: validate Host on loopback connections against DNS rebinding

    A page whose own name resolves to 127.0.0.1 reaches the server with
    that name in Host and a matching Origin, so the same-origin shortcut
    let it through. On a loopback connection, Host must now name loopback
    or the host of an allowed --cors-origins entry. The LAN tests reach
    the server through a real interface instead of a forged Host.

    * fix python type check

    ---------

    Co-authored-by: Pascal <admin@serveurperso.com>

diff --git a/tools/server/server-http.cpp b/tools/server/server-http.cpp
index cf147c7e7..23eb471aa 100644
--- a/tools/server/server-http.cpp
+++ b/tools/server/server-http.cpp
@@ -6,6 +6,9 @@

 #include <cpp-httplib/httplib.h>

+#include <algorithm>
+#include <cctype>
+#include <cstdio>
 #include <functional>
 #include <future>
 #include <memory>
@@ -81,6 +84,99 @@ static bool origin_is_localhost(const std::string & origin) {
     }
 }

+static std::string ascii_lower(std::string s) {
+    std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
+    return s;
+}
+
+// true if the text is a dotted IPv4 address in 127.0.0.0/8
+static bool ipv4_is_loopback(const std::string & s) {
+    unsigned a, b, c, d;
+    char tail;
+    return std::sscanf(s.c_str(), "%u.%u.%u.%u%c", &a, &b, &c, &d, &tail) == 4 && a == 127 && b < 256 && c < 256 && d < 256;
+}
+
+// true if the address is loopback: 127.0.0.0/8, ::1 or an IPv4 mapped 127.0.0.0/8
+static bool addr_is_loopback(const std::string & addr) {
+    static const std::string mapped = "::ffff:";
+    if (addr.compare(0, mapped.size(), mapped) == 0) {
+        return ipv4_is_loopback(addr.substr(mapped.size()));
+    }
+    return addr == "::1" || ipv4_is_loopback(addr);
+}
+
+// the name of a Host header value, lowercase, without port and IPv6 brackets
+static std::string host_name(const std::string & host) {
+    if (!host.empty() && host[0] == '[') {
+        const size_t end = host.find(']');
+        return end == std::string::npos ? "" : ascii_lower(host.substr(1, end - 1));
+    }
+    return ascii_lower(host.substr(0, host.find(':')));
+}
+
+// DNS rebinding guard: a page whose own name resolves to 127.0.0.1 reaches a loopback
+// connection with that name in Host, so Host names loopback or the host of an allowed origin
+static bool request_host_is_allowed(const std::string & cors_origins, const httplib::Request & req) {
+    if (!addr_is_loopback(req.local_addr)) {
+        return true;
+    }
+    const std::string host = req.get_header_value("Host");
+    const std::string name = host_name(host);
+    // the unspecified addresses only reach this machine, e.g. curl http://0.0.0.0:8080
+    if (name == "localhost" || name == "0.0.0.0" || name == "::" || addr_is_loopback(name)) {
+        return true;
+    }
+    if (cors_origins == "localhost") {
+        return false;
+    }
+    for (const auto & entry : string_split<std::string>(cors_origins, ',')) {
+        const std::string allowed = string_strip(entry);
+        const size_t sep = allowed.find("://");
+        if (sep != std::string::npos && ascii_lower(allowed.substr(sep + 3)) == ascii_lower(host)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+// CSRF guard when CORS is not "*"
+// no ACAO header only hides the response, browsers still send simple requests (text/plain POST, no-cors GET), so reject them here
+static bool request_origin_is_allowed(const std::string & cors_origins, const httplib::Request & req, bool is_frontend_path) {
+    if (cors_origins == "*") {
+        return true;
+    }
+
+    if (!request_host_is_allowed(cors_origins, req)) {
+        return false;
+    }
+
+    const std::string origin = req.get_header_value("Origin");
+    if (!origin.empty()) {
+        // same-origin, e.g. UI opened via LAN address
+        const std::string host = req.get_header_value("Host");
+        const size_t sep = origin.find("://");
+        if (!host.empty() && sep != std::string::npos && ascii_lower(origin.substr(sep + 3)) == ascii_lower(host)) {
+            return true;
+        }
+        if (cors_origins == "localhost") {
+            return origin_is_localhost(origin);
+        }
+        for (const auto & allowed : string_split<std::string>(cors_origins, ',')) {
+            if (string_strip(allowed) == origin) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // no Origin: non-browser client or browser no-cors GET; if cross-site, only allow navigation to the UI
+    const std::string site = req.get_header_value("Sec-Fetch-Site");
+    if (site == "cross-site" || site == "same-site") {
+        return req.method == "GET" && req.get_header_value("Sec-Fetch-Mode") == "navigate" && is_frontend_path;
+    }
+    return true;
+}
+
 // For Google Cloud Platform deployment compatibility
 struct gcp_params {
     bool enabled;
@@ -352,6 +448,23 @@ bool server_http_context::init_listener(const common_params & params) {
             res.set_content("", "text/html"); // blank response, no data
             return httplib::Server::HandlerResponse::Handled; // skip further processing
         }
+        if (!request_origin_is_allowed(params.cors_origins, req, frontend_paths.count(req.path) > 0)) {
+            SRV_WRN("(CORS) rejected cross-origin request: %s %s, host = '%s', origin = '%s', sec-fetch-site = '%s'\n",
+                    req.method.c_str(), req.path.c_str(), req.get_header_value("Host").c_str(),
+                    req.get_header_value("Origin").c_str(), req.get_header_value("Sec-Fetch-Site").c_str());
+            res.status = 403;
+            res.set_content(
+                safe_json_to_str(json {
+                    {"error", {
+                        {"message", "Cross-origin request not allowed (see --cors-origins)"},
+                        {"type", "permission_error"},
+                        {"code", 403}
+                    }}
+                }),
+                "application/json; charset=utf-8"
+            );
+            return httplib::Server::HandlerResponse::Handled;
+        }
         if (!middleware_server_state(req, res)) {
             return httplib::Server::HandlerResponse::Handled;
         }
diff --git a/tools/server/server-models.cpp b/tools/server/server-models.cpp
index dc277ead2..93425fec0 100644
--- a/tools/server/server-models.cpp
+++ b/tools/server/server-models.cpp
@@ -265,6 +265,23 @@ private:
 // delete). distinct from params.timeout_read/write which only applies to the generation proxy
 static constexpr int STREAM_LOOKUP_TIMEOUT_MS = 250;

+// router already checked the origin; child sees a loopback Host, so do not forward origin headers
+static bool is_browser_origin_header(const std::string & key) {
+    std::string lower = key;
+    std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
+    return lower == "origin" || lower.compare(0, 10, "sec-fetch-") == 0;
+}
+
+static std::map<std::string, std::string> child_request_headers(const std::map<std::string, std::string> & headers) {
+    std::map<std::string, std::string> out;
+    for (const auto & [key, value] : headers) {
+        if (!is_browser_origin_header(key)) {
+            out.emplace(key, value);
+        }
+    }
+    return out;
+}
+
 static void unset_reserved_args(common_preset & preset, bool unset_model_args) {
     preset.unset_option("LLAMA_ARG_SSL_KEY_FILE");
     preset.unset_option("LLAMA_ARG_SSL_CERT_FILE");
@@ -1714,7 +1731,7 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
             CHILD_ADDR,
             meta->port,
             proxy_path,
-            req.headers,
+            child_request_headers(req.headers),
             req.body,
             req.files,
             // a detached request belongs to a replay session
@@ -1769,7 +1786,8 @@ void server_models::proxy_ws(const server_http_req & req, server_http_context::w
     for (const auto & [key, value] : req.headers) {
         std::string lower = key;
         std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
-        if (lower == "host" || lower == "connection" || lower == "upgrade" || lower.compare(0, 14, "sec-websocket-") == 0) {
+        if (lower == "host" || lower == "connection" || lower == "upgrade" || lower.compare(0, 14, "sec-websocket-") == 0
+                || is_browser_origin_header(key)) {
             continue;
         }
         headers.emplace(key, value);
@@ -2410,7 +2428,7 @@ void server_models_routes::init_routes() {
                 CHILD_ADDR,
                 owner->port,
                 child_path,
-                req.headers,
+                child_request_headers(req.headers),
                 req.body,
                 req.files,
                 req.should_stop,
diff --git a/tools/server/tests/unit/test_router.py b/tools/server/tests/unit/test_router.py
index bd5fb785d..d7dde1117 100644
--- a/tools/server/tests/unit/test_router.py
+++ b/tools/server/tests/unit/test_router.py
@@ -929,3 +929,27 @@ def test_router_download_drops_stale_partials():

     _wait_for_model_status(MODEL_DOWNLOAD_ID, {"downloaded", "unloaded"}, timeout=MODEL_DOWNLOAD_TIMEOUT)
     server.make_request("DELETE", f"/models?model={MODEL_DOWNLOAD_ID}")
+
+
+def test_router_restricted_cors_same_origin_reaches_child():
+    # same-origin request via LAN address must not be refused by the child
+    global server
+    lan_ip = get_lan_ip()
+    if lan_ip is None:
+        pytest.skip("no non-loopback interface")  # ty: ignore[too-many-positional-arguments]
+    server.cors_origins = "localhost"
+    server.server_host = "0.0.0.0"
+    server.start()
+    body = {
+        "model": "ggml-org/tinygemma3-GGUF:Q8_0",
+        "max_tokens": 4,
+        "messages": [{"role": "user", "content": "hello"}],
+    }
+    res = requests.post(server.make_url("/chat/completions", host=lan_ip), json=body, headers={
+        "Origin": f"http://{lan_ip}:{server.server_port}",
+        "Sec-Fetch-Site": "same-origin",
+    })
+    assert res.status_code == 200, res.text
+
+    res = requests.post(server.make_url("/chat/completions", host="127.0.0.1"), json=body, headers={"Origin": "http://evil.com"})
+    assert res.status_code == 403
diff --git a/tools/server/tests/unit/test_security.py b/tools/server/tests/unit/test_security.py
index 36fc439f9..4113c1254 100644
--- a/tools/server/tests/unit/test_security.py
+++ b/tools/server/tests/unit/test_security.py
@@ -171,6 +171,131 @@ def test_cors_origins_defaults_to_localhost_with_tools_enabled():
     assert "Access-Control-Allow-Origin" not in res.headers


+def _raw_post(path: str, body: str, headers: dict) -> requests.Response:
+    # text/plain: "simple" request, no CORS preflight
+    return requests.post(server.make_url(path), data=body, headers={"Content-Type": "text/plain", **headers})
+
+
+@pytest.mark.parametrize("headers,expected_status", [
+    ({}, 200),                                                    # non-browser client
+    ({"Origin": "http://localhost:3000"}, 200),                   # allowed localhost origin
+    ({"Origin": "http://evil.com"}, 403),                         # cross-origin simple request
+    ({"Origin": "http://localhost.evil.com"}, 403),
+    ({"Origin": "null"}, 403),                                    # sandboxed iframe / file://
+    ({"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "no-cors"}, 403),
+])
+def test_cors_origins_localhost_blocks_simple_requests(headers: dict, expected_status: int):
+    global server
+    server.api_key = None
+    server.cors_origins = "localhost"
+    server.start()
+    res = _raw_post("/tokenize", '{"content": "hello"}', headers)
+    assert res.status_code == expected_status
+
+
+def test_cors_origins_localhost_allows_same_origin():
+    global server
+    lan_ip = get_lan_ip()
+    if lan_ip is None:
+        pytest.skip("no non-loopback interface")  # ty: ignore[too-many-positional-arguments]
+    server.api_key = None
+    server.cors_origins = "localhost"
+    server.server_host = "0.0.0.0"
+    server.start()
+    # UI opened via LAN address: Origin matches Host
+    url = server.make_url("/tokenize", host=lan_ip)
+    res = requests.post(url, data='{"content": "hello"}', headers={
+        "Content-Type": "text/plain",
+        "Origin": f"http://{lan_ip}:{server.server_port}",
+    })
+    assert res.status_code == 200
+
+
+@pytest.mark.parametrize("host,headers,expected_status", [
+    ("localhost",       {}, 200),
+    ("127.0.0.1",       {}, 200),
+    ("127.0.0.2",       {}, 200),
+    ("[::1]",           {}, 200),
+    ("0.0.0.0",         {}, 200),
+    ("evil.com",        {"Origin": "http://evil.com:{port}"}, 403),     # rebinding, Origin matches Host
+    ("evil.com",        {"Sec-Fetch-Site": "same-origin"}, 403),        # rebinding, same-origin GET has no Origin
+    ("127.0.0.1.evil.com", {}, 403),
+    ("localhost.evil.com", {}, 403),
+])
+def test_cors_origins_localhost_blocks_dns_rebinding(host: str, headers: dict, expected_status: int):
+    global server
+    server.api_key = None
+    server.cors_origins = "localhost"
+    server.start()
+    port = server.server_port
+    headers = {k: v.format(port=port) for k, v in headers.items()}
+    res = _raw_post("/tokenize", '{"content": "hello"}', {"Host": f"{host}:{port}", **headers})
+    assert res.status_code == expected_status
+
+
+def test_cors_origins_explicit_list_allows_its_hosts():
+    global server
+    server.api_key = None
+    server.cors_origins = "https://ia.example.com"
+    server.start()
+    # reverse proxy that keeps the Host of the public name
+    for host, expected_status in [
+        ("ia.example.com", 200),
+        ("evil.com", 403),
+    ]:
+        res = _raw_post("/tokenize", '{"content": "hello"}', {"Host": host, "Origin": f"https://{host}"})
+        assert res.status_code == expected_status, host
+
+
+def test_cors_origins_explicit_list_blocks_simple_requests():
+    global server
+    server.api_key = None
+    server.cors_origins = "https://a.example, https://b.example"
+    server.start()
+    for origin, expected_status in [
+        ("https://a.example", 200),
+        ("https://b.example", 200),
+        ("https://c.example", 403),
+    ]:
+        res = _raw_post("/tokenize", '{"content": "hello"}', {"Origin": origin})
+        assert res.status_code == expected_status, origin
+
+
+def test_cors_origins_wildcard_keeps_allowing_all():
+    global server
+    server.api_key = None
+    server.cors_origins = "*"
+    server.start()
+    res = _raw_post("/tokenize", '{"content": "hello"}', {"Origin": "http://evil.com"})
+    assert res.status_code == 200
+
+
+def test_cors_cross_site_navigation_to_ui_allowed():
+    global server
+    server.api_key = None
+    server.cors_origins = "localhost"
+    server.start()
+    nav = {"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "navigate"}
+    # UI may not be built in tests, only check it is not refused
+    assert requests.get(server.make_url("/"), headers=nav).status_code != 403
+    assert requests.get(server.make_url("/props"), headers=nav).status_code == 403
+
+
+def test_tools_drive_by_exec_is_blocked():
+    global server
+    server.api_key = None
+    server.server_tools = "exec_shell_command" # implies --cors-origins localhost
+    server.start()
+    body = '{"tool": "exec_shell_command", "params": {"command": "echo csrf-guard-test"}}'
+    res = _raw_post("/tools", body, {"Origin": "http://evil.com"})
+    assert res.status_code == 403
+    assert "csrf-guard-test" not in res.text
+
+    res = _raw_post("/tools", body, {"Origin": "http://localhost:5173"})
+    assert res.status_code == 200
+    assert "csrf-guard-test" in res.text
+
+
 def test_cors_proxy_only_forwards_explicit_proxy_headers():
     class CaptureHeadersHandler(BaseHTTPRequestHandler):
         def do_GET(self):
diff --git a/tools/server/tests/utils.py b/tools/server/tests/utils.py
index e232fdd83..5dd760baf 100644
--- a/tools/server/tests/utils.py
+++ b/tools/server/tests/utils.py
@@ -794,5 +794,17 @@ def download_file(url: str, output_file_path: str | None = None) -> str:
     return output_file


+def get_lan_ip() -> str | None:
+    # address of the interface that routes outside, no packet is sent
+    import socket
+    try:
+        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
+            s.connect(("192.0.2.1", 9))
+            ip = s.getsockname()[0]
+    except OSError:
+        return None
+    return None if ip.startswith("127.") else ip
+
+
 def is_slow_test_allowed():
     return os.environ.get("SLOW_TESTS") == "1" or os.environ.get("SLOW_TESTS") == "ON"