From 8780693c1830c2a7f0b97f4bf2ef8c2104c54d06 Mon Sep 17 00:00:00 2001 From: damingishere-coder Date: Sun, 30 Aug 2026 14:06:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=20Relay=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=B8=8E=E4=BA=A4=E4=BB=98=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/intake.py | 26 +++++-- backend/app/services/intake_relay_security.py | 19 ++++- backend/app/services/intake_remote.py | 29 +++++++- backend/tests/test_deployment_contracts.py | 33 +++++++++ backend/tests/test_intake_relay.py | 60 ++++++++++++++- backend/tests/test_intake_remote.py | 22 ++++++ deploy/cloudbase/README.md | 2 +- deploy/synology/.env.example | 1 + deploy/synology/README.md | 38 ++++++++-- deploy/synology/compose.yaml | 2 +- docs/tasks/P28_RELAY_SECURITY_BOUNDARY.md | 73 +++++++++++++++++++ 11 files changed, 286 insertions(+), 19 deletions(-) create mode 100644 backend/tests/test_deployment_contracts.py create mode 100644 docs/tasks/P28_RELAY_SECURITY_BOUNDARY.md diff --git a/backend/app/services/intake.py b/backend/app/services/intake.py index ff95b2f..3b6bb38 100644 --- a/backend/app/services/intake.py +++ b/backend/app/services/intake.py @@ -1079,11 +1079,27 @@ def claim_submission( ): raise HTTPException(status_code=409, detail="该提交正在执行其他审核动作") raw_claim = secrets.token_urlsafe(32) - submission.claim_token_hash = token_digest(raw_claim) - submission.claim_expires_at = changed_at + CLAIM_TTL - submission.revision_number += 1 - submission.updated_at = changed_at - session.flush() + result = session.execute( + update(CustomerFormSubmission) + .where( + CustomerFormSubmission.id == submission.id, + CustomerFormSubmission.status == FormSubmissionStatus.PROCESSING, + CustomerFormSubmission.revision_number == submission.revision_number, + CustomerFormSubmission.idempotency_key == idempotency_key, + CustomerFormSubmission.decision_mode == decision_mode, + ) + .values( + claim_token_hash=token_digest(raw_claim), + claim_expires_at=changed_at + CLAIM_TTL, + revision_number=submission.revision_number + 1, + updated_at=changed_at, + ) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + session.rollback() + raise HTTPException(status_code=409, detail="处理租约已被其他请求重领") + session.expire(submission) record_intake_audit_event( session, event_type="processing_reclaimed", diff --git a/backend/app/services/intake_relay_security.py b/backend/app/services/intake_relay_security.py index 153ae38..ff4a7d0 100644 --- a/backend/app/services/intake_relay_security.py +++ b/backend/app/services/intake_relay_security.py @@ -20,6 +20,22 @@ } +async def _read_public_body_bounded(request: Request) -> bool: + """Cache an accepted body for downstream without reading past the hard limit.""" + + chunks: list[bytes] = [] + total = 0 + async for chunk in request.stream(): + total += len(chunk) + if total > MAX_PUBLIC_BODY_BYTES: + return False + if chunk: + chunks.append(chunk) + # Starlette's cached request wrapper replays this bounded body to the endpoint. + setattr(request, "_body", b"".join(chunks)) + return True + + def require_relay_service( authorization: str | None = Header(default=None), ) -> None: @@ -115,8 +131,7 @@ async def protect_public_intake(request: Request, call_next): headers=PRIVATE_RESPONSE_HEADERS, ) if request.method in {"PUT", "POST", "PATCH"}: - body = await request.body() - if len(body) > MAX_PUBLIC_BODY_BYTES: + if not await _read_public_body_bounded(request): return JSONResponse( status_code=413, content={"detail": "提交内容过大"}, diff --git a/backend/app/services/intake_remote.py b/backend/app/services/intake_remote.py index 6dcdafe..ccba473 100644 --- a/backend/app/services/intake_remote.py +++ b/backend/app/services/intake_remote.py @@ -1,5 +1,5 @@ import os -from urllib.parse import urljoin +from urllib.parse import urljoin, urlsplit import httpx from fastapi import HTTPException @@ -34,6 +34,7 @@ PUBLIC_ORIGIN_ENV = "CATCARE_PUBLIC_FILL_ORIGIN" RELAY_TIMEOUT_SECONDS = 12.0 RELAY_SERVER_ENV = "CATCARE_INTAKE_RELAY_SERVER" +LOOPBACK_RELAY_HOSTS = {"localhost", "127.0.0.1", "::1"} def remote_intake_enabled() -> bool: @@ -43,13 +44,35 @@ def remote_intake_enabled() -> bool: ) +def _validated_relay_url(raw_url: str) -> str: + value = raw_url.strip().rstrip("/") + parsed = urlsplit(value) + if ( + not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + ): + raise HTTPException(status_code=503, detail="云端填写中转地址无效") + if parsed.scheme == "https": + return value + if parsed.scheme == "http" and parsed.hostname.lower() in LOOPBACK_RELAY_HOSTS: + return value + raise HTTPException( + status_code=503, + detail="云端填写中转地址必须使用 HTTPS,明文 HTTP 仅允许本机回环", + ) + + class RemoteIntakeClient: def __init__(self) -> None: - self.base_url = os.getenv(RELAY_URL_ENV, "").strip().rstrip("/") + raw_url = os.getenv(RELAY_URL_ENV, "") self.key = os.getenv(RELAY_KEY_ENV, "") self.public_origin = os.getenv(PUBLIC_ORIGIN_ENV, "").strip().rstrip("/") - if not self.base_url: + if not raw_url.strip(): raise HTTPException(status_code=503, detail="云端填写中转地址未配置") + self.base_url = _validated_relay_url(raw_url) if not self.key: raise HTTPException(status_code=503, detail="云端填写中转凭据未配置") if not self.public_origin: diff --git a/backend/tests/test_deployment_contracts.py b/backend/tests/test_deployment_contracts.py new file mode 100644 index 0000000..cc135b7 --- /dev/null +++ b/backend/tests/test_deployment_contracts.py @@ -0,0 +1,33 @@ +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _read(relative_path: str) -> str: + return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8") + + +def test_synology_relay_is_loopback_only_and_gateway_denies_admin_routes() -> None: + compose = _read("deploy/synology/compose.yaml") + gateway = _read("deploy/synology/gateway/nginx.conf") + postgres_service = compose.split(" postgres:", 1)[1].split("\n relay:", 1)[0] + + assert '"127.0.0.1:${CATCARE_RELAY_LAN_PORT:-18081}:8080"' in compose + assert '"${CATCARE_NAS_LAN_IP}:${CATCARE_RELAY_LAN_PORT:-18081}:8080"' not in compose + assert '"127.0.0.1:${CATCARE_FUNNEL_TARGET_PORT:-18080}:8080"' in compose + assert "\n ports:" not in postgres_service + assert "location ^~ /api/admin/" in gateway + assert "location ~ ^/(?:f|fill)/[A-Za-z0-9_-]+/?$" in gateway + assert "location ~ ^/api/fill/[A-Za-z0-9_-]+(?:/submit)?$" in gateway + + +def test_public_hosting_docs_cover_current_and_compatibility_fill_paths() -> None: + cloudbase = _read("deploy/cloudbase/README.md") + synology = _read("deploy/synology/README.md") + + assert "/f/*" in cloudbase + assert "/fill/*" in cloudbase + assert "tailscale serve --https=8443 --bg http://127.0.0.1:18081" in synology + assert "http://:18081" in synology + assert "https://:8443" in synology diff --git a/backend/tests/test_intake_relay.py b/backend/tests/test_intake_relay.py index a8821c9..6697e39 100644 --- a/backend/tests/test_intake_relay.py +++ b/backend/tests/test_intake_relay.py @@ -1,13 +1,17 @@ +import asyncio from collections.abc import Generator from datetime import datetime, timedelta, timezone +import httpx import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient from sqlalchemy.orm import Session, sessionmaker from app.db.session import build_engine, get_db from app.intake_relay import create_relay_app from app.models.intake import CustomerFormSubmission +from app.services.intake import claim_submission @pytest.fixture @@ -105,6 +109,34 @@ def test_relay_cors_auth_body_limit_and_route_isolation( assert relay_client.get(private_path).status_code == 404 +def test_relay_stops_streaming_unknown_length_body_at_limit( + relay_client: TestClient, +) -> None: + yielded_chunks: list[int] = [] + + async def oversized_chunks(): + for index in range(4): + yielded_chunks.append(index) + yield b"x" * (100 * 1024) + + async def request() -> httpx.Response: + transport = httpx.ASGITransport(app=relay_client.app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.put( + "/api/fill/streamed-test-token", + content=oversized_chunks(), + ) + + response = asyncio.run(request()) + + assert response.status_code == 413 + assert response.headers["cache-control"] == "no-store" + assert yielded_chunks == [0, 1, 2] + + def test_relay_rate_limit_applies_to_token( relay_client: TestClient, ) -> None: @@ -159,11 +191,36 @@ def test_relay_claim_complete_and_redaction_keep_only_receipt( }, ) assert claim.status_code == 200 + + session_factory = relay_client.app.state.testing_session + with session_factory() as first_session, session_factory() as stale_session: + first_submission = first_session.get(CustomerFormSubmission, summary["id"]) + stale_submission = stale_session.get(CustomerFormSubmission, summary["id"]) + assert first_submission is not None and stale_submission is not None + reclaimed = claim_submission( + first_session, + first_submission, + expected_revision=claim.json()["revision"], + idempotency_key="relay-decision-idempotency-0001", + decision_mode="customer", + ) + assert reclaimed.claim_token is not None + first_session.commit() + with pytest.raises(HTTPException) as conflict: + claim_submission( + stale_session, + stale_submission, + expected_revision=claim.json()["revision"], + idempotency_key="relay-decision-idempotency-0001", + decision_mode="customer", + ) + assert conflict.value.status_code == 409 + complete = relay_client.post( f"/api/admin/intake/submissions/{summary['id']}/complete", headers=_auth(), json={ - "claim_token": claim.json()["claim_token"], + "claim_token": reclaimed.claim_token, "idempotency_key": "relay-decision-idempotency-0001", "decision_mode": "customer", "customer_id": 987654, @@ -184,6 +241,7 @@ def test_relay_claim_complete_and_redaction_keep_only_receipt( assert [event["event_type"] for event in detail["audit_events"]] == [ "submitted", "processing_claimed", + "processing_reclaimed", "completed_customer", ] assert "TEST-CONTACT" not in str(detail["audit_events"]) diff --git a/backend/tests/test_intake_remote.py b/backend/tests/test_intake_remote.py index 759e86f..ecbf77b 100644 --- a/backend/tests/test_intake_remote.py +++ b/backend/tests/test_intake_remote.py @@ -85,6 +85,28 @@ def complete( ) +def test_remote_client_requires_https_except_for_loopback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CATCARE_INTAKE_RELAY_KEY", "relay-test-secret") + monkeypatch.setenv("CATCARE_PUBLIC_FILL_ORIGIN", "https://fill.example.test") + monkeypatch.setenv("CATCARE_INTAKE_RELAY_URL", "http://192.168.1.30:18081") + + with pytest.raises(HTTPException, match="必须使用 HTTPS"): + intake_remote.RemoteIntakeClient() + + monkeypatch.setenv( + "CATCARE_INTAKE_RELAY_URL", + "https://nas-name.example.ts.net:8443/", + ) + secure = intake_remote.RemoteIntakeClient() + assert secure.base_url == "https://nas-name.example.ts.net:8443" + + monkeypatch.setenv("CATCARE_INTAKE_RELAY_URL", "http://127.0.0.1:18081/") + loopback = intake_remote.RemoteIntakeClient() + assert loopback.base_url == "http://127.0.0.1:18081" + + def test_remote_complete_failure_retry_returns_local_receipt_without_duplicates( migrated_database_url: str, monkeypatch: pytest.MonkeyPatch, diff --git a/deploy/cloudbase/README.md b/deploy/cloudbase/README.md index 746c9b3..5b4dc48 100644 --- a/deploy/cloudbase/README.md +++ b/deploy/cloudbase/README.md @@ -53,7 +53,7 @@ docker build -f deploy/cloudbase/intake-relay/Dockerfile -t catcare-intake-relay ## 静态托管要求 -- 上传 `frontend/dist-public/`,将所有 `/fill/*` 回退到 `index.html`。 +- 上传 `frontend/dist-public/`,将 `/f/*` 和兼容路径 `/fill/*` 都回退到 `index.html`;新生成的正式分享链接使用 `/f/{token}`。 - 只允许公开页 Origin 调用 CloudBase Run;不要使用 `*` CORS。 - 首次联调可使用平台默认 HTTPS 域名,第一版不创建公众号或小程序资源;CloudBase 官方说明默认 HTTP 访问域名仅适合开发测试,正式对客前应换成已备案自定义域名,避免安全提示中间页和默认域名限制。 - 公网检查必须确认 `/admin`、`/mobile`、`/api/admin/customers`、`/api/orders` 等路径均不可访问。 diff --git a/deploy/synology/.env.example b/deploy/synology/.env.example index 812d6e7..8e94a88 100644 --- a/deploy/synology/.env.example +++ b/deploy/synology/.env.example @@ -3,4 +3,5 @@ CATCARE_PUBLIC_HOST=nas-name.example.ts.net CATCARE_NAS_LAN_IP=192.168.1.30 CATCARE_FUNNEL_TARGET_PORT=18080 +# Legacy variable name; Compose binds this Relay port to NAS loopback only. CATCARE_RELAY_LAN_PORT=18081 diff --git a/deploy/synology/README.md b/deploy/synology/README.md index 4548893..4fa9dbe 100644 --- a/deploy/synology/README.md +++ b/deploy/synology/README.md @@ -5,11 +5,15 @@ ## 已确认的目标结构 ```text -互联网 HTTPS - -> Tailscale Funnel(只转发 NAS 127.0.0.1:18080) +互联网 HTTPS :443 + -> Tailscale Funnel(只转发 NAS 127.0.0.1:18080 的公开 Gateway) -> gateway(只允许填写页和 public fill API) - -> relay(局域网管理口另绑定 192.168.1.30:18081 + Bearer Secret) + -> relay -> PostgreSQL(仅内部 Docker 网络) + +Windows 管理端 -> Tailnet HTTPS :8443 + -> Tailscale Serve(只转发 NAS 127.0.0.1:18081) + -> relay(Bearer Secret 只在 TLS 内传输) ``` ## 本地生成镜像和部署包 @@ -45,11 +49,32 @@ ## 网络边界 - Funnel 目标:`http://127.0.0.1:18080`。 -- 本地 CatCare 中转地址:`http://192.168.1.30:18081`。 +- Relay 宿主机端口只绑定 NAS 回环:`http://127.0.0.1:18081`,不得改回 NAS 局域网 IP 或 `0.0.0.0`。 +- Windows 侧 `CATCARE_INTAKE_RELAY_URL` 必须使用 Tailscale Serve 提供的 `https://:8443`,不能使用 `http://:18081`。 - PostgreSQL 无 `ports`,只能由内部 `data` 网络访问。 - 公网网关明确拒绝 `/api/admin/*`,其余非白名单路径统一 404。 - DS218+ 的群晖内核不支持 Docker `NanoCPUs` 硬配额,因此使用兼容的 `cpu_shares` 相对权重。该内核也会忽略 `pids_limit`;实际硬限制只有内存,进程数限制不能作为已生效的安全边界。 +### Tailnet 内管理 HTTPS + +以下命令属于设备侧网络变更,本仓库不会自动执行。首次配置或变更前,必须确认 Windows 和 NAS 位于同一 tailnet、MagicDNS/HTTPS 已启用,并在 tailnet ACL 中只允许指定 Windows 设备或用户访问 NAS 的 TCP 8443。然后由用户在 NAS SSH 终端执行: + +```sh +tailscale serve --https=8443 --bg http://127.0.0.1:18081 +tailscale serve status --json +``` + +Serve 8443 只在 tailnet 内可达并自动终止 TLS;公网 Funnel 继续使用 443。两者不得配置到同一端口。设备侧验收至少包括: + +命令语法与端口隔离规则以 [Tailscale Serve CLI](https://tailscale.com/docs/reference/tailscale-cli/serve) 和 [Tailscale Funnel 限制](https://tailscale.com/kb/1223/funnel) 为准。 + +```powershell +Test-NetConnection '' -Port 18081 +Invoke-RestMethod 'https://:8443/api/ready' +``` + +第一条必须显示 LAN 直连失败,第二条必须通过系统信任链完成 HTTPS 校验并返回 `ready`。不得使用 `-SkipCertificateCheck`。Windows 防火墙三个 Profile 应继续启用且默认阻止入站,不要为 CatCare 的 8000、5180、18080 或 18081 新增入站放行规则。 + ## 公开 Gateway 的受限更新 公开页面改动不需要重建 PostgreSQL、Relay 或备份容器。先在 Windows 本机生成只包含 Gateway 的离线镜像包: @@ -100,8 +125,9 @@ sudo sh /volume3/docker/CatCare/install-catcare-restricted-ssh.sh ## 公开前闸门 1. 四个容器均健康。 -2. 局域网填写、保存、提交和本地审核链路通过。 +2. 公网填写、保存、提交和 tailnet HTTPS 审核链路通过;NAS LAN 的 18081 直连失败。 3. 网关对 `/admin`、`/api/admin/intake/tokens`、订单、财务和数据库端口拒绝。 -4. 用户再次确认“将 CatCare 指定入口公开到互联网”后,才执行 `tailscale funnel --bg http://127.0.0.1:18080`。 +4. `tailscale serve status --json` 证明管理入口只在 8443,且 tailnet ACL 已限制管理来源。 +5. 用户再次确认“将 CatCare 指定入口公开到互联网”后,才执行 `tailscale funnel --bg http://127.0.0.1:18080`。 Funnel 当前仍是 Beta,存在带宽限制;家庭断网、断电、NAS 故障和第三方服务异常都会造成公网填写不可用,不构成正式生产 SLA。 diff --git a/deploy/synology/compose.yaml b/deploy/synology/compose.yaml index da25348..8c3fe0b 100644 --- a/deploy/synology/compose.yaml +++ b/deploy/synology/compose.yaml @@ -52,7 +52,7 @@ services: - postgres_app_password - relay_key ports: - - "${CATCARE_NAS_LAN_IP}:${CATCARE_RELAY_LAN_PORT:-18081}:8080" + - "127.0.0.1:${CATCARE_RELAY_LAN_PORT:-18081}:8080" networks: - edge - data diff --git a/docs/tasks/P28_RELAY_SECURITY_BOUNDARY.md b/docs/tasks/P28_RELAY_SECURITY_BOUNDARY.md new file mode 100644 index 0000000..e35e4ae --- /dev/null +++ b/docs/tasks/P28_RELAY_SECURITY_BOUNDARY.md @@ -0,0 +1,73 @@ +# P28 Relay 安全与交付边界 + +## 背景 + +第二次工程复检确认,公网填写链路仍有四组值得立即收口的问题:Relay 处理中租约重领不是数据库原子写、公开请求在未知长度时可能无上限缓冲、Windows 到 NAS 的 Relay Bearer 仍走局域网明文 HTTP,以及正式 `/f/{token}` 与 CloudBase 静态回退文档不一致。 + +本任务是既定整改计划的第二阶段,必须保持为独立、可审查、可回滚的 PR。 + +## 目标 + +- 将 Relay `processing` 状态重领改为数据库条件更新,确保并发请求只有一个成功。 +- 对公开填写写请求执行最多 256 KiB 的有界读取,不在检查前无上限缓冲请求体。 +- 本机 Relay 客户端拒绝向非回环明文 HTTP 地址发送 Bearer。 +- Synology Relay 管理端口只绑定 NAS 回环,由 Tailscale Serve 在 tailnet 内提供 HTTPS;公网 Funnel 继续只指向 Gateway。 +- 同步 `/f/*` 与 `/fill/*` 的静态托管契约和设备侧验收步骤。 + +## 允许修改范围 + +- `backend/app/services/intake.py` +- `backend/app/services/intake_relay_security.py` +- `backend/app/services/intake_remote.py` +- `backend/tests/test_intake_relay.py`、`backend/tests/test_intake_remote.py` +- `deploy/synology/compose.yaml`、`deploy/synology/.env.example` +- `deploy/synology/README.md`、`deploy/cloudbase/README.md` +- 与上述边界直接相关的部署契约测试 +- 本任务文件 + +## 禁止修改范围 + +- 本机管理 API 的认证体系、订单/任务/财务业务逻辑 +- 真实 NAS、Tailscale、Windows 防火墙、证书、CloudBase 资源和公网状态 +- PostgreSQL、备份容器、脱敏 Worker 健康语义(留给 PR3) +- UI 回归、包体、依赖升级、God Component 和低价值清理 +- `.env`、Secret、Token、私钥、证书内容、审计报告与 Codemap 产物 + +## 已确定实现要求 + +1. Relay 重领必须使用 `UPDATE ... WHERE status/revision/idempotency_key/decision_mode` 并检查 `rowcount`;冲突返回 `409`,失败请求不得覆盖成功请求的 claim token。 +2. 公开写请求即使缺少或伪造 `Content-Length`,也只能读取到上限加一个字节;超限立即返回 `413`,可接受请求必须可由下游正常读取。 +3. `CATCARE_INTAKE_RELAY_URL` 只允许 HTTPS;仅 `localhost`、`127.0.0.1`、`::1` 可在本地测试或回环代理场景使用 HTTP。 +4. Synology Compose 不再把 Relay 绑定到 NAS LAN IP;只发布到 `127.0.0.1`。设备侧通过 `tailscale serve --https=8443 --bg http://127.0.0.1:` 提供 tailnet 内 TLS。 +5. 公网 Funnel 与管理 Serve 必须使用不同端口;公网入口仍只允许 Gateway 白名单,管理路由不得进入 Funnel。 +6. 仓库只提供配置和验收说明,不自动执行设备命令。真正修改 NAS/Tailscale/防火墙或公网更新前必须重新确认。 +7. CloudBase 文档必须同时要求 `/f/*` 和 `/fill/*` 回退到 `index.html`。 + +## 验收标准 + +- 两个会话用同一 processing revision 重领时,恰好一个成功,另一个 `409`;成功 token 可完成租约。 +- 无长度或伪造长度的分块请求超过 256 KiB 时返回 `413`,且应用不会先缓存完整超大请求。 +- 非回环 `http://` Relay URL 在发送凭据前被拒绝;HTTPS 与回环 HTTP 保持可用。 +- Compose 渲染后 Relay 只绑定 `127.0.0.1`,Gateway 仍只绑定 `127.0.0.1`,PostgreSQL 无宿主机端口。 +- `/f/*`、`/fill/*`、公开 API 和管理路由拒绝契约有自动化或静态配置证据。 +- 全量后端测试、前端测试、lint、类型检查和双构建通过。 +- Git diff 不包含范围外修改、Secret、调试代码或部署产物。 + +## 测试命令 + +- `.venv\\Scripts\\python.exe -m pytest backend/tests/test_intake_relay.py backend/tests/test_intake_remote.py -q` +- `.venv\\Scripts\\python.exe -m pytest backend/tests -q` +- `npm --prefix frontend test -- --run` +- `npm --prefix frontend run lint` +- `npm --prefix frontend run typecheck` +- `npm --prefix frontend run build` +- `npm --prefix frontend run build:public` +- `docker compose --env-file deploy/synology/.env.example -f deploy/synology/compose.yaml config --quiet` + +## 返回格式 + +- 修改摘要与安全边界变化 +- 测试和配置渲染的准确结果 +- 设备侧仍待执行的步骤和中断风险 +- Git 分支、提交、远端 SHA、PR 和 CI 状态 +- 明确声明未修改真实 NAS、Tailscale、防火墙、CloudBase 或生产数据库,且未自动合并本 PR