# 附件存储安全加固 Spec
## Why
当前附件上传后文件名含 UID 前缀(`uid_xn_rand(15).ext`),可被猜解遍历;物理文件可通过 URL 直接访问,无需任何权限校验,存在越权下载风险。
## What Changes
- 上传文件重命名为不可预测的随机字符串(32位),去除 UID 前缀
- 图片附件使用签名 URL 直接访问(防猜解,不走 PHP),非图片附件走 PHP 权限校验输出
- `upload/attach/` 目录禁止直接 HTTP 访问(.htaccess / Nginx 规则)
- 新增 `/attach/{id}/{token}` 路由用于图片签名访问,`/attach/download/{id}` 用于非图片下载
- 修改 `attach_format()` 生成新的安全 URL
- 仅新上传附件走新规则,历史附件保持兼容
## Impact
- Affected code: `route/attach.php`, `model/attach.func.php`, `model/post.func.php`, `view/htm/thread.htm`, `upload/.htaccess`
- Affected configs: `conf/conf.default.php`(新增签名密钥配置)
- 不影响头像系统(头像独立存储,不在 bbs_attach 表中)
- 不新增数据库字段
## ADDED Requirements
### Requirement: 随机文件名生成
系统 SHALL 在附件上传时生成 32 位随机十六进制字符串作为文件名,保留原始扩展名,格式为 `{32位随机hex}.{ext}`。
#### Scenario: 新附件上传
- **WHEN** 用户上传文件 `报告.pdf`
- **THEN** 服务器存储为 `upload/attach/202606/a3f8b2c1d4e5f6789012345678abcdef.pdf`
- **AND** `bbs_attach.filename` 记录为 `202606/a3f8b2c1d4e5f6789012345678abcdef.pdf`
- **AND** `bbs_attach.orgfilename` 仍保留原始文件名 `报告.pdf`
### Requirement: 图片签名 URL 访问
系统 SHALL 为图片类附件(`isimage=1`)生成带签名 token 的直接访问 URL,格式为 `/attach/{aid}/{token}`。
#### Scenario: 图片附件 URL 生成
- **WHEN** `attach_format()` 处理 `isimage=1` 的附件
- **THEN** 生成 URL 格式为 `/attach/{aid}/{签名token}`
- **AND** token 由 `md5(aid . filename . 密钥)` 生成
- **AND** token 长度为 32 位十六进制字符串
#### Scenario: 图片签名 URL 校验
- **WHEN** 请求 `/attach/{aid}/{token}`
- **THEN** 系统重新计算 token 并与请求中的 token 比对
- **AND** token 匹配时,通过 X-Accel-Redirect(Nginx)或 readfile()(Apache)输出文件
- **AND** token 不匹配时返回 403
- **AND** 设置 `Cache-Control: public, max-age=86400` 允许浏览器缓存
### Requirement: 非图片附件 PHP 权限校验输出
系统 SHALL 对非图片附件(`isimage=0`)通过 PHP 脚本进行权限校验后输出文件流。
#### Scenario: 非图片附件下载
- **WHEN** 用户请求下载非图片附件
- **THEN** 系统检查当前用户是否拥有下载权限(`forum_access_user($fid, $gid, 'allowdown')`)
- **AND** 权限通过后通过 readfile() 输出文件,设置正确 Content-Type 和 Content-Disposition
- **AND** 权限不足时返回错误提示
### Requirement: upload/attach 目录禁止直接访问
系统 SHALL 在 `upload/attach/` 目录下放置 `.htaccess` 规则,拒绝所有直接 HTTP 访问。
#### Scenario: 直接访问物理文件
- **WHEN** 攻击者直接请求 `upload/attach/202606/xxx.pdf`
- **THEN** 服务器返回 403 Forbidden
- **AND** 仅通过 PHP 入口(签名 URL 或下载路由)可访问文件
### Requirement: 签名密钥配置
系统 SHALL 在 `conf/conf.default.php` 中新增 `attach_sign_key` 配置项,用于生成和验证签名 token。
#### Scenario: 密钥初始化
- **WHEN** 系统安装或升级
- **THEN** 自动生成 32 位随机密钥存入配置
- **AND** 密钥变更后所有旧签名 URL 失效
### Requirement: 历史附件兼容
系统 SHALL 保持历史附件的现有访问方式不变,仅新上传附件使用新的安全机制。
#### Scenario: 历史附件访问
- **WHEN** 访问历史附件(filename 中含 UID 前缀的旧格式)
- **THEN** `attach_format()` 仍生成直接物理路径 URL(`upload_url + attach/ + filename`)
- **AND** 历史附件不受 `.htaccess` 限制影响(仅 `upload/attach/` 下新增子目录受限制)
### Requirement: 可选防盗链
系统 SHALL 提供可配置的防盗链开关,根据 `HTTP_REFERER` 检查是否来自本站。
#### Scenario: 防盗链开启
- **WHEN** 配置 `attach_referer_check` 为 TRUE
- **THEN** 附件请求的 Referer 不在本站域名时返回 403
- **AND** Referer 为空(直接访问)时允许通过
### Requirement: 下载日志记录
系统 SHALL 记录非图片附件的下载行为,用于安全审计。
#### Scenario: 下载日志
- **WHEN** 用户通过 PHP 入口下载非图片附件
- **THEN** 记录附件 ID、用户 UID、IP、时间到日志文件 `tmp/attach_download.log`
## MODIFIED Requirements
### Requirement: attach_format URL 生成逻辑
原逻辑:`$attach['url'] = $conf['upload_url'].'attach/'.$attach['filename']`
新逻辑:
- 如果附件为新格式文件名(32位hex,不含 UID 前缀)且 `isimage=1`:生成签名 URL `/attach/{aid}/{token}`
- 如果附件为新格式文件名且 `isimage=0`:生成下载 URL `/attach/download/{aid}`
- 如果附件为旧格式文件名:保持原有直接物理路径 URL
### Requirement: 附件上传文件名生成
原逻辑:`$tmpname = $uid.'_'.xn_rand(15).'.'.$ext`
新逻辑:`$tmpname = bin2hex(random_bytes(16)).'.'.$ext`(32位随机hex)
## REMOVED Requirements
最后更新日期:2026.06.05
XIUNO BBS NEXT 重构记录贴(二十)附件存储加固
复制成功