feat: 头像上传(uuid重命名/魔数校验/2MB/配置版本管线)与三级解析链

- POST /api/platform/tenants/{slug}/brand/avatar(require_tenant):
  multipart 收文件,魔数判定类型(\x89PNG/\xff\xd8\xff/RIFF....WEBP,
  优先于扩展名——改后缀骗不过,存盘扩展名取自内容)、≤2MB(413,
  upload.size 预检免拷贝)、服务端 uuid4 重命名(原始文件名不参与
  路径),落 data/uploads/<slug>/(目录懒建,gitignore 已忽略)
- 走 Task 6 配置版本管线(复用 config_routes 内部 helper):读当前
  快照 → brand 组只替换 avatar 字段 → validate_group →
  write_config_version(comment=头像上传) → registry_reload 热生效
- /api/public/avatar 三级解析链:uploads → seed 资产 → 静态兜底;
  Phase 1 文件名穿越防线(拒 / \ . ..)扩到 uploads 段
- registry.uploads_root:自 data_dir.parent 派生(生产=<repo>/data/
  uploads;测试注入 tmp data_dir 时自动落 tmp 内,隔离免污染)
- 新依赖 python-multipart(multipart 表单解析)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 10:35:41 +08:00
parent 7502176190
commit a43c654337
6 changed files with 382 additions and 17 deletions
+25 -15
View File
@@ -9,8 +9,10 @@
- ``?site=<slug>`` 站点覆盖:空域名站点(如 jiancai 的 es)靠嵌入代码
``data-site`` 接入,未知/缺省回退中间件按 Host 解析出的 state.site
(即租户默认门面);
- avatar 按当前租户解析 ``seed_profiles/<slug>/assets/<brand.avatar 文件名>``,
文件不存在回退 ``static/kefu-small.png``(两租户头像各归各,字节级不同)。
- avatar 按当前租户走**三级解析链**(Task 7):``data/uploads/<slug>/
<brand.avatar 文件名>``(后台上传的头像,优先)→ ``seed_profiles/<slug>/
assets/<同名>``(出厂档案资产)→ ``static/kefu-small.png`` 兜底。文件名
的穿越防线(拒 ``/`` ``\\`` ``.`` ``..``,Phase 1)同样覆盖 uploads 段。
中间件已保证到达路由的请求必有 state.tenant/state.site;本路由照常经
中间件(租户域名下访问),经 get_runtime 取运行时。
@@ -73,20 +75,28 @@ async def avatar(request: Request):
runtime = get_runtime(request)
site, _ = _effective_site(request, runtime)
# 只取文件名(PurePosixPath.name 剥掉一切路径成分)——brand.avatar 是
# "/static/kefu-small.png" 这类路径,取基名后拼租户 assets 目录,无路径
# 逃逸面(单成分文件名不可能越出 assets/)
# "/static/kefu-small.png" 或上传后的 "<uuid>.png" 这类值,取基名后拼
# 租户目录,无路径逃逸面(单成分文件名不可能越出目录)
name = pathlib.PurePosixPath(str(site.brand.get("avatar") or "")).name
# 复审加固: PurePosixPath 不认反斜杠(整串留在 .name 里),Windows 文件
# 系统却把反斜杠当分隔符——seed_dir/assets/.. 会真穿出资产目录 serve
# 任意文件。显式拒绝分隔符与目录点,命中即回退静态兜底。
# 系统却把反斜杠当分隔符——assets/.. 会真穿出资产目录 serve 任意文件。
# 显式拒绝分隔符与目录点,命中即回退静态兜底。防线覆盖整条解析链
# (uploads 段与 seed 段消费同一个守卫后的 name)。
if name in {".", ".."} or "/" in name or "\\" in name:
name = ""
# seed_dir=None = DB 源租户(Task 4;资产目录尚无 DB 侧存储)——直接
# 落静态兜底,不让 None 进路径拼接炸 500
candidate = (runtime.seed_dir / "assets" / name
if name and runtime.seed_dir is not None else None)
if candidate is not None and candidate.is_file():
return FileResponse(
candidate, headers={"Access-Control-Allow-Origin": "*"})
return FileResponse(
_AVATAR_FALLBACK, headers={"Access-Control-Allow-Origin": "*"})
headers = {"Access-Control-Allow-Origin": "*"}
if name:
# 三级解析链(Task 7):uploads(后台上传,优先)→ seed 资产 → 静态
# 兜底。uploads_root 生产行为见 registry(仓库根 data/,gitignore);
# registry 经中间件保证存在(本请求已过 Host 解析)。
uploaded = (request.app.state.registry.uploads_root
/ runtime.config.slug / name)
if uploaded.is_file():
return FileResponse(uploaded, headers=headers)
# seed_dir=None = DB 源租户(Task 4;出厂资产无 DB 侧存储)——
# 跳过 seed 段落静态兜底,不让 None 进路径拼接炸 500
if runtime.seed_dir is not None:
seeded = runtime.seed_dir / "assets" / name
if seeded.is_file():
return FileResponse(seeded, headers=headers)
return FileResponse(_AVATAR_FALLBACK, headers=headers)
+4
View File
@@ -86,6 +86,7 @@ from app.platform.routes.config_routes import (
settings_router as platform_settings_router,
)
from app.platform.routes.tenant_routes import router as platform_tenant_router
from app.platform.routes.upload_routes import router as platform_upload_router
app.include_router(chat_router)
app.include_router(chat_rest_router)
app.include_router(public_router)
@@ -97,6 +98,9 @@ app.include_router(platform_tenant_router)
# 前缀的 Host 豁免由 Task 3 的中间件规则统一覆盖。
app.include_router(platform_config_router)
app.include_router(platform_settings_router)
# 头像上传(Task 7):multipart 魔数校验 + uuid 重命名 + 配置版本管线
# (require_tenant);/api/public/avatar 的三级解析链消费上传产物。
app.include_router(platform_upload_router)
# 平台认证路由(Task 2):本任务即挂——已知租户 Host(如 jiancai)下
# /api/platform/auth/* 经 Host 中间件直达,TDD 循环闭环。Task 3 将为
# /api/platform/ 前缀加中间件豁免(平台后台不依赖租户域名解析);
+8 -2
View File
@@ -90,8 +90,8 @@ class TenantRuntime:
加载期就绪:config(TenantConfig:data=快照⊕出厂默认,含 sites)/
db_path(该租户 SQLite 文件路径)/seed_dir(文件源=租户目录;DB 源
None——avatar 解析链对其回退静态兜底)/compiled_wall(墙编译产物)/
prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一
None——avatar 解析链的 seed 资产段对其跳过)/compiled_wall(墙编译
产物)/prompts(PromptProvider.from_config(snapshot["prompts"]),两源统一
入口——$DEFLECTION_POOL 话术池注入的单一消费口)。
懒建:engine/session_factory(Registry 首次 resolve,ensure_schema 已跑)。
manager(Task 5b)由装配线另行构造注入,不在本类持有。
@@ -123,6 +123,12 @@ class TenantRegistry:
self._by_slug: dict[str, TenantRuntime] = {}
self._domain_index: dict[str, tuple[str, str]] = {}
self._locks: dict[str, asyncio.Lock] = {}
# 头像上传根目录(Task 7):生产 data_dir = <repo>/tenants,与
# <repo>/data/ 是仓库根兄弟目录(spec 布局,.gitignore 已忽略 data/)
# → 取 data_dir.parent 派生;测试注入 tmp data_dir 时上传自动落
# tmp 内(DB 源夹具 data_dir=tmp/tenants → tmp/data/uploads),
# 隔离免污染仓库目录。布局若变,只改这一处。
self.uploads_root = self._data_dir.parent / "data" / "uploads"
# ---- 加载 -----------------------------------------------------------
+109
View File
@@ -0,0 +1,109 @@
"""头像上传(Phase 2 Task 7):POST /api/platform/tenants/{slug}/brand/avatar。
安全纪律(三防):
- **魔数校验优先于扩展名**:类型只认文件内容(\x89PNG / \xff\xd8\xff /
RIFF....WEBP),改后缀骗不过——.png 名的 JPEG 按内容存 .jpg,纯文本/
RIFF 非 WEBP 一律 400。存盘扩展名取自魔数判定的规范名,自洽无伪装面;
- **服务端 uuid 重命名**:uuid4().hex + "." + 扩展,原始文件名不参与
路径(带 ../ 的文件名无从生效);目录段 slug 取自 DB 行(tenant.slug),
路径参数先过 _get_tenant_or_404 才拼盘——未知/伪造 slug 到不了文件系统;
- **大小 ≤2MB**(413);缺 file 字段 400(不留给 FastAPI 422 抢答,与
配置 API 的 400 契约一致)。
配置版本管线(与 Task 6 的 PUT 同款模式,复用 config_routes 内部 helper):
读当前快照 → brand 组**只替换 avatar 字段**(其余字段原样保留——与 PUT
的整组替换不同,上传是单字段编辑)→ validate_group(brand) →
write_config_version(comment="头像上传") → registry_reload 热生效。
落盘顺序:校验全过 → 写文件(uploads_root/<slug>/,目录懒建)→ 写版本
→ commit → reload。文件先落盘:commit 失败只留无害孤儿文件;反过来
(先版本后文件)失败会留下指向不存在文件的 avatar。
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
# request.form() 产出 starlette 的 UploadFile;fastapi.UploadFile 是其子类
# (File() 参数专用),isinstance 反向判 False——必须对基类判
from starlette.datastructures import UploadFile
from app.platform.config_validation import ValidationContext, validate_group
from app.platform.db import write_config_version
from app.platform.routes.config_routes import _load_current, _updated_by
from app.platform.routes.tenant_routes import _get_tenant_or_404, _tenant_guard
router = APIRouter(prefix="/api/platform/tenants", tags=["platform-upload"])
MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2MB(spec 头像上限)
# 魔数 → 规范扩展名(检测只认内容;存盘名与之自洽)
_MAGIC_SIGNATURES = (
(b"\x89PNG\r\n\x1a\n", "png"),
(b"\xff\xd8\xff", "jpg"),
)
def _sniff_image(data: bytes) -> str | None:
"""按魔数嗅探图片类型,返回规范扩展名;非 png/jpg/webp → None。"""
for signature, ext in _MAGIC_SIGNATURES:
if data.startswith(signature):
return ext
# WEBP 容器: "RIFF" + 4 字节长度 + "WEBP"(头尾两段,无法单串 startswith)
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "webp"
return None
@router.post("/{slug}/brand/avatar")
async def upload_avatar(slug: str, request: Request,
user=Depends(_tenant_guard)):
"""multipart 头像上传:魔数判定类型 → uuid 重命名落盘 → 配置版本管线
写 brand.avatar → reload 热生效。返回 {slug, avatar, version}。"""
form = await request.form()
upload = form.get("file")
if upload is None or not isinstance(upload, UploadFile):
raise HTTPException(
status_code=400, detail="缺少 multipart 文件字段 file")
# 预检 upload.size(starlette 解析期已跟踪的实际字节数):超标直接 413,
# 不把超大 body 再 read() 进内存(spool 盘上的部分不复制)
if upload.size is not None and upload.size > MAX_AVATAR_BYTES:
raise HTTPException(
status_code=413,
detail=f"头像超过 2MB 上限(实际 {upload.size} 字节)")
data = await upload.read()
if len(data) > MAX_AVATAR_BYTES:
raise HTTPException(
status_code=413,
detail=f"头像超过 2MB 上限(实际 {len(data)} 字节)")
ext = _sniff_image(data)
if ext is None:
raise HTTPException(
status_code=400,
detail="不支持的图片类型: 只允许 png/jpg/jpeg/webp"
"(按文件内容判定,改后缀无效)")
filename = f"{uuid.uuid4().hex}.{ext}"
engine = request.app.state.platform_engine
async with AsyncSession(engine, expire_on_commit=False) as session:
tenant = await _get_tenant_or_404(session, slug)
_, current = await _load_current(session, tenant.id, slug)
brand = dict(current.get("brand") or {})
brand["avatar"] = filename
errors = validate_group("brand", brand, ValidationContext())
if errors: # 管线纪律:落库前过校验(理论不触发)
raise HTTPException(status_code=400, detail="; ".join(errors))
new_snapshot = dict(current)
new_snapshot["brand"] = brand
version = await write_config_version(
session, tenant.id, new_snapshot,
updated_by=_updated_by(user), comment="头像上传")
# 目录段用 DB 行的 slug(路径 slug 已过 404 守卫,二者必一致——
# 但以库为准拼路径,杜绝任何路径参数形态的逃逸面)
dest_dir = request.app.state.registry.uploads_root / tenant.slug
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / filename).write_bytes(data)
await session.commit()
await request.app.state.registry.registry_reload(slug)
return {"slug": tenant.slug, "avatar": filename, "version": version}
+1
View File
@@ -8,6 +8,7 @@ httpx>=0.27.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
python-multipart>=0.0.20
sniffio>=1.3.0
iptocc>=0.1.0
phonenumberslite>=9.0
+235
View File
@@ -0,0 +1,235 @@
"""头像上传与解析链(Phase 2 Task 7)。
POST /api/platform/tenants/{slug}/brand/avatar(multipart,require_tenant):
- 魔数校验优先于扩展名——改后缀骗不过(.png 名的 JPEG 按内容存 .jpg;
RIFF 但非 WEBP 拒;纯文本拒);
- ≤2MB(超出 413);服务端 uuid 重命名,原始文件名不参与路径;
- 走配置版本管线(快照 brand 组改 avatar 字段 → validate_group →
write_config_version(comment=头像上传)→ registry_reload);
- /api/public/avatar 三级解析链: data/uploads/<slug>/<name> →
seed_profiles/<slug>/assets/<name> → static/kefu-small.png;文件名的
穿越防线(PHase 1)扩到 uploads 段。
夹具 db_source_app/super_client(conftest);助手复用 test_tenant_admin_api。
上传落盘位置 = registry.uploads_root(DB 源 tmp 布局下 = tmp_path/data/
uploads,隔离免污染;生产行为见 registry.uploads_root 注释)。
"""
import re
from pathlib import Path
from tests.conftest import SEED_DIR
from tests.test_tenant_admin_api import (
_auth,
_bearer,
_create,
_tenant_admin_token,
)
REPO = Path(__file__).resolve().parents[1]
TENANTS = "/api/platform/tenants"
LOGIN = "/api/platform/auth/login"
# 最小"合法头"测试字节——魔数校验只看头几个字节,后续填充唯一标记
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"avatar-body-UNIQUE-42"
JPG_BYTES = b"\xff\xd8\xff" + b"jpeg-body"
WEBP_BYTES = b"RIFF" + b"\x24\x00\x00\x00" + b"WEBP" + b"VP8 webp-body"
UUID_EXT_RE = re.compile(r"^[0-9a-f]{32}\.(png|jpg|webp)$")
def _upload(client, slug, name, data, content_type="image/octet-stream",
headers=None):
return client.post(f"{TENANTS}/{slug}/brand/avatar",
headers=headers or _auth(client),
files={"file": (name, data, content_type)})
def _config(client, slug):
r = client.get(f"{TENANTS}/{slug}/config", headers=_auth(client))
assert r.status_code == 200, r.text
return r.json()
def _avatar(client, host):
return client.get("/api/public/avatar", headers={"host": host})
# ---- 上传 → 版本管线 → 热生效 ------------------------------------------------------
def test_upload_and_serve(super_client, db_source_app, tmp_path):
_create(super_client, slug="demo", domains=["demo.example.com"])
fallback = (REPO / "static" / "kefu-small.png").read_bytes()
# 基线:DB 源租户(无 seed 资产目录)→ 静态兜底
r = _avatar(super_client, "demo.example.com")
assert r.status_code == 200
assert r.content == fallback
r = _upload(super_client, "demo", "me.png", PNG_BYTES, "image/png")
assert r.status_code == 200, r.text
body = r.json()
# 服务端 uuid 重命名:uuid4 hex + 魔数判定的扩展名;原始文件名不出现
assert UUID_EXT_RE.fullmatch(body["avatar"]), body["avatar"]
assert body["avatar"].endswith(".png")
assert "me.png" not in body["avatar"]
# 落盘:data/uploads/<slug>/<uuid>.<ext>(目录懒建),字节原样
stored = tmp_path / "data" / "uploads" / "demo" / body["avatar"]
assert stored.is_file()
assert stored.read_bytes() == PNG_BYTES
# 配置版本管线:版本 +1,brand 组只改 avatar 字段(其余字段原样保留),
# 版本历史 comment 记"头像上传"
cfg = _config(super_client, "demo")
assert cfg["version"] == 2
assert cfg["config"]["brand"]["avatar"] == body["avatar"]
assert cfg["config"]["brand"]["title"] == "HESUPIPE Support" # jiancai 档案值
versions = super_client.get(f"{TENANTS}/demo/config/versions",
headers=_auth(super_client)).json()["items"]
assert versions[-1]["comment"] == "头像上传"
# 热生效:registry_reload 后 /api/public/avatar(该租户 Host)返回新图字节
r = _avatar(super_client, "demo.example.com")
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/")
assert r.content == PNG_BYTES
assert r.content != fallback
def test_magic_beats_extension(super_client, tmp_path):
"""魔数优先于扩展名:内容是 JPEG 的 .png 名文件按内容收为 .jpg;
webp 魔数(RIFF....WEBP)同理。"""
_create(super_client, slug="demo")
r = _upload(super_client, "demo", "sneaky.png", JPG_BYTES, "image/png")
assert r.status_code == 200, r.text
assert r.json()["avatar"].endswith(".jpg")
r = _upload(super_client, "demo", "logo.webp", WEBP_BYTES, "image/webp")
assert r.status_code == 200, r.text
assert r.json()["avatar"].endswith(".webp")
# ---- 拒收面:类型 / 大小 ------------------------------------------------------------
def test_reject_bad_type_and_oversize(super_client, tmp_path):
_create(super_client, slug="demo")
# .txt(内容也非图)→ 400
r = _upload(super_client, "demo", "a.txt", b"hello plain text", "text/plain")
assert r.status_code == 400
# .png 后缀但内容非 png(魔数不符)→ 400,改后缀骗不过
r = _upload(super_client, "demo", "fake.png", b"definitely not an image",
"image/png")
assert r.status_code == 400
# RIFF 头但非 WEBP(如 wav)→ 400
r = _upload(super_client, "demo", "a.wav",
b"RIFF\x24\x00\x00\x00WAVEfmt ", "audio/wav")
assert r.status_code == 400
# 3MB(合法 png 头)→ 413
big = b"\x89PNG\r\n\x1a\n" + b"x" * (3 * 1024 * 1024)
r = _upload(super_client, "demo", "big.png", big, "image/png")
assert r.status_code == 413
# 缺 file 字段 → 400(不是 422/500)
r = super_client.post(f"{TENANTS}/demo/brand/avatar",
headers=_auth(super_client), data={"x": "1"})
assert r.status_code == 400
# 全部拒收:版本不涨、盘上无文件
assert _config(super_client, "demo")["version"] == 1
uploads_dir = tmp_path / "data" / "uploads" / "demo"
assert not uploads_dir.exists() or not any(uploads_dir.iterdir())
# 恰在上限内(2MB 整)→ 通过
ok = b"\x89PNG\r\n\x1a\n" + b"y" * (2 * 1024 * 1024 - 8)
assert len(ok) == 2 * 1024 * 1024
r = _upload(super_client, "demo", "edge.png", ok, "image/png")
assert r.status_code == 200, r.text
# ---- 穿越三防 ---------------------------------------------------------------------
def test_traversal_safe(super_client, db_source_app, tmp_path):
_create(super_client, slug="demo", domains=["demo.example.com"])
# 防 1:上传文件名带 ../ 不生效——服务端 uuid 重命名,路径成分不落盘
r = _upload(super_client, "demo", "../../evil.png", PNG_BYTES, "image/png")
assert r.status_code == 200, r.text
avatar = r.json()["avatar"]
assert UUID_EXT_RE.fullmatch(avatar), avatar
assert (tmp_path / "data" / "uploads" / "demo" / avatar).is_file()
assert not (tmp_path / "data" / "uploads" / "evil.png").exists()
assert not (tmp_path / "evil.png").exists()
assert not (tmp_path / "data" / "evil.png").exists()
# 防 2:解析链 uploads 段拒绝路径字符——brand.avatar 指到 uploads 目录外
# 的真实文件(Windows 反斜杠语义下 ..\secret.txt 会穿出 uploads/demo/),
# 命中防线即回退静态兜底,绝不 serve 穿越目标
secret = tmp_path / "data" / "uploads" / "secret.txt"
secret.write_bytes(b"TOPSECRET-never-serve")
r = super_client.put(f"{TENANTS}/demo/config/brand",
headers=_auth(super_client),
json={"avatar": "..\\secret.txt"})
assert r.status_code == 200, r.text
r = _avatar(super_client, "demo.example.com")
assert r.status_code == 200
assert b"TOPSECRET" not in r.content
assert r.content == (REPO / "static" / "kefu-small.png").read_bytes()
# 防 3:正斜杠形态(PurePosixPath 剥成基名,单成分不可能越出 uploads/<slug>/)
r = super_client.put(f"{TENANTS}/demo/config/brand",
headers=_auth(super_client),
json={"avatar": "../../secret.txt"})
assert r.status_code == 200, r.text
r = _avatar(super_client, "demo.example.com")
assert r.status_code == 200
assert b"TOPSECRET" not in r.content
# ---- 鉴权边界(require_tenant:401/409/403,本租户管理员放行,未知租户 404) ----------
def test_upload_auth_boundaries(super_client):
_create(super_client, slug="demo")
_create(super_client, slug="other")
url = f"{TENANTS}/demo/brand/avatar"
# 未认证 → 401
r = super_client.post(url, files={"file": ("a.png", PNG_BYTES, "image/png")})
assert r.status_code == 401
# 引导密码未改(登录即持 must_change 标志)→ 409
r = super_client.post(LOGIN, json={"username": "demo-admin",
"password": "demo-pass-1"})
fresh_token = r.json()["token"]
r = super_client.post(url, headers=_bearer(fresh_token),
files={"file": ("a.png", PNG_BYTES, "image/png")})
assert r.status_code == 409
# 其他租户管理员 → 403
other_token = _tenant_admin_token(super_client, "other-admin",
"other-pass-1")
r = super_client.post(url, headers=_bearer(other_token),
files={"file": ("a.png", PNG_BYTES, "image/png")})
assert r.status_code == 403
# 本租户管理员 → 200(require_tenant 放行租户全开)
demo_token = _tenant_admin_token(super_client, "demo-admin",
"demo-pass-1")
r = super_client.post(url, headers=_bearer(demo_token),
files={"file": ("a.png", PNG_BYTES, "image/png")})
assert r.status_code == 200, r.text
# 未知租户 → 404
r = _upload(super_client, "nope", "a.png", PNG_BYTES, "image/png")
assert r.status_code == 404
# ---- 解析链既有级行为回归(seed 段优先级在 uploads 之后) ----------------------------
def test_seed_assets_still_served_for_file_tenants(app_client):
"""文件源租户(jiancai)不上传时:uploads 段落空 → seed 资产照常服务
(uploads 段的加入不改变既有两级链的行为)。"""
r = app_client.get("/api/public/avatar")
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/")
assert r.content == (SEED_DIR / "jiancai" / "assets" /
"kefu-small.png").read_bytes()