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:
+25
-15
@@ -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)
|
||||
|
||||
@@ -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/ 前缀加中间件豁免(平台后台不依赖租户域名解析);
|
||||
|
||||
@@ -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"
|
||||
|
||||
# ---- 加载 -----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user