Skip to content

全量修复/补测试+UED改进 - #535

Merged
vastsa merged 31 commits into
vastsa:masterfrom
Marrrrrrrrry:fix/m0-stop-bleeding
Sep 19, 2026
Merged

vastsa merged 31 commits into
vastsa:masterfrom
Marrrrrrrrry:fix/m0-stop-bleeding

Conversation

@Marrrrrrrrry

Copy link
Copy Markdown
Contributor

改进了游客上传被拒的困扰(拒绝提前到选文件前)优化交互体验
然后拆了一些耦合和补测试(现在项目应该没有硬伤了)应该没有引入回归 我review了提交
但是依然可能有疏忽 虽然我本地构建了也跑了一些基本测试 但是还是建议你那也跑一下 尤其是老用户升级 我没这环境(我生产服务器性能一般)
以下是详细报告

Replace the set-membership key check in /share/download with
hmac.compare_digest over the two valid window tokens; add negative-path
tests (wrong key 403, both windows accepted, foreign-code token 403).
…m 2)

All six Content-Disposition construction sites across the five storage
backends now go through build_attachment_headers; the header is what
neutralizes stored XSS on the same-origin download path. Guard tests
assert every get_file_response uses the builder and no hand-built
disposition reappears.
…tem 7)

s3_endpoint_url/s3_hostname/webdav_url are fetched server-side; with
APP_ENV=production the write entry now enforces http(s) schemes and a
loopback/private/link-local/metadata host blacklist. Development env
stays open for local minio/webdav. Already-stored values are never
re-validated, so existing deployments are unaffected.
Starlette status-code handlers take precedence over the HTTPException
class handler, so registering the theme index as the 404 handler turned
every HTTPException(404) app-wide into a 200 HTML page (found by the new
negative-path tests). The new not_found_handler serves the theme page
only to browsers (Accept: text/html) and JSON 404 to API clients.

Negative-path batch: download count exhaustion, missing chunk session
404s, expired presign session deletion, admin update_file uniqueness/
existence.
…urity item 3)

Entrypoint starts as root only to fix the data-dir ownership (skipped
when nothing needs changing or when the deployer sets an explicit user),
then drops to uid 10001 via gosu. Verified in-container: uvicorn runs as
app (uid 10001), setup and share round-trip work against a root-owned
volume.
Integration regression for the background lesson: a stored internal
endpoint must not block unrelated settings saves in production mode;
changing to an internal endpoint (URL or bare-hostname form) is 400.
…DNS maps

Probe found the static blacklist bypassed by glibc shorthands (127.1,
10.1), decimal (2130706433) and hex IP forms, *.localhost, and
loopback-mapping DNS services. Both validators now resolve the host and
re-check every resolved address; hostname tier extracts the host part
correctly for [v6]/host:port/bare-v6 forms. 44 tests.
- S3 multipart rejects parts <5MiB (EntityTooSmall) except the last; chunk
  size is client-controlled (commonly 2-4MB) so multi-chunk merges always
  failed with 500. Merge now buffers chunks into >=5MiB parts (memory
  bound: 5MB + one chunk).
- get_file_response: a 404 head_object now raises StorageError(404)
  upfront instead of signing a doomed presigned URL and failing mid-stream
  (aligns with local backend semantics from the M2 behavior unification).
mock_aws cannot intercept aioboto3's aiohttp stack, so tests run against
a real in-process ThreadedMotoServer endpoint. Covers roundtrips, missing
object 404, merge failure modes (missing chunk / hash mismatch -> abort,
no leftover object), cleanup scoping, presign shapes, proxy dispatch.
Also adds moto[s3,server] to the dev group and CI.
HEAD 404 was silently swallowed (signed a 200 bad stream that failed
mid-download, same family as the S3 bug), and HEAD connection errors were
also swallowed so the 503 mapping never fired. Both now surface properly;
the aiohttp session is reclaimed on pre-stream exception paths.
Real HTTP (HEAD/GET/PUT/DELETE/MKCOL/PROPFIND) against a minimal aiohttp
WebDAV server over a temp dir: roundtrips, missing object 404, connection
error 503, delete with empty-parent cleanup, merge failure modes, chunk
cleanup scoping.
…nned

OneDrive get_file_response translated graph itemNotFound into the outer
503 catch-all; now raises StorageError(404) like local/S3/WebDAV.
OpenDAL already mapped missing objects to 404 via its outer catch —
pinned with fake-operator tests (SDK not in runtime deps, instances built
via __new__). Also tightens the S3 presign URL assertion.
…olicy actions)

