From 3d2f4c434e026ac48974347ee6876566a0db72df Mon Sep 17 00:00:00 2001 From: Anai-Guo Date: Fri, 14 Aug 2026 00:17:03 -0700 Subject: [PATCH] fix(server): make CORS origins configurable and drop credentialed wildcard The app shipped `CORSMiddleware(allow_origins=["*"], allow_credentials=True)`. Because credentials are enabled, Starlette reflects the requesting origin instead of sending `*`, so any web page open in the browser can preflight and read responses from a loopback instance. With `disable_auth: true` (which the config explicitly suggests for localhost-only use) that page reaches the admin surface (`/v1/model/load`, `/v1/download`, model path listing, ...). Expose a `network.allowed_origins` config key (default `["*"]`, so existing browser frontends keep working) and set `allow_credentials=False`. TabbyAPI authenticates with a header/query token, never cookies, so credentialed CORS buys nothing and only enables the origin-reflection footgun. Operators who want to lock a shared instance down can now set an explicit allowlist or `[]`. Closes #448 (CORS portion). Signed-off-by: Anai-Guo --- common/config_models.py | 12 ++++++++++++ config_sample.yml | 7 +++++++ endpoints/server.py | 9 ++++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/common/config_models.py b/common/config_models.py index fb0db10e..f4c6f64b 100644 --- a/common/config_models.py +++ b/common/config_models.py @@ -53,6 +53,18 @@ class NetworkConfig(BaseConfigModel): "Turn on this option if you are ONLY connecting from localhost." ), ) + allowed_origins: Optional[List[str]] = Field( + ["*"], + description=( + 'Origins allowed to call the API from a browser (default: ["*"]).\n' + "This is a CORS allowlist, not an auth mechanism: it only governs which\n" + "web pages a browser will let read this API's responses.\n" + 'The default "*" means any site open in your browser can send requests to\n' + "this instance, which matters most when disable_auth is on. Restrict this to\n" + 'your own frontends (e.g. ["http://localhost:8000"]) to close that off, or\n' + "use an empty list [] to block all browser (cross-origin) callers." + ), + ) disable_fetch_requests: Optional[bool] = Field( False, description=( diff --git a/config_sample.yml b/config_sample.yml index 7b16b06b..6710e658 100644 --- a/config_sample.yml +++ b/config_sample.yml @@ -20,6 +20,13 @@ network: # Turn on this option if you are ONLY connecting from localhost. disable_auth: false + # Origins allowed to call the API from a browser (default: ["*"]). + # This is a CORS allowlist, not authentication: "*" lets any site open in your + # browser send requests to this instance, which matters most when disable_auth + # is on. Restrict to your own frontends (e.g. ["http://localhost:8000"]) or use + # an empty list [] to block all browser (cross-origin) callers. + allowed_origins: ["*"] + # Disable fetching external content in response to requests,such as images from URLs. disable_fetch_requests: false diff --git a/endpoints/server.py b/endpoints/server.py index b3ed1e4a..95478881 100644 --- a/endpoints/server.py +++ b/endpoints/server.py @@ -29,11 +29,14 @@ def setup_app(host: Optional[str] = None, port: Optional[int] = None): ) app.add_exception_handler(ContextLengthHTTPException, context_length_exception_handler) - # ALlow CORS requests + # Allow CORS requests from the configured origins. + # allow_credentials stays False: TabbyAPI authenticates with a header/query + # token rather than cookies, so credentialed CORS buys nothing and would make + # Starlette reflect an arbitrary requesting origin back instead of sending "*". app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=config.network.allowed_origins, + allow_credentials=False, allow_methods=["*"], allow_headers=["*"], )