Commit e182fdad4 for llama.cpp

commit e182fdad4a2241ff2022067292a94cba88835824
Author: Xuan-Son Nguyen <son@huggingface.co>
Date:   Thu Nov 12 12:16:29 2026 +0100

    server: disallow direct access child instance from browser (#32120)

diff --git a/tools/server/server-http.cpp b/tools/server/server-http.cpp
index 23eb471aa..1a344e53c 100644
--- a/tools/server/server-http.cpp
+++ b/tools/server/server-http.cpp
@@ -424,8 +424,26 @@ bool server_http_context::init_listener(const common_params & params) {
         return true;
     };

+    // a router child has no API key; the router strips the browser headers, so only a browser sends them here
+    const bool managed_by_router = std::getenv(SERVER_ENV_ROUTER_PORT) != nullptr;
+
     // register server middlewares
-    srv->set_pre_routing_handler([&params, middleware_validate_api_key, middleware_server_state](const httplib::Request & req, httplib::Response & res) {
+    srv->set_pre_routing_handler([&params, managed_by_router, middleware_validate_api_key, middleware_server_state](const httplib::Request & req, httplib::Response & res) {
+        if (managed_by_router && (req.has_header("Origin") || req.has_header("Sec-Fetch-Site") || req.has_header("Sec-Fetch-Mode"))) {
+            SRV_WRN("rejected browser request on router child: %s %s\n", req.method.c_str(), req.path.c_str());
+            res.status = 403;
+            res.set_content(
+                safe_json_to_str(json {
+                    {"error", {
+                        {"message", "Browser requests must go through the router"},
+                        {"type", "permission_error"},
+                        {"code", 403}
+                    }}
+                }),
+                "application/json; charset=utf-8"
+            );
+            return httplib::Server::HandlerResponse::Handled;
+        }
         if (params.cors_credentials && params.cors_origins == "*") {
             // special case: echo back the Origin header to allow any origin to access the server with credentials
             res.set_header("Access-Control-Allow-Origin", req.get_header_value("Origin"));
diff --git a/tools/server/tests/unit/test_router.py b/tools/server/tests/unit/test_router.py
index 2f3e4ee82..e4e62086b 100644
--- a/tools/server/tests/unit/test_router.py
+++ b/tools/server/tests/unit/test_router.py
@@ -953,3 +953,42 @@ def test_router_restricted_cors_same_origin_reaches_child():

     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
+
+
+def test_router_child_refuses_browser_requests(tmp_path):
+    import re
+    global server
+    log_path = tmp_path / "router.log"
+    server.log_path = str(log_path)
+    server.start()
+    model_id = "ggml-org-dev/tinygemma3-GGUF:Q8_0"
+    _load_model_and_wait(model_id)
+
+    pattern = rf"spawning server instance with name={re.escape(model_id)} on port (\d+)"
+    match = None
+    for _ in range(50):
+        match = re.search(pattern, log_path.read_text())
+        if match:
+            break
+        time.sleep(0.1)
+    assert match, "child port not found in router log"
+    child_url = f"http://127.0.0.1:{match.group(1)}"
+
+    # non-browser clients can reach the child directly
+    assert requests.get(f"{child_url}/health").status_code == 200
+
+    for headers in [
+        {"Origin": "http://evil.com"},
+        {"Origin": child_url},
+        {"Sec-Fetch-Site": "cross-site", "Sec-Fetch-Mode": "no-cors"},
+        {"Sec-Fetch-Site": "same-origin"},
+    ]:
+        assert requests.get(f"{child_url}/health", headers=headers).status_code == 403, headers
+
+    # browser requests through the router still reach the child
+    res = requests.post(server.make_url("/chat/completions"), json={
+        "model": model_id,
+        "max_tokens": 4,
+        "messages": [{"role": "user", "content": "hello"}],
+    }, headers={"Origin": f"http://127.0.0.1:{server.server_port}", "Sec-Fetch-Site": "same-origin"})
+    assert res.status_code == 200, res.text