13 negative-path cases for the previously untested data-modifying admin
endpoints: mixed-id aggregation with duplicates and missing records,
empty-list rejections, clear_expired_at permanence semantics, policy
action boundaries (zero limit 400, unknown action 400, missing 404),
and the DoesNotExist->404 mapping pinned for single delete.
Detail/metadata/preview/admin-download/activities/local-lists+delete/
verify: missing-record 404s, note/tag truncation limits, preview max_chars
truncation, activity filtering and the 80-event clamp, local path
traversal rejection. Presets: update-vs-create by id, name truncation,
filter normalization clamps, 24-preset cap, delete-missing 404.
JWT create/verify, bearer extraction and the share-upload gate move to
apps.base.auth (consumed by both surfaces); admin.dependencies keeps
admin session gating and service providers, re-exporting the primitives
for import-path compatibility. base.views no longer imports from
apps.admin — the last cross-app reverse dependency is gone.
apps/admin/services.py (1697 lines) becomes FileService (~1200 lines) +
a compatibility facade re-exporting ConfigService, LocalFileService,
LocalFileClass and keyvalue_write_lock from their new homes
(config_service.py holds the D5 KeyValue lock; one-way imports, no
cycle). test_admin_security patch targets follow ConfigService to its
new module. Behavior unchanged; 247 tests green.
validate_file_size's size-None branch called UploadFile.seek(0, 2),
which raises TypeError (UploadFile.seek takes one arg); the underlying
SpooledTemporaryFile.seek is sync, so awaiting it also fails. Use the
underlying file object with sync seek(0, SEEK_END). Surface discovered
by the mypy pilot run; regression test covers the size-None branch.
fastapi 0.139.2->0.141.1, pydantic 2.12.5->2.13.5, uvicorn 0.51.0->0.53.0,
aiohttp 3.14.2->3.14.3; lockfile regenerated. tortoise-orm 0.x->1.x major
deliberately deferred pending API-change review.
scripts/mypy_ratchet.py compares mypy output against
scripts/mypy-baseline.txt (line numbers stripped for edit stability):
new type errors fail CI, fixes are folded back via --regenerate.
Baseline starts at 30 known errors; the seek bug found in the pilot was
fixed before baselining. mypy joins the dev group and CI.
Dependency-upgrade probe on aiohttp 3.14.3 surfaced the BasicAuth
deprecation; switch to aiohttp.encode_basic_auth() headers so the
aiohttp 4.0 upgrade does not break the WebDAV backend.
Magic-bytes spoofing (text-as-png, exe-as-pdf, mp4/webp box detection),
whitelist rule semantics (star/image-wildcard/content mismatch), chunk-0
header validation parity, and end-to-end UploadFile rejection.
Full chain executes 001-007 in filename order and registers; rerun is
idempotent; pre-registered entries are skipped (DDL not re-executed);
the resulting schema matches deployment; a failing migration propagates
and is not registered. Uses a fresh :memory: DB without
generate_schemas so the migrations themselves build the schema (the
real deployment path).
core/storage.py (1400+ lines, six backends) becomes core/storage/ with
_base.py (data contracts, interface, shared header builder), and
local/s3/onedrive/opendal/webdav modules. The package __init__
re-exports the full historical public surface; test patch targets move
to the precise backend modules (core.storage.local.data_root etc.) and
the attachment guard scans the package. Import-only change; 264 tests
green. Storage-layer DI deferred: current tests construct instances
against patched settings, so the added surface isn't justified yet.
The rate-limit ledger mixed int and datetime under a loose Union typing,
producing 5 mypy noise errors. A TypedDict gives precise per-key types;
setdefault replaces the get-then-assign pattern. One mypy baseline error
family resolved; 264 tests green.
Per-error treatment (no bulk script): honest annotations for
StoredDownload (Path/bytes/Callable), KeyValue.value (Any for JSONField),
dict[str, Any] for form/config dicts; signature split in create_token;
str() normalization in config int(); platform/type-ignore only where the
checker cannot follow (msvcrt, FastAPI Request injection). Baseline file
and ratchet compare now redundant at zero errors but kept as the guard
against regressions.

