server: add --cors-* options (#25655)

* server: add --cors-* options

* add special "localhost" value

* add tests

* fix test

* add link to PR
This commit is contained in:
Xuan-Son Nguyen
2026-07-14 17:23:44 +02:00
committed by GitHub
parent 236ab574e0
commit 6e52db5b72
6 changed files with 177 additions and 21 deletions
+28 -5
View File
@@ -47,6 +47,16 @@ static void log_server_request(const httplib::Request & req, const httplib::Resp
SRV_DBG("response: %s\n", res.body.c_str());
}
// returns true if the Origin header value's host is localhost / 127.0.0.1 / ::1 (any port)
static bool origin_is_localhost(const std::string & origin) {
try {
const std::string host = common_http_parse_url(origin).host;
return host == "localhost" || host == "127.0.0.1" || host == "::1";
} catch (const std::exception &) {
return false;
}
}
// For Google Cloud Platform deployment compatibility
struct gcp_params {
bool enabled;
@@ -266,13 +276,26 @@ bool server_http_context::init(const common_params & params) {
};
// register server middlewares
srv->set_pre_routing_handler([middleware_validate_api_key, middleware_server_state](const httplib::Request & req, httplib::Response & res) {
res.set_header("Access-Control-Allow-Origin", req.get_header_value("Origin"));
srv->set_pre_routing_handler([&params, middleware_validate_api_key, middleware_server_state](const httplib::Request & req, httplib::Response & res) {
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"));
} else if (params.cors_origins == "localhost") {
// special case: only reflect the Origin header if it is a localhost origin
std::string origin = req.get_header_value("Origin");
if (origin_is_localhost(origin)) {
res.set_header("Access-Control-Allow-Origin", origin);
} else {
SRV_WRN("(CORS) skip non-localhost origin: %s\n", origin.c_str());
}
} else {
res.set_header("Access-Control-Allow-Origin", params.cors_origins);
}
// If this is OPTIONS request, skip validation because browsers don't include Authorization header
if (req.method == "OPTIONS") {
res.set_header("Access-Control-Allow-Credentials", "true");
res.set_header("Access-Control-Allow-Methods", "GET, POST");
res.set_header("Access-Control-Allow-Headers", "*");
res.set_header("Access-Control-Allow-Credentials", params.cors_credentials ? "true" : "false");
res.set_header("Access-Control-Allow-Methods", params.cors_methods);
res.set_header("Access-Control-Allow-Headers", params.cors_headers);
res.set_content("", "text/html"); // blank response, no data
return httplib::Server::HandlerResponse::Handled; // skip further processing
}
+25 -11
View File
@@ -303,14 +303,24 @@ int llama_server(common_params & params, int argc, char ** argv) {
return res;
};
if (params.cors_origins == "*" && params.api_keys.empty()) {
SRV_WRN("%s", "-----------------\n");
SRV_WRN("%s", "CORS is set to allow all origins ('*') and no API key is set\n");
SRV_WRN("%s", "this can be a security risk (cross-origin attacks)\n");
SRV_WRN("%s", "more info: https://github.com/ggml-org/llama.cpp/pull/25655\n");
SRV_WRN("%s", "-----------------\n");
}
// CORS proxy (EXPERIMENTAL, only used by the Web UI for MCP)
std::vector<std::string> warn_names;
if (is_router_server) {
warn_names.push_back("router mode");
}
if (params.ui_mcp_proxy) {
SRV_WRN("%s", "-----------------\n");
SRV_WRN("%s", "CORS proxy is enabled, do not expose server to untrusted environments\n");
SRV_WRN("%s", "This feature is EXPERIMENTAL and may be removed or changed in future versions\n");
SRV_WRN("%s", "-----------------\n");
ctx_http.get ("/cors-proxy", ex_wrapper(proxy_handler_get));
ctx_http.post("/cors-proxy", ex_wrapper(proxy_handler_post));
warn_names.push_back("MCP proxy (experimental)");
} else {
ctx_http.get ("/cors-proxy", ex_wrapper(res_403));
ctx_http.post("/cors-proxy", ex_wrapper(res_403));
@@ -324,17 +334,24 @@ int llama_server(common_params & params, int argc, char ** argv) {
SRV_ERR("tools setup failed: %s\n", e.what());
return 1;
}
SRV_WRN("%s", "-----------------\n");
SRV_WRN("%s", "Built-in tools are enabled, do not expose server to untrusted environments\n");
SRV_WRN("%s", "This feature is EXPERIMENTAL and may be changed in the future\n");
SRV_WRN("%s", "-----------------\n");
ctx_http.get ("/tools", ex_wrapper(tools.handle_get));
ctx_http.post("/tools", ex_wrapper(tools.handle_post));
warn_names.push_back("built-in tools (experimental)");
} else {
ctx_http.get ("/tools", ex_wrapper(res_403));
ctx_http.post("/tools", ex_wrapper(res_403));
}
if (warn_names.size() > 0) {
SRV_WRN("%s", "-----------------\n");
SRV_WRN("%s", "the following feature(s) are enabled:\n");
for (const auto & name : warn_names) {
SRV_WRN(" %s\n", name.c_str());
}
SRV_WRN("%s", "do not expose the server to untrusted environments\n");
SRV_WRN("%s", "-----------------\n");
}
//
// Handle downloading model
//
@@ -452,9 +469,6 @@ int llama_server(common_params & params, int argc, char ** argv) {
SRV_INF("listening on %s\n", ctx_http.listening_address.c_str());
if (is_router_server) {
SRV_WRN("%s", "NOTE: router mode is experimental\n");
SRV_WRN("%s", " it is not recommended to use this mode in untrusted environments\n");
if (!params.models_preset_hf.empty()) {
SRV_WRN( "NOTE: using preset.ini from HF repo '%s'\n", params.models_preset_hf.c_str());
SRV_WRN("%s", " please only use presets that you can trust! Unknown presets may be unsafe\n");
+65 -1
View File
@@ -91,7 +91,7 @@ def test_openai_library_correct_api_key():
("localhost", "Access-Control-Allow-Origin", "localhost"),
("web.mydomain.fr", "Access-Control-Allow-Origin", "web.mydomain.fr"),
("origin", "Access-Control-Allow-Credentials", "true"),
("web.mydomain.fr", "Access-Control-Allow-Methods", "GET, POST"),
("web.mydomain.fr", "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"),
("web.mydomain.fr", "Access-Control-Allow-Headers", "*"),
])
def test_cors_options(origin: str, cors_header: str, cors_header_value: str):
@@ -107,6 +107,70 @@ def test_cors_options(origin: str, cors_header: str, cors_header_value: str):
assert res.headers[cors_header] == cors_header_value
@pytest.mark.parametrize("origin", [
"http://localhost",
"http://localhost:8080",
"http://127.0.0.1",
"http://127.0.0.1:3000",
"http://[::1]",
"http://[::1]:3000",
])
def test_cors_origins_localhost_reflects(origin: str):
global server
server = ServerPreset.router()
server.cors_origins = "localhost"
server.start()
res = server.make_request("OPTIONS", "/completions", headers={
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization",
})
assert res.status_code == 200
assert res.headers["Access-Control-Allow-Origin"] == origin
@pytest.mark.parametrize("origin", [
"http://web.mydomain.fr",
"http://evil.com",
"http://notlocalhost",
"http://localhost.evil.com",
])
def test_cors_origins_localhost_rejects(origin: str):
global server
server = ServerPreset.router()
server.cors_origins = "localhost"
server.start()
res = server.make_request("OPTIONS", "/completions", headers={
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization",
})
assert res.status_code == 200
assert "Access-Control-Allow-Origin" not in res.headers
def test_cors_origins_defaults_to_localhost_with_tools_enabled():
global server
server = ServerPreset.router()
server.server_tools = "all"
server.start()
res = server.make_request("OPTIONS", "/completions", headers={
"Origin": "http://localhost:8080",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization",
})
assert res.status_code == 200
assert res.headers["Access-Control-Allow-Origin"] == "http://localhost:8080"
res = server.make_request("OPTIONS", "/completions", headers={
"Origin": "http://evil.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Authorization",
})
assert res.status_code == 200
assert "Access-Control-Allow-Origin" not in res.headers
def test_cors_proxy_only_forwards_explicit_proxy_headers():
class CaptureHeadersHandler(BaseHTTPRequestHandler):
def do_GET(self):
+4 -1
View File
@@ -114,6 +114,7 @@ class ServerProcess:
backend_sampling: bool = False
gcp_compat: bool = False
server_tools: str | None = None
cors_origins: str | None = None
# session variables
process: subprocess.Popen | None = None
@@ -170,6 +171,8 @@ class ServerProcess:
server_args.extend(["--models-max", self.models_max])
if self.models_preset:
server_args.extend(["--models-preset", self.models_preset])
if self.cors_origins:
server_args.extend(["--cors-origins", self.cors_origins])
if self.n_batch:
server_args.extend(["--batch-size", self.n_batch])
if self.n_ubatch:
@@ -359,7 +362,7 @@ class ServerProcess:
if parse_body:
try:
result.body = response.json()
except JSONDecodeError:
except (JSONDecodeError, requests.exceptions.JSONDecodeError):
result.body = response.text
else:
result.body = None