feat(api): add configurable CORS origins via CORS_ORIGINS (#767)

Replace hardcoded `allow_origins=["*"]` with a parsed `CORS_ORIGINS`
environment variable (comma-separated). Default remains `*` for
backward compatibility — no existing deployment breaks — but the API
now logs a startup warning prompting users to set it explicitly for
production.

Exception handlers now route their CORS headers through a shared
`_cors_headers()` helper that mirrors Starlette's CORSMiddleware
behavior: reflects the request Origin when allowed (handling the
browser-rejected `*` + credentials combination correctly), and omits
`Access-Control-Allow-Origin` for disallowed origins so error bodies
don't leak cross-origin when `CORS_ORIGINS` is configured.

Closes #585, #730.

Based on the original work by Greg Grace in #597; rewritten on top of
current main to address prior review feedback (load_dotenv kept at
top, `import os` grouped with stdlib, `_cors_headers` defined before
its exception-handler callers, origins parsed once at module load)
and to choose a non-breaking default paired with a startup warning
instead of a stricter-by-default origin.

Co-authored-by: Greg Grace <ggrace@519lab.com>
This commit is contained in:
Luis Novo 2026-04-19 16:22:10 -03:00 committed by GitHub
parent 4d4330fb3f
commit ec41ef8f2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 107 additions and 32 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- `OPEN_NOTEBOOK_EMBEDDING_BATCH_SIZE` environment variable to override the embedding batch size; default remains `50`. Helps with CPU-only local embedding and stricter OpenAI-compatible endpoints (#735) - `OPEN_NOTEBOOK_EMBEDDING_BATCH_SIZE` environment variable to override the embedding batch size; default remains `50`. Helps with CPU-only local embedding and stricter OpenAI-compatible endpoints (#735)
- `CORS_ORIGINS` environment variable to configure the API's allowed origins (comma-separated). Default remains `*` for backward compatibility; the API now logs a startup warning prompting users to set it for production deployments. Exception responses honor the configured origins when explicitly set (#585, #597, #730)
## [1.8.5] - 2026-04-14 ## [1.8.5] - 2026-04-14

View file

@ -3,6 +3,7 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@ -12,16 +13,6 @@ from loguru import logger
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from api.auth import PasswordAuthMiddleware from api.auth import PasswordAuthMiddleware
from open_notebook.exceptions import (
AuthenticationError,
ConfigurationError,
ExternalServiceError,
InvalidInputError,
NetworkError,
NotFoundError,
OpenNotebookError,
RateLimitError,
)
from api.routers import ( from api.routers import (
auth, auth,
chat, chat,
@ -46,8 +37,57 @@ from api.routers import (
) )
from api.routers import commands as commands_router from api.routers import commands as commands_router
from open_notebook.database.async_migrate import AsyncMigrationManager from open_notebook.database.async_migrate import AsyncMigrationManager
from open_notebook.exceptions import (
AuthenticationError,
ConfigurationError,
ExternalServiceError,
InvalidInputError,
NetworkError,
NotFoundError,
OpenNotebookError,
RateLimitError,
)
from open_notebook.utils.encryption import get_secret_from_env from open_notebook.utils.encryption import get_secret_from_env
def _parse_cors_origins(raw: str) -> list[str]:
"""Parse CORS_ORIGINS env value into a list of origins."""
value = raw.strip()
if value == "*":
return ["*"]
return [origin.strip() for origin in value.split(",") if origin.strip()]
# Parsed once at module load; CORS_ORIGINS changes require a restart.
_cors_origins_raw = os.getenv("CORS_ORIGINS")
CORS_ALLOWED_ORIGINS = _parse_cors_origins(_cors_origins_raw or "*")
CORS_IS_DEFAULT_WILDCARD = _cors_origins_raw is None
def _cors_headers(request: Request) -> dict[str, str]:
"""
Build CORS headers for error responses.
Mirrors Starlette CORSMiddleware behavior: reflects the request Origin
when the origin is allowed (or when wildcard is configured, since
browsers reject `Access-Control-Allow-Origin: *` combined with
credentials). Omits `Access-Control-Allow-Origin` for disallowed
origins so the browser blocks the error body from leaking cross-origin.
"""
origin = request.headers.get("origin")
headers: dict[str, str] = {
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
}
if origin and ("*" in CORS_ALLOWED_ORIGINS or origin in CORS_ALLOWED_ORIGINS):
headers["Access-Control-Allow-Origin"] = origin
headers["Vary"] = "Origin"
return headers
# Import commands to register them in the API process # Import commands to register them in the API process
try: try:
logger.info("Commands imported in API process") logger.info("Commands imported in API process")
@ -61,8 +101,6 @@ async def lifespan(app: FastAPI):
Lifespan event handler for the FastAPI application. Lifespan event handler for the FastAPI application.
Runs database migrations automatically on startup. Runs database migrations automatically on startup.
""" """
import os
# Startup: Security checks # Startup: Security checks
logger.info("Starting API initialization...") logger.info("Starting API initialization...")
@ -122,6 +160,16 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
if CORS_IS_DEFAULT_WILDCARD:
logger.warning(
"CORS_ORIGINS is not set — API accepts cross-origin requests from any "
"origin (default: '*'). For production deployments, set CORS_ORIGINS to "
"your frontend origin(s), e.g. "
"CORS_ORIGINS=https://notebook.example.com"
)
else:
logger.info(f"CORS allowed origins: {CORS_ALLOWED_ORIGINS}")
# Add password authentication middleware first # Add password authentication middleware first
# Exclude /api/auth/status and /api/config from authentication # Exclude /api/auth/status and /api/config from authentication
app.add_middleware( app.add_middleware(
@ -140,7 +188,7 @@ app.add_middleware(
# Add CORS middleware last (so it processes first) # Add CORS middleware last (so it processes first)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # In production, replace with specific origins allow_origins=CORS_ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -159,31 +207,13 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
FastAPI, this handler won't be called. In that case, configure your reverse proxy FastAPI, this handler won't be called. In that case, configure your reverse proxy
to add CORS headers to error responses. to add CORS headers to error responses.
""" """
# Get the origin from the request
origin = request.headers.get("origin", "*")
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={"detail": exc.detail}, content={"detail": exc.detail},
headers={ headers={**(exc.headers or {}), **_cors_headers(request)},
**(exc.headers or {}), "Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
},
) )
def _cors_headers(request: Request) -> dict[str, str]:
origin = request.headers.get("origin", "*")
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
}
@app.exception_handler(NotFoundError) @app.exception_handler(NotFoundError)
async def not_found_error_handler(request: Request, exc: NotFoundError): async def not_found_error_handler(request: Request, exc: NotFoundError):
return JSONResponse( return JSONResponse(

View file

@ -69,6 +69,25 @@ Comprehensive list of all environment variables available in Open Notebook.
--- ---
## API / CORS
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `CORS_ORIGINS` | No | `*` | Comma-separated list of origins allowed to call the API (e.g. `https://app.example.com,https://www.example.com`). Default `*` accepts any origin; **for production, set this explicitly to your frontend origin(s)**. Changes require an API restart. The API logs a warning on startup when unset. |
**When to change this**:
- You access the UI at a custom domain (reverse proxy, HTTPS, public deployment).
- The frontend runs on a different port than `3000`.
- You serve the frontend from a different host than the API (e.g. CDN).
Example for a production deployment behind a reverse proxy:
```bash
CORS_ORIGINS=https://notebook.example.com
```
---
## Text-to-Speech (TTS) ## Text-to-Speech (TTS)
| Variable | Required? | Default | Description | | Variable | Required? | Default | Description |

View file

@ -287,6 +287,31 @@ iptables -A INPUT -p tcp --dport 5055 -j DROP
See [Reverse Proxy Configuration](reverse-proxy.md) for complete nginx/Caddy/Traefik setup with HTTPS. See [Reverse Proxy Configuration](reverse-proxy.md) for complete nginx/Caddy/Traefik setup with HTTPS.
### CORS Origins
The API accepts cross-origin requests from any origin by default (`*`). This is convenient for development and diverse self-hosted setups, but it's not recommended for internet-facing production deployments because any website the user visits can issue authenticated cross-origin requests to your API.
When `CORS_ORIGINS` is not set, the API logs a startup warning prompting you to configure it.
**For production, set `CORS_ORIGINS` to your frontend's actual origin(s):**
```bash
# Single origin
CORS_ORIGINS=https://notebook.example.com
# Multiple origins (comma-separated)
CORS_ORIGINS=https://notebook.example.com,https://admin.example.com
```
**Guidelines:**
- Always use HTTPS origins in production.
- List only the exact origins that should be allowed to call the API.
- Include the scheme and port (if non-default): `https://example.com`, `http://192.168.1.10:3000`.
- Changes require an API restart to take effect.
**Error responses** (401, 404, 500, etc.) also respect the configured origins — they only include `Access-Control-Allow-Origin` for allowed origins, so error bodies are not leaked cross-origin when `CORS_ORIGINS` is configured.
--- ---
## Security Limitations ## Security Limitations