From c261b2bc9a9e90eb6eeccd112574ba39db0fa758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 12 Aug 2026 11:41:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20CORS=E9=99=90=E5=88=B6+SSRF?= =?UTF-8?q?=E9=98=B2=E6=8A=A4+PATCH=E4=B9=90=E8=A7=82=E9=94=81+accounts?= =?UTF-8?q?=E5=AE=89=E5=85=A8cap(4=20important=20bugs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.py + config.py: CORS origins read from Settings.cors_origins (comma-separated). Empty falls back to dev origin localhost:5173; never wildcard. Methods locked to GET/PATCH/POST; allow_credentials=False. - scraper/media.py: download_url now passes follow_redirects=False to httpx. OSS URLs return 200 directly; a 3xx is treated as a download failure (caught by existing except clauses) instead of opening an SSRF vector to internal hosts. - routers/voice_clips.py: PATCH /voice-clips/{id}/match accepts an optional If-Match header carrying the client's last-seen updated_at. On mismatch the row was modified by another request and the server returns 409. Without the header the behaviour is unchanged (backward compatible). Response now echoes the post-update updated_at so the client can fold it into the next request. - routers/organization.py: /api/accounts now UNION ALLs the wechat_account and whatsapp_account projections at the SQL level and pushes COUNT + pagination (offset/limit) down to the DB. Stable ordering on (channel, account_id). Removes the in-memory materialization that broke at scale. Tests: 395 passed. Added optimistic-lock coverage (If-Match accepted / stale 409 / absent header backward-compat). Updated existing PATCH tests for the new updated_at response field. Co-Authored-By: Claude --- yunke-server/app/api/main.py | 23 +++- yunke-server/app/api/routers/organization.py | 122 ++++++++++++------- yunke-server/app/api/routers/voice_clips.py | 28 ++++- yunke-server/app/config.py | 9 ++ yunke-server/app/scraper/media.py | 12 +- yunke-server/tests/test_api_voice_clips.py | 99 ++++++++++++++- 6 files changed, 238 insertions(+), 55 deletions(-) diff --git a/yunke-server/app/api/main.py b/yunke-server/app/api/main.py index 3fee548..815ae8a 100644 --- a/yunke-server/app/api/main.py +++ b/yunke-server/app/api/main.py @@ -20,8 +20,10 @@ Includes: * ``GET /api/search`` (see :mod:`app.api.routers.search`). * ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`). -CORS is configured permissively (wildcard origin, no credentials) so the local -frontend can call the API during development. +CORS is configured to read explicit allowed origins from ``Settings.cors_origins`` +(comma-separated ``CORS_ORIGINS`` env var). Empty (default) falls back to the dev +origin ``http://localhost:5173``; set ``CORS_ORIGINS=https://app.example.com`` in +prod. Never wildcard — see the comment near ``add_middleware`` below. Auth (Task 2.9): a single shared-token dependency (:func:`app.api.auth.require_auth`) is applied globally on the app. Trust model: when ``Settings.api_token`` is @@ -52,14 +54,27 @@ from app.api.routers import ( sync, voice_clips, ) +from app.config import get_settings app = FastAPI(title="yunke data mirror", dependencies=[Depends(require_auth)]) +# CORS: read explicit allowed origins from Settings (``CORS_ORIGINS`` env var, +# comma-separated). Empty -> dev fallback (``http://localhost:5173``). Never +# ``*`` — wildcard CORS on an authenticated API is an open door even without +# credentials, since the middleware would reflect any attacker-controlled +# origin back in ``Access-Control-Allow-Origin``. +_settings = get_settings() +_cors = _settings.cors_origins.strip() +if _cors: + _origins = [o.strip() for o in _cors.split(",") if o.strip()] +else: + _origins = [] # empty = deny all cross-origin (safest default) app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], + allow_origins=_origins or ["http://localhost:5173"], # dev fallback + allow_methods=["GET", "PATCH", "POST"], allow_headers=["*"], + allow_credentials=False, ) app.include_router(customer.router) diff --git a/yunke-server/app/api/routers/organization.py b/yunke-server/app/api/routers/organization.py index e283144..dec8b56 100644 --- a/yunke-server/app/api/routers/organization.py +++ b/yunke-server/app/api/routers/organization.py @@ -10,9 +10,10 @@ Read-only queries over the mirrored ``department``, ``staff``, * ``GET /api/accounts`` -> ``Page[AccountOut]`` projecting both staff-messaging account tables onto a unified shape (see :class:`AccountOut`). The optional ``channel`` filter restricts to one of ``wechat`` / ``whatsapp``; when - omitted, both tables are unioned in memory. Staff-account volumes are modest - (tens to low hundreds of rows), so the in-memory merge is cheap and keeps - the API contract simple. + omitted, both tables are UNIONed at the SQL level and paginated in-database + (no full-table Python materialization). Staff-account volumes are modest in + practice today, but the SQL-level pagination keeps the endpoint safe if a + tenant ever scales past thousands of rows. """ from __future__ import annotations @@ -91,49 +92,80 @@ async def list_accounts( """List staff messaging accounts (WeChat / WhatsApp) in a unified shape. ``channel`` optional; one of ``wechat`` / ``whatsapp``. When omitted, both - tables are merged in memory. Pagination is applied after the merge using a - stable ``(channel, account_id)`` ordering. + tables are UNIONed at the SQL level. Pagination and total count are pushed + down to the database (single ``COUNT(*)`` over the UNION subquery + a + paginated ``SELECT`` with ``OFFSET``/``LIMIT``) so we never materialize the + full cross-table row set in Python. Stable ordering is ``(channel, + account_id)``. + + ``AccountOut`` field mapping (identical shape from both source tables): + + * WeChat: account_id=wx_id, nickname=nick_name, user_id, user_name, phone, + contacts_num, group_num, is_on_job, raw_json (verbatim upstream record). + * WhatsApp: account_id=uid (+ same projection). """ - selected_channels = [channel] if channel else ["wechat", "whatsapp"] + wechat_q = sa.select( + WechatAccount.wx_id.label("account_id"), + sa.literal("wechat").label("channel"), + WechatAccount.nick_name.label("nickname"), + WechatAccount.user_id.label("user_id"), + WechatAccount.user_name.label("user_name"), + WechatAccount.phone.label("phone"), + WechatAccount.contacts_num.label("contacts_num"), + WechatAccount.group_num.label("group_num"), + WechatAccount.is_on_job.label("is_on_job"), + WechatAccount.raw_json.label("raw_json"), + ) + whatsapp_q = sa.select( + WhatsappAccount.uid.label("account_id"), + sa.literal("whatsapp").label("channel"), + WhatsappAccount.nick_name.label("nickname"), + WhatsappAccount.user_id.label("user_id"), + WhatsappAccount.user_name.label("user_name"), + WhatsappAccount.phone.label("phone"), + WhatsappAccount.contacts_num.label("contacts_num"), + WhatsappAccount.group_num.label("group_num"), + WhatsappAccount.is_on_job.label("is_on_job"), + WhatsappAccount.raw_json.label("raw_json"), + ) - rows: list[AccountOut] = [] - if "wechat" in selected_channels: - result = (await session.execute(sa.select(WechatAccount))).scalars().all() - for r in result: - rows.append( - AccountOut( - channel="wechat", - account_id=r.wx_id, - nickname=r.nick_name, - user_id=r.user_id, - user_name=r.user_name, - phone=r.phone, - contacts_num=r.contacts_num, - group_num=r.group_num, - is_on_job=r.is_on_job, - raw_json=r.raw_json, - ) - ) - if "whatsapp" in selected_channels: - result = (await session.execute(sa.select(WhatsappAccount))).scalars().all() - for r in result: - rows.append( - AccountOut( - channel="whatsapp", - account_id=r.uid, - nickname=r.nick_name, - user_id=r.user_id, - user_name=r.user_name, - phone=r.phone, - contacts_num=r.contacts_num, - group_num=r.group_num, - is_on_job=r.is_on_job, - raw_json=r.raw_json, - ) - ) + if channel == "wechat": + parts: list[sa.Select] = [wechat_q] + elif channel == "whatsapp": + parts = [whatsapp_q] + else: + parts = [wechat_q, whatsapp_q] - rows.sort(key=lambda a: (a.channel, a.account_id)) - total = len(rows) - start = (page - 1) * page_size - items = rows[start : start + page_size] + base_q: sa.Select = parts[0] if len(parts) == 1 else sa.union_all(*parts) + + # Wrap in a subquery so COUNT and paginated SELECT both have a stable + # column set (the labelled columns become ``sub.c.