Tests: assertions tightened to status_code == 403 with HTTPException
raises; _create_share explicit parameters replace **extra passthrough;
seek-fix pointer-reset assertion persisted.
@vercel

vercel Bot commented Sep 19, 2026

Copy link
Copy Markdown

@Marrrrrrrrry is attempting to deploy a commit to the vastsa's projects Team on Vercel.

A member of the Team first needs to authorize it.

@vastsa

vastsa commented Sep 19, 2026

Copy link
Copy Markdown
Owner

感谢 pr,我 review 一下

@Marrrrrrrrry

Copy link
Copy Markdown
Contributor Author

1.下载 token 常量时间比较

/share/download 的 key 校验原为 set 成员判断,改为对当前与上一时间窗 token
逐个 hmac.compare_digest 字节比较(顺带修复非 ASCII key 导致 500 的问题)。
判定结果与原来完全一致,附边界测试。

2.attachment 响应头单一来源

Content-Disposition: attachment 是同源下载路径上防存储型 XSS 的关键,
此前在五个后端手拼了 6 处。收敛为单一构造函数,并加守护测试:
任何后端绕过它或手拼该头都会导致测试失败。行为零变化。

3.JSON 404 修复 + 非 root 容器(可拆两个)

  1. 主题首页兼任 404 handler 后,全应用所有 HTTPException(404) 都返回
    200 + HTML。现改为浏览器仍拿主题页、API 客户端拿 JSON 404。
  2. 容器改为非 root 运行:entrypoint 按需修正数据卷属主后降权 uid 10001,
    存量 root 卷无感,已容器内实测。
    附带一批负路径测试。

4.存储 endpoint 写入口校验

