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([¶ms, middleware_validate_api_key, middleware_server_state](const httplib::Request & req, httplib::Response & res) {
+ srv->set_pre_routing_handler([¶ms, 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