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"