s3_endpoint_url / s3_hostname / webdav_url 会被服务端直接访问,被劫持的
管理员会话可借此探内网。写入口新增校验:http(s) 白名单 + 内网黑名单 +
解析后逐 IP 复判(静态黑名单挡不住 127.1、2130706433 这类速记形式,实测确认)。
仅校验发生变化的值(与 #528 对 background 的修复同一语义),存量内网
minio/webdav 部署不受影响;APP_ENV=production 才强制。

5.S3 后端两个修复 + 测试

  1. 分片合并对小分片必然失败:S3 要求除最后一片外每部分 ≥5MB,而分片大小
    由前端决定(常见 2-4MB)。合并时缓冲到 ≥5MiB 再上传 part。
  2. 缺失对象返回 200 坏流:HEAD 404 被吞后照常签发下载,错误在流中段爆发。
    改为前置 404,与本地存储语义一致。
    新增进程内 moto 服务测试 13 例(mock_aws 拦不了 aioboto3,这个坑写在
    测试注释里)。另建议 DEFAULT_CONFIG 的 s3v2 签名版本改为 s3v4(botocore
    不支持 s3v2),本 PR 未动,等你确认。

6.WebDAV 修复 + 测试

与 S3 同款的"缺失对象 200 坏流"之外,还发现 HEAD 连接错误被内层
except 吞掉(503 映射从未生效)、异常路径 session 泄漏,一并修复。
新增进程内 WebDAV 服务器测试 9 例。

7.OneDrive 404 前置 + OpenDAL 行为迭代

OneDrive 缺失对象经外层兜底返回 503,改为前置 404,与 local/S3/WebDAV
统一;OpenDAL 本就正确,用假 operator 测试钉死防回归。
注:office365/opendal SDK 不在 requirements 里,Docker 构建下这两个后端
选了即 ImportError,注意一下

@Marrrrrrrrry

Copy link
Copy Markdown
Contributor Author

前端稍等我一下 我前端因为在写新2026的样式 然后有点乱 因为UED改进涉及前端 让我整理一下再PR掉

@Marrrrrrrrry

Copy link
Copy Markdown
Contributor Author

前端推了

@Marrrrrrrrry

Copy link
Copy Markdown
Contributor Author

dependabot 这个批量pr 我这次pr前手工处理依赖升级了 流水线那块不一定是最佳实践(我好像还是忘了做bot报告合并)你正好修掉吧 然后那个收件箱那个不然我就先不做?不然容易撞 我本地有个分支已经做一半了 我开发思路写在那个PR里面了

@vastsa vastsa left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

先说结论:先别合。 CI 是红的,还有一处可复现的 SSRF 绕过、一处 Docker 升级会炸。方向是对的,拆包/测试/安全加固都有价值,修完这几条再合。

我这边对照 master(2.7.0)看过 diff,本地跑了测试,也核对了 GitHub Actions。

必须改

1. CI 挂了:test_browser_accept_gets_theme_page

not_found_handlerAccept: text/html 直接 await index(),而 themes/.gitignore 里(只有 Docker 构建才有)。仓库 checkout / CI 都没有 index.html,于是再抛一次 HTTPException(404)

Actions:1 failed, 263 passed,就是这条。测试里造一个假主题文件,或者 theme 缺失时回落 JSON 404,别再往上抛。

2. s3_hostname 的 SSRF 校验能被 @ 绕过(已复现)

validate_outbound_hostname("evil.com@127.0.0.1")  → 放行

core/security.py 手写拆 host(只挡 :///),evil.com@127.0.0.1 当主机名 → getaddrinfo 失败 → fail-open。存储层拼的是 https://{s3_hostname},urlsplit 会把 @ 前当 userinfo,实际连 127.0.0.1

URL 档用了 urlsplit().hostname,不受影响。需要 admin token。改成拒绝 @ / userinfo,解析失败改为拒绝,不要当放行。

3. 非 root 入口会让 NAS / 只读卷起不来

docker-entrypoint.shset -e + 发现非 uid 10001 就 chown -R app:app /app/data。文档和 compose 注释里的 /app/data/local:ro、NFS root_squash 都会 EROFS → 容器起不来。changelog 里我们自己写过「为了卷兼容才用 root 跑」,这次等于又改回去,NAS 场景没覆盖到。

chown 失败应降级告警,只读挂载跳过。

升级兼容(给合入后的预期)

  • 配置键、SQLite 表、提取码/JWT/下载 token 算法都没变,源码部署可以当 drop-in
  • 已写入的内网 MinIO/WebDAV 不会被新 SSRF 校验误杀(只校验有改动的字段),老用户保存其它设置不受影响。
  • 分享下载缺文件仍是 HTTP 200 + {"code":404},前端这条契约没动。
  • 默认 Docker named volume 能升(启动时 chown 成 10001)。挂了 NAS 只读目录或 bind mount 的先别升,等第 3 条修完。
  • API 行为变化:未知路径 / HTTPException(404) 对 JSON 请求现在是真 404(以前一律 200 HTML)。分享流不受影响;curl/监控如果靠「乱打路径返回 200」会红。这是修复,但要心里有数。

建议改(不挡这次,别装没看见)

  • 解析失败 fail-open + 只在写入时解析:DNS rebinding / Clash fake-ip(198.18.0.0/15is_private 判死)下,生产会拒掉所有公网 S3。至少文档写清楚。
  • WebDAV 默认跟随 3xx:公网 302 到 169.254.169.254 仍会打到 metadata。
  • test_unknown_extension_with_known_signature_still_checked 只有 docstring,空测试。
  • 附件头测试只断言 startswith("attachment;"),把 quote() 拿掉也绿。
  • conftest.pyrmtree(data/share) 是老问题,这次放大了爆炸半径——别在有真实数据的 checkout 上跑 pytest。
  • PR 描述里的「游客上传提前拒绝」这 PR 没动前端,后端 share_required_login 只是搬家。

没问题的部分

  • storage 拆包:类名/方法签名与旧 core/storage.py 一致,from core.storage import X 还在。
  • 认证搬到 apps.base.auth:权限语义没变,base→admin 依赖断了,旧 import 有 re-export。
  • 下载 token 改 hmac.compare_digest(bytes):非 ASCII 不再 500,调用点只有这一处。
  • 五个后端都走 build_attachment_headers
  • S3 multipart ≥5MiB 缓冲、无 size 上传改 file.file.seek:对症。
  • 测试主体(migration runner、moto、admin 写路径、token 边界)能当回归网。

先把 CI 那条 404、s3_hostname@ 绕过、entrypoint 的 chown 修了。其余可以 follow-up。31 个 commit 塞了安全/重构/测试/Docker/升依赖,下次拆开更好审。

@Marrrrrrrrry

Copy link
Copy Markdown
Contributor Author

那我处理吗?还是直接你处理 我弄也行但是跟我讲一下 别等下撞了

@vastsa
vastsa merged commit 57c75a7 into vastsa:master Sep 19, 2026
0 of 2 checks passed
@Marrrrrrrrry
Marrrrrrrrry deleted the fix/m0-stop-bleeding branch September 19, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants