fix(cors): require explicit allowed origins
This commit is contained in:
+40
-47
@@ -1,90 +1,83 @@
|
||||
# TJWater Server 环境变量配置模板
|
||||
# 复制此文件为 .env 并填写实际值
|
||||
# CI/CD: 生产环境变量由 Dev 主机的受控 backend.env 注入,不要将完整 .env 保存为 Gitea 仓库密钥。
|
||||
ENVIRONMENT="production"
|
||||
NETWORK_NAME="tjwater"
|
||||
# ============================================
|
||||
# 敏感配置加密 (必填)
|
||||
# ============================================
|
||||
# TJWaterServerBinary 环境变量模板
|
||||
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
|
||||
# 生产配置由部署主机的受控 backend.env 注入,不随镜像或 CI/CD 同步本地 .env。
|
||||
# 进程环境变量优先;修改 Compose env_file 后需重新创建容器。
|
||||
|
||||
# Fernet 格式,生产环境必须替换为独立密钥
|
||||
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置
|
||||
# 基础运行
|
||||
PROJECT_NAME="TJWater Server"
|
||||
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"
|
||||
|
||||
# ============================================
|
||||
# 数据库配置 (PostgreSQL)
|
||||
# ============================================
|
||||
# 业务数据库(PostgreSQL)
|
||||
DB_NAME="tjwater"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
DB_USER="tjwater"
|
||||
DB_PASSWORD="password"
|
||||
|
||||
# ============================================
|
||||
# 数据库配置 (TimescaleDB)
|
||||
# ============================================
|
||||
# 时序数据库(TimescaleDB)
|
||||
TIMESCALEDB_DB_NAME="tjwater"
|
||||
TIMESCALEDB_DB_HOST="localhost"
|
||||
TIMESCALEDB_DB_PORT="5433"
|
||||
TIMESCALEDB_DB_USER="tjwater"
|
||||
TIMESCALEDB_DB_PASSWORD="password"
|
||||
|
||||
# ============================================
|
||||
# 元数据数据库配置 (Metadata DB)
|
||||
# ============================================
|
||||
# 元数据数据库
|
||||
METADATA_DB_NAME="system_hub"
|
||||
METADATA_DB_HOST="localhost"
|
||||
METADATA_DB_PORT="5432"
|
||||
METADATA_DB_USER="tjwater"
|
||||
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_TS_CACHE_SIZE="16"
|
||||
PROJECT_PG_POOL_MIN_SIZE="0"
|
||||
PROJECT_PG_POOL_SIZE="4"
|
||||
PROJECT_PG_MAX_OVERFLOW="2"
|
||||
PROJECT_TS_POOL_MIN_SIZE="0"
|
||||
PROJECT_TS_POOL_MAX_SIZE="4"
|
||||
# 项目数据库连接池、缓存与模板
|
||||
PROJECT_PG_CACHE_SIZE=16
|
||||
PROJECT_TS_CACHE_SIZE=16
|
||||
PROJECT_PG_POOL_MIN_SIZE=0
|
||||
PROJECT_PG_POOL_SIZE=4
|
||||
PROJECT_PG_MAX_OVERFLOW=2
|
||||
PROJECT_TS_POOL_MIN_SIZE=0
|
||||
PROJECT_TS_POOL_MAX_SIZE=4
|
||||
WNDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_schema_template"
|
||||
TIMESCALEDB_SCHEMA_TEMPLATE_DB_NAME="tjwater_v2_timescale_template"
|
||||
WNDB_TEMP_DB_MAX_COUNT="8"
|
||||
WNDB_TEMP_DB_MAX_COUNT=8
|
||||
|
||||
# ============================================
|
||||
# GeoServer 项目供应
|
||||
# ============================================
|
||||
GEOSERVER_URL="http://localhost:8080/geoserver"
|
||||
GEOSERVER_USERNAME="admin"
|
||||
GEOSERVER_PASSWORD="password"
|
||||
# 留空时分别复用 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD。
|
||||
# 留空时分别复用 DB_HOST、DB_PORT、DB_USER、DB_PASSWORD。
|
||||
# GeoServer 在容器内运行时,GEOSERVER_DB_HOST 通常应填写数据库服务名。
|
||||
GEOSERVER_DB_HOST=""
|
||||
GEOSERVER_DB_PORT=""
|
||||
GEOSERVER_DB_USER=""
|
||||
GEOSERVER_DB_PASSWORD=""
|
||||
# 30 天客户端缓存,单位为秒。
|
||||
GEOSERVER_CLIENT_CACHE_SECONDS="2592000"
|
||||
# 客户端缓存时间,单位为秒;默认 30 天。
|
||||
GEOSERVER_CLIENT_CACHE_SECONDS=2592000
|
||||
|
||||
# ============================================
|
||||
# Keycloak JWT (可选)
|
||||
# ============================================
|
||||
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
KEYCLOAK_ALGORITHM=RS256
|
||||
# Keycloak JWT 校验
|
||||
# 未启用认证时可留空;生产环境应注入完整公钥和预期 audience。
|
||||
KEYCLOAK_PUBLIC_KEY=""
|
||||
KEYCLOAK_ALGORITHM="RS256"
|
||||
KEYCLOAK_AUDIENCE="account"
|
||||
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 Web Search API
|
||||
# ============================================
|
||||
BOCHA_API_KEY="sk-your-bocha-api-key"
|
||||
# Bocha 网页搜索(旧版仍保留;未启用时令牌留空)
|
||||
BOCHA_API_KEY=""
|
||||
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
|
||||
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
|
||||
|
||||
# ============================================
|
||||
# Tianditu Geocoding API
|
||||
# ============================================
|
||||
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
|
||||
# 天地图地理编码(旧版使用 TIANDITU_GEOCODER_TOKEN)
|
||||
TIANDITU_GEOCODER_TOKEN=""
|
||||
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
|
||||
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
|
||||
|
||||
@@ -43,6 +43,22 @@ conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
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
|
||||
|
||||
@@ -63,6 +63,19 @@ class Settings(BaseSettings):
|
||||
KEYCLOAK_AUDIENCE: str = ""
|
||||
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_API_KEY: str = ""
|
||||
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ app.add_middleware(AuditMiddleware)
|
||||
# 确保这是你最后一个添加的 app.add_middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 允许所有来源
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user