Skip to content

Commit f4f6bed

Browse files
NinoSkopacLightheartdevs
authored andcommitted
Update CORS configuration and documentation
1 parent 18277e5 commit f4f6bed

4 files changed

Lines changed: 194 additions & 2 deletions

File tree

resources/products/token-spy/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,31 @@ Copy `.env.example` to `.env` and set required values:
8383
| `DEFAULT_API_KEY` | | API key for upstream (if required) |
8484
| `TOKEN_SPY_PROXY_PORT` | | Proxy port (default: 8080) |
8585
| `TOKEN_SPY_DASHBOARD_PORT` | | Dashboard port (default: 3001) |
86+
| `DASHBOARD_ALLOWED_ORIGINS` | | Dashboard CORS allowlist (CSV or JSON array). Leave empty for secure default (no cross-origin access). |
87+
| `DASHBOARD_CORS_ALLOW_CREDENTIALS` | | Whether CORS allows credentials (default: `true`; requires explicit origins) |
88+
89+
### Dashboard CORS Security
90+
91+
Token Spy dashboard CORS is environment-driven via `DASHBOARD_ALLOWED_ORIGINS`.
92+
93+
- **Secure default (recommended):** leave `DASHBOARD_ALLOWED_ORIGINS` empty to disable cross-origin browser access.
94+
- **Production:** set explicit trusted origins only.
95+
- **Local development override:** set localhost origins as a CSV or JSON array.
96+
97+
Examples:
98+
99+
```bash
100+
# Production
101+
DASHBOARD_ALLOWED_ORIGINS=https://dashboard.example.com
102+
103+
# Local development (CSV)
104+
DASHBOARD_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
105+
106+
# Local development (JSON array)
107+
DASHBOARD_ALLOWED_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"]
108+
```
109+
110+
> ⚠️ Startup validation rejects insecure combinations. If `DASHBOARD_CORS_ALLOW_CREDENTIALS=true`, wildcard `*` is not allowed in `DASHBOARD_ALLOWED_ORIGINS` and the dashboard will fail fast with an error log.
86111
87112
### Generating a Secure Password
88113

resources/products/token-spy/config/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ DASHBOARD_AUTH_ENABLED=false
4545
DASHBOARD_USERNAME=admin
4646
DASHBOARD_PASSWORD=
4747

48+
# Dashboard CORS allowlist (recommended: explicit origins only)
49+
# Secure default is empty, which disables cross-origin browser requests.
50+
# Production example: DASHBOARD_ALLOWED_ORIGINS=https://dashboard.example.com
51+
# Local dev override examples:
52+
# DASHBOARD_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
53+
# DASHBOARD_ALLOWED_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"]
54+
# NOTE: If DASHBOARD_CORS_ALLOW_CREDENTIALS=true, wildcard '*' is rejected at startup.
55+
DASHBOARD_ALLOWED_ORIGINS=
56+
DASHBOARD_CORS_ALLOW_CREDENTIALS=true
57+
4858
# ============================================
4959
# Logging & Performance
5060
# ============================================

resources/products/token-spy/dashboard/main.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
import asyncio
2121
import logging
22+
import json
2223
from datetime import datetime, timedelta
2324
from decimal import Decimal
2425
from typing import Optional, List, Dict, Any, Literal
@@ -51,6 +52,8 @@ class Settings(BaseSettings):
5152
dashboard_auth_enabled: bool = False
5253
dashboard_username: str = "admin"
5354
dashboard_password: Optional[str] = Field(default=None, description="Dashboard password (required when auth enabled)")
55+
dashboard_allowed_origins: str = ""
56+
dashboard_cors_allow_credentials: bool = True
5457

5558
class Config:
5659
env_file = ".env"
@@ -98,6 +101,63 @@ def verify_auth(credentials: HTTPBasicCredentials = Depends(security)):
98101
logger = logging.getLogger("token-spy-dashboard")
99102

100103

104+
def parse_allowed_origins(raw_origins: str) -> List[str]:
105+
"""Parse CORS origins from CSV or JSON array."""
106+
value = (raw_origins or "").strip()
107+
if not value:
108+
return []
109+
110+
if value.startswith("{"):
111+
raise ValueError(
112+
"DASHBOARD_ALLOWED_ORIGINS JSON format must be an array, not an object"
113+
)
114+
115+
if value.startswith("["):
116+
try:
117+
parsed = json.loads(value)
118+
except json.JSONDecodeError as exc:
119+
raise ValueError(
120+
"DASHBOARD_ALLOWED_ORIGINS must be valid CSV or JSON array"
121+
) from exc
122+
123+
if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed):
124+
raise ValueError("DASHBOARD_ALLOWED_ORIGINS JSON format must be an array of strings")
125+
return [origin.strip() for origin in parsed if origin.strip()]
126+
127+
return [origin.strip() for origin in value.split(",") if origin.strip()]
128+
129+
130+
def get_cors_settings() -> Dict[str, Any]:
131+
"""Return validated CORS settings from environment configuration."""
132+
allow_origins = parse_allowed_origins(settings.dashboard_allowed_origins)
133+
allow_credentials = settings.dashboard_cors_allow_credentials
134+
has_wildcard = "*" in allow_origins
135+
136+
if allow_credentials and has_wildcard:
137+
logger.error(
138+
"Invalid CORS config: DASHBOARD_CORS_ALLOW_CREDENTIALS=true cannot be combined "
139+
"with wildcard origin '*' in DASHBOARD_ALLOWED_ORIGINS."
140+
)
141+
raise ValueError(
142+
"Refusing to start with insecure CORS config: credentials + wildcard origin"
143+
)
144+
145+
if has_wildcard:
146+
logger.warning(
147+
"CORS is configured with wildcard origin '*'. This should only be used in controlled local development."
148+
)
149+
elif not allow_origins:
150+
logger.info(
151+
"CORS allowlist is empty; cross-origin browser requests are disabled. "
152+
"Set DASHBOARD_ALLOWED_ORIGINS for explicit trusted origins."
153+
)
154+
155+
return {
156+
"allow_origins": allow_origins,
157+
"allow_credentials": allow_credentials,
158+
}
159+
160+
101161
def normalize_cost_and_speed_metrics(
102162
total_tokens: Optional[int],
103163
total_cost: Optional[float],
@@ -181,10 +241,11 @@ async def lifespan(app: FastAPI):
181241
)
182242

