diff --git a/.env.example b/.env.example index a43c131..22a9ac0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ -# TJWater Server 环境变量模板 +# NextTJWater Server 环境变量模板 # 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。 -# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。 +# 生产配置由部署主机的受控 next-backend.env 注入,不随镜像或 CI/CD 同步本地 .env。 +# 进程环境变量优先;修改 Compose env_file 后需重新创建容器。 # 基础运行 PROJECT_NAME="TJWater Server" @@ -69,9 +70,11 @@ KEYCLOAK_ALGORITHM="RS256" KEYCLOAK_AUDIENCE="account" KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900 -# 工作台事件与跨域 -# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。 -CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173" +# 浏览器跨域访问(主 API) +# 必须填写明确来源(协议、域名或 IP、端口),英文逗号分隔;不允许 * 或网段正则。 +CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173 + +# 工作台事件 WORKBENCH_EVENTS_ENABLED=true WORKBENCH_EVENT_RETENTION_DAYS=7 @@ -116,6 +119,17 @@ OPERATIONAL_ACTION_PERIPHERAL_URL="" OPERATIONAL_ACTION_COMMAND_GATEWAY_URL="" OPERATIONAL_ACTION_TIMEOUT_SECONDS=10 +# 外围样板(挂载于 /peripheral;故障注入默认关闭) +# 独立启动时用于调用主 API 验证身份;容器内按网络填写主服务地址。 +PERIPHERAL_SAMPLE_NEXT_SERVER_URL="http://127.0.0.1:8000" +# 以下两个默认库选择只供种子、导出等脚本使用,HTTP 请求仍按已授权项目路由。 +PERIPHERAL_SAMPLE_DB_NAME="tjwater_v2" +PERIPHERAL_SAMPLE_PROJECT_CODE="tjwater_v2" +# 子应用独立运行时的浏览器来源列表,使用 JSON 格式;挂载时还受主 API CORS 配置控制。 +PERIPHERAL_SAMPLE_CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]' +PERIPHERAL_SAMPLE_ENABLE_FAULTS=false +PERIPHERAL_SAMPLE_TIMEOUT_SECONDS=5 + # MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。 TJWATER_MCP_ENABLED=false TJWATER_MCP_ISSUER_URI="" diff --git a/README.md b/README.md index 56bd6d6..0bd3c61 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,18 @@ conda activate server 基础数据库变量用于启动和项目供应。运行时的项目请求仍以元数据库中的 `biz_data` 和 `iot_data` 路由为准,不会因为存在默认数据库变量而回退到固定项目。 +CORS 通过后端 `.env` 或部署进程环境变量中的 `CORS_ALLOWED_ORIGINS` 配置,进程环境变量优先。 +必须使用英文逗号分隔的明确页面来源(协议、域名或 IP、端口,不带路径),不能填写 `*`。 +未设置时默认只允许本机 `5173`、`15173` 开发来源。正式部署按实际浏览器地址填写,例如: + +```dotenv +CORS_ALLOWED_ORIGINS=https://next-demo.waternetwork.cn,http://192.168.1.133,https://192.168.1.133,http://192.168.1.110,https://192.168.1.110 +``` + +CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果;非默认端口须逐项列出。 +保留跨域凭据支持及 `Authorization`、`X-Project-Id` 请求头,接口认证和权限校验继续生效。 +修改后需重启进程;使用 Compose `env_file` 注入时需重新创建容器才能读取新值。 + ## 常用命令 外围接入样板作为 `/peripheral` 子应用随主 API 进程启动,复用 PostgreSQL 且不自动建表;授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。 diff --git a/docs/operational-task-workers.md b/docs/operational-task-workers.md index 585ca06..7c949af 100644 --- a/docs/operational-task-workers.md +++ b/docs/operational-task-workers.md @@ -138,7 +138,7 @@ conda run -n server python scripts/cleanup_workbench_events.py \ 禁止省略 `--project-id` 执行清理。数据库维护脚本同样遵循先在 `tjwater_v2` 验证、再逐库扩展的规则。 -浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源。 +浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源,不允许 `*`。配置说明见 [README](../README.md#环境配置)。 ## 外围样板工单同步 diff --git a/tests/unit/test_cors.py b/tests/unit/test_cors.py new file mode 100644 index 0000000..788c7a2 --- /dev/null +++ b/tests/unit/test_cors.py @@ -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://next-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://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"]