fix(cors): require explicit allowed origins

This commit is contained in:
2026-11-04 13:32:50 +08:00
parent 9ab7b10e3c
commit bd8c619bfb
5 changed files with 143 additions and 48 deletions
+40 -47
View File
@@ -1,90 +1,83 @@
# TJWater Server 环境变量配置模板 # TJWaterServerBinary 环境变量模板
# 复制此文件为 .env 并填写实际值 # 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。 # 生产配置由部署主机的受控 backend.env 注入,不随镜像或 CI/CD 同步本地 .env。
ENVIRONMENT="production" # 进程环境变量优先;修改 Compose env_file 后需重新创建容器。
NETWORK_NAME="tjwater"
# ============================================
# 敏感配置加密 (必填)
# ============================================
# Fernet 格式,生产环境必须替换为独立密钥 # 基础运行
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" PROJECT_NAME="TJWater Server"
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置 ENVIRONMENT="production"
API_V1_STR="/api/v1"
NETWORK_NAME="tjwater"
# Fernet 格式;用于加密项目数据库 DSN、GeoServer 管理密码等敏感配置。
# 生成方式:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key" DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
# ============================================ # 业务数据库(PostgreSQL)
# 数据库配置 (PostgreSQL)
# ============================================
DB_NAME="tjwater" DB_NAME="tjwater"
DB_HOST="localhost" DB_HOST="localhost"
DB_PORT="5432" DB_PORT="5432"
DB_USER="tjwater" DB_USER="tjwater"
DB_PASSWORD="password" DB_PASSWORD="password"
# ============================================ # 时序数据库(TimescaleDB)
# 数据库配置 (TimescaleDB)
# ============================================
TIMESCALEDB_DB_NAME="tjwater" TIMESCALEDB_DB_NAME="tjwater"
TIMESCALEDB_DB_HOST="localhost" TIMESCALEDB_DB_HOST="localhost"
TIMESCALEDB_DB_PORT="5433" TIMESCALEDB_DB_PORT="5433"
TIMESCALEDB_DB_USER="tjwater" TIMESCALEDB_DB_USER="tjwater"
TIMESCALEDB_DB_PASSWORD="password" TIMESCALEDB_DB_PASSWORD="password"
# ============================================ # 元数据数据库
# 元数据数据库配置 (Metadata DB)
# ============================================
METADATA_DB_NAME="system_hub" METADATA_DB_NAME="system_hub"
METADATA_DB_HOST="localhost" METADATA_DB_HOST="localhost"
METADATA_DB_PORT="5432" METADATA_DB_PORT="5432"
METADATA_DB_USER="tjwater" METADATA_DB_USER="tjwater"
METADATA_DB_PASSWORD="password" METADATA_DB_PASSWORD="password"
METADATA_DB_POOL_SIZE=5
METADATA_DB_MAX_OVERFLOW=10
# Per-project synchronous connection pools # 项目数据库连接池、缓存与模板
PROJECT_PG_CACHE_SIZE="16" PROJECT_PG_CACHE_SIZE=16
PROJECT_TS_CACHE_SIZE="16" PROJECT_TS_CACHE_SIZE=16
PROJECT_PG_POOL_MIN_SIZE="0" PROJECT_PG_POOL_MIN_SIZE=0
PROJECT_PG_POOL_SIZE="4" PROJECT_PG_POOL_SIZE=4
PROJECT_PG_MAX_OVERFLOW="2" PROJECT_PG_MAX_OVERFLOW=2
PROJECT_TS_POOL_MIN_SIZE="0" PROJECT_TS_POOL_MIN_SIZE=0
PROJECT_TS_POOL_MAX_SIZE="4" PROJECT_TS_POOL_MAX_SIZE=4
WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template" WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template"
TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template" TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template"
WNDB_TEMP_DB_MAX_COUNT="8" WNDB_TEMP_DB_MAX_COUNT=8
# ============================================
# GeoServer 项目供应 # GeoServer 项目供应
# ============================================
GEOSERVER_URL="http://localhost:8080/geoserver" GEOSERVER_URL="http://localhost:8080/geoserver"
GEOSERVER_USERNAME="admin" GEOSERVER_USERNAME="admin"
GEOSERVER_PASSWORD="password" GEOSERVER_PASSWORD="password"
# 留空时分别复用 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD。 # 留空时分别复用 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD。
# GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。 # GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。
GEOSERVER_DB_HOST="" GEOSERVER_DB_HOST=""
GEOSERVER_DB_PORT="" GEOSERVER_DB_PORT=""
GEOSERVER_DB_USER="" GEOSERVER_DB_USER=""
GEOSERVER_DB_PASSWORD="" GEOSERVER_DB_PASSWORD=""
# 30 天客户端缓存,单位为秒。 # 客户端缓存时间,单位为秒;默认 30 天。
GEOSERVER_CLIENT_CACHE_SECONDS="2592000" GEOSERVER_CLIENT_CACHE_SECONDS=2592000
# ============================================ # Keycloak JWT 校验
# Keycloak JWT (可选) # 未启用认证时可留空;生产环境应注入完整公钥和预期 audience。
# ============================================ KEYCLOAK_PUBLIC_KEY=""
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" KEYCLOAK_ALGORITHM="RS256"
KEYCLOAK_ALGORITHM=RS256
KEYCLOAK_AUDIENCE="account" KEYCLOAK_AUDIENCE="account"
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900 KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
# 浏览器跨域访问(主 API)
# 必须填写明确来源(协议、域名或 IP、端口),英文逗号分隔;不允许 *。
CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173
# ============================================ # Bocha 网页搜索(旧版仍保留;未启用时令牌留空)
# Bocha Web Search API BOCHA_API_KEY=""
# ============================================
BOCHA_API_KEY="sk-your-bocha-api-key"
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search" BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30 BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
# ============================================ # 天地图地理编码(旧版使用 TIANDITU_GEOCODER_TOKEN)
# Tianditu Geocoding API TIANDITU_GEOCODER_TOKEN=""
# ============================================
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder" TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30 TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
+16
View File
@@ -43,6 +43,22 @@ conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
conda activate server conda activate server
``` ```
## CORS 配置
后端通过 `.env` 或进程环境变量中的 `CORS_ALLOWED_ORIGINS` 设置允许的浏览器页面来源,
进程环境变量优先。必须使用英文逗号分隔的明确来源,不能填写 `*`,例如:
```dotenv
CORS_ALLOWED_ORIGINS=https://demo.waternetwork.cn,http://192.168.1.133,https://192.168.1.133,http://192.168.1.110,https://192.168.1.110
```
未设置时默认只允许本机 `5173`、`15173` 开发来源。来源需包含协议、域名或 IP、端口,
不带路径;非默认端口须逐项列出。CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果。
保留跨域凭据支持及 `Authorization`、`X-Project-Id` 请求头,接口认证和权限校验继续生效。
修改后需重启后端进程。容器部署需同步更新宿主机的受控 `backend.env`;
使用 Compose `env_file` 注入时,需要重新创建容器才能读取新值,CI/CD 不会自动同步本地 `.env`。
## 常用命令 ## 常用命令
```bash ```bash
+13
View File
@@ -63,6 +63,19 @@ class Settings(BaseSettings):
KEYCLOAK_AUDIENCE: str = "" KEYCLOAK_AUDIENCE: str = ""
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900 KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900
# Browser cross-origin access; process environment overrides .env.
CORS_ALLOWED_ORIGINS: str = (
"http://127.0.0.1:5173,http://localhost:5173,"
"http://127.0.0.1:15173,http://localhost:15173"
)
@property
def cors_allowed_origins(self) -> list[str]:
origins = [value.strip() for value in self.CORS_ALLOWED_ORIGINS.split(",") if value.strip()]
if "*" in origins:
raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins")
return origins
# Bocha Web Search API # Bocha Web Search API
BOCHA_API_KEY: str = "" BOCHA_API_KEY: str = ""
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search" BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
+1 -1
View File
@@ -66,7 +66,7 @@ app.add_middleware(AuditMiddleware)
# 确保这是你最后一个添加的 app.add_middleware # 确保这是你最后一个添加的 app.add_middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # 允许所有来源 allow_origins=settings.cors_allowed_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
+73
View File
@@ -0,0 +1,73 @@
import pytest
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
from app.core.config import Settings
ALLOWED_ORIGINS = [
"https://demo.waternetwork.cn",
"http://192.168.1.133",
"https://192.168.1.133",
"http://192.168.1.110",
"https://192.168.1.110",
]
@pytest.mark.parametrize("origins", ["*", " *, http://localhost:5173", "http://localhost:5173,*"])
def test_wildcard_origins_are_rejected(origins: str):
with pytest.raises(ValueError, match="must list explicit origins"):
Settings(_env_file=None, CORS_ALLOWED_ORIGINS=origins).cors_allowed_origins
@pytest.mark.parametrize(
"origin",
ALLOWED_ORIGINS + [
"http://192.168.1.114",
"http://192.168.1.133:5173",
"https://next-demo.waternetwork.cn",
"http://192.168.1.133.evil.example",
"null",
],
)
def test_explicit_origin_preflight(origin: str):
settings = Settings(_env_file=None, CORS_ALLOWED_ORIGINS=", ".join(ALLOWED_ORIGINS))
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
with TestClient(app) as client:
response = client.options(
"/api/v1/projects",
headers={
"Origin": origin,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization,x-project-id",
},
)
allowed = origin in ALLOWED_ORIGINS
assert response.status_code == (200 if allowed else 400)
assert response.headers.get("access-control-allow-origin") == (origin if allowed else None)
if allowed:
assert response.headers["access-control-allow-credentials"] == "true"
assert "authorization" in response.headers["access-control-allow-headers"]
def test_default_origins_are_explicit_local_sources(monkeypatch):
monkeypatch.delenv("CORS_ALLOWED_ORIGINS", raising=False)
settings = Settings(_env_file=None)
assert settings.cors_allowed_origins == [
"http://127.0.0.1:5173", "http://localhost:5173",
"http://127.0.0.1:15173", "http://localhost:15173",
]
def test_environment_controls_origins(monkeypatch):
monkeypatch.setenv("CORS_ALLOWED_ORIGINS", "http://192.168.1.133,https://192.168.1.110")
settings = Settings(_env_file=None)
assert settings.cors_allowed_origins == ["http://192.168.1.133", "https://192.168.1.110"]