183243
# CORS for React frontend
244+
cors_settings = get_cors_settings()
184245
app.add_middleware(
185246
CORSMiddleware,
186-
allow_origins=["*"],
187-
allow_credentials=True,
247+
allow_origins=cors_settings["allow_origins"],
248+
allow_credentials=cors_settings["allow_credentials"],
188249
allow_methods=["*"],
189250
allow_headers=["*"],
190251
)

resources/products/token-spy/tests/test_dashboard_api_metrics.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,99 @@ def test_dashboard_and_sidecar_normalization_are_in_parity(
281281
avg_ttft_ms=avg_ttft_ms,
282282
)
283283
assert dashboard_result == sidecar_result
284+
285+
286+
@pytest.mark.parametrize(
287+
"raw_origins, expected",
288+
[
289+
("", []),
290+
(" ", []),
291+
("http://localhost:3000", ["http://localhost:3000"]),
292+
(
293+
"http://localhost:3000, http://127.0.0.1:3000",
294+
["http://localhost:3000", "http://127.0.0.1:3000"],
295+
),
296+
(
297+
'["http://localhost:3000", "http://127.0.0.1:3000"]',
298+
["http://localhost:3000", "http://127.0.0.1:3000"],
299+
),
300+
],
301+
)
302+
def test_parse_allowed_origins_accepts_csv_and_json(
303+
dashboard_main_module,
304+
raw_origins,
305+
expected,
306+
):
307+
assert dashboard_main_module.parse_allowed_origins(raw_origins) == expected
308+
309+
310+
@pytest.mark.parametrize(
311+
"raw_origins",
312+
[
313+
'["http://localhost:3000",]',
314+
'{"origin": "http://localhost:3000"}',
315+
"[1, 2]",
316+
],
317+
)
318+
def test_parse_allowed_origins_rejects_invalid_json(dashboard_main_module, raw_origins):
319+
with pytest.raises(ValueError):
320+
dashboard_main_module.parse_allowed_origins(raw_origins)
321+
322+
323+
def test_get_cors_settings_rejects_credentials_with_wildcard(
324+
dashboard_main_module,
325+
monkeypatch,
326+
caplog,
327+
):
328+
monkeypatch.setattr(dashboard_main_module.settings, "dashboard_allowed_origins", "*")
329+
monkeypatch.setattr(
330+
dashboard_main_module.settings,
331+
"dashboard_cors_allow_credentials",
332+
True,
333+
)
334+
335+
with caplog.at_level("ERROR"):
336+
with pytest.raises(ValueError, match="insecure CORS config"):
337+
dashboard_main_module.get_cors_settings()
338+
339+
assert "cannot be combined with wildcard origin '*'" in caplog.text
340+
341+
342+
def test_get_cors_settings_allows_wildcard_without_credentials(
343+
dashboard_main_module,
344+
monkeypatch,
345+
caplog,
346+
):
347+
monkeypatch.setattr(dashboard_main_module.settings, "dashboard_allowed_origins", "*")
348+
monkeypatch.setattr(
349+
dashboard_main_module.settings,
350+
"dashboard_cors_allow_credentials",
351+
False,
352+
)
353+
354+
with caplog.at_level("WARNING"):
355+
cors_settings = dashboard_main_module.get_cors_settings()
356+
357+
assert cors_settings["allow_origins"] == ["*"]
358+
assert cors_settings["allow_credentials"] is False
359+
assert "wildcard origin '*'" in caplog.text
360+
361+
362+
def test_get_cors_settings_logs_empty_allowlist_info(
363+
dashboard_main_module,
364+
monkeypatch,
365+
caplog,
366+
):
367+
monkeypatch.setattr(dashboard_main_module.settings, "dashboard_allowed_origins", "")
368+
monkeypatch.setattr(
369+
dashboard_main_module.settings,
370+
"dashboard_cors_allow_credentials",
371+
True,
372+
)
373+
374+
with caplog.at_level("INFO"):
375+
cors_settings = dashboard_main_module.get_cors_settings()
376+
377+
assert cors_settings["allow_origins"] == []
378+
assert cors_settings["allow_credentials"] is True
379+
assert "CORS allowlist is empty" in caplog.text

0 commit comments

Comments
 (0)