feat: StarLight 第三方登录 + 安全加固 + 循环池 + 热配置

功能:
- StarLight Yggdrasil 第三方登录(后端中转,accessToken 不落地)
- 管理员可视化权限管理(用户列表 + 一键授予/移出)
- 设置页热配置(11 项可编辑,立即生效)
- 循环池(每流独立手动歌单,队列空时随机不重复抽取)
- 登录页统一主站浅色风格
- 全站改名「Starlight专属电台」
- 删除关于页

安全:
- 全局 CSRF 防护(双提交 Cookie)
- 登录限流防 XFF 伪造、错误信息脱敏
- 权限中间件 requireAdminForWrites(读放行/写 admin)
- 密码哈希存储(scrypt/sha256)
- SSRF 防护(封面下载域名白名单)
- 空 catch 加日志、SSE 连接上限

修复:
- 多角色账号 selectedProfile 缺失
- 普通用户越权删歌(V4)
- streams 写操作越权(V9)
- 设置页布局溢出

测试:38 项全绿(api16 / e2e17 / write5)
This commit is contained in:
Xiao-no-love committed 2026-10-06 16:37:03 +08:00
1 parent 5066f32fa4
commit f0fe50e484
29 files changed
+1703 -315

No files matched your search

+335 -2
View File
@@ -1,4 +1,4 @@
# 交接文档 · 音乐在线电台
# 交接文档 · Starlight专属电台
> 跨会话交接。**新会话先读此文件再动手**。最后更新:2026-09-25(v2.6)。
@@ -206,4 +206,337 @@ python -X utf8 test_write.py # 5 项(需 tmp/test_import.mp3 素材)
- 后台服务:`svc003`(`http://127.0.0.1:3000`)
- DB:`data.db`(6 首歌曲,封面已本地化)
- 缓存:`hls_cache/` 约 34MB
- Git:`2fc2548` 干净
- Git:`2fc2548` 干净
---
## 13. StarLight 第三方登录接入(v2.7)
**背景**:管理员要求用 StarLight(`skin.starlight.cool/yggdrasil`)做第三方登录,替换原密码登录(保留应急后门)。
**方案**:后端中转(浏览器→本服务→Yggdrasil),accessToken 不落地、不下发。
**改动文件**:
- `src/starlight.js`(新)— Yggdrasil 客户端,仅 `authenticate()`;CLIENT_TOKEN 启动随机
- `src/config.js` — 新增 `STARLIGHT_API`、`STARLIGHT_ADMINS`(逗号分隔,小写匹配)
- `src/db.js` — sessions 表迁移加 `starlight_name` 列
- `src/store.js` — `createSession(token, role, expiresAt, starlightName)`
- `src/auth.js` — `loginViaStarLight()`、`getStarlightName()`;exports 更新
- `src/routes/auth.js` — `/login` 双模式:有 username→StarLight;无→应急密码
- `public/login.html` — 用户名+密码表单 + "管理员应急登录"折叠区
- `public/index.html` — 顶栏 `#userName` 显示"你好,xxx"
- `public/js/app.js` — `applyRoleUI(role, starlightName)`
- `start.cmd` — 加 STARLIGHT_API / STARLIGHT_ADMINS 配置项
**登录逻辑**:
- 前端 `{username, password}` → 后端调 authenticate → 成功建本地会话
- 角色:`STARLIGHT_ADMINS` 命中=admin,否则 user
- **应急后门**:username 留空 + `{password}` → 走 ADMIN_PASSWORD(防皮肤站挂掉锁死)
**测试**:api 16 / e2e 17 / write 5 全绿。真实打到 StarLight(错误凭证→401)。
**注意**:`test_write.py` 第 30 行写死旧路径 `F:\music_online\demo\tmp\`,复制项目时未改(跑测试需先把素材放到该路径)。
---
## 14. 安全加固(v2.7.1,2026-10-05)
对上一条 StarLight 登录做了自审+修复,详见 `SECURITY_REVIEW.md`。
**修复清单**:
- 🔴 XFF 伪造绕过限流 → `trust proxy=false` + `getClientIp()` 取 socket IP
- 🔴 白名单按用户名判定可抢注提权 → 改绑 **UUID**
- 🟠 错误信息泄露 StarLight 内部细节 → 脱敏
- 🟠 用户名字符集未校验 → 正则过滤 `[A-Za-z0-9_]`
- 🟠 日志记 PII(邮箱)→ 移除
- 🟠 无 body 大小限制 → `express.json({limit:"64kb"})`
- 🟡 无滑动续期 → `getValidSession` 加续期 + `store.touchSession()`
**验证**:38 项回归全绿 + XFF 伪造测试(6 个不同 XFF 仍触发 429)+ 真实账号登录正常。
**待办**:
- `STARLIGHT_ADMINS` 填 **UUID**(不是用户名!)
- 生产环境用强密码 + HTTPS(Cookie 加 Secure)
- 登录锁定无解锁途径(重启服务可清 failMap)
---
## 15. 全局 CSRF 防护(v2.7.2,2026-10-05)
**背景**:管理员要求加全局 CSRF(选项 C)。
**方案**:双提交 Cookie(Double Submit Cookie)
- 登录时发 `mr_csrf` cookie(**非 HttpOnly**,JS 可读)+ session cookie
- 前端所有写请求带 `X-CSRF-Token` 头(值 = cookie)
- 后端 `csrfProtection` 中间件校验(GET/HEAD/OPTIONS 豁免)
**改动文件**:
- `src/auth.js` — CSRF_COOKIE / genCsrfToken / setCsrfCookie / clearCsrfCookie / csrfProtection;setSessionCookie 改用 `res.append`(与 csrf cookie 共存)
- `src/routes/auth.js` — 登录成功发 csrf cookie;登出清 csrf
- `src/server.js` — 受保护 API 挂 `auth.csrfProtection`
- `public/js/app.js` — `api()` 统一给写请求加 `X-CSRF-Token`
- `test_api.py` / `test_e2e.py` / `test_write.py` — call() 加 CSRF 头(从 cookie jar 读);test_write 的 multipart import 也加
**验证**:38 项全绿(api16/e2e17/write5)+ CSRF 专项(无头403 / 带头200 / GET豁免)。
**⚠️ 代价**:所有 API 客户端(脚本、第三方集成)写操作必须带 `X-CSRF-Token` 头,否则 403。
**测试脚本适配**:三个 test_*.py 已改,写请求自动读 cookie jar 的 mr_csrf。
---
## 16. 渗透测试修复(v2.7.3,2026-10-05)
依据 `SECURITY_PENTEST.md`(黑盒渗透报告)修复 3 个漏洞:
- **V1 硬编码密码** → start.cmd 改存 **sha256 哈希**;auth.js 加 `passwordMatches()` 支持明文/hash 两种。⚠️ git 历史里的明文未清(老板决定不动云端),**test123 需轮换**。
- **V2 堆栈泄露** → server.js 加全局错误处理中间件,客户端只收通用文案。
- **V3 限流 DoS** → ①类型校验前置不计失败 ②新增 `TRUSTED_IPS` 白名单豁免 ③`isTrustedIp` 归一化 IPv4-mapped IPv6。
**新配置**:`TRUSTED_IPS`(默认 127.0.0.1,::1)。
**测试**:38 项全绿 + V1/V2/V3 专项验证。
**遗留**:轮换 test123、生产 TRUSTED_IPS 收紧、HTTPS。
---
## 17. 第三/四轮渗透修复(v2.7.4,2026-10-05)
依据 `SECURITY_PENTEST_R2.md`(复测)+ `SECURITY_PENTEST_R3_WHITEBOX.md`(白盒):
- **V4【高危·水平越权】**:普通用户可删歌/批量删/改元数据 → `routes/music.js` 给 delete/delete-batch/enrich 补 `requireAdmin`。**已动态验证 403。**
- **V5【低】**:settings 无 schema → 字段白名单。
- **V6【低】**:/hls_cache 未鉴权 → **评估后不改**(切片路径被 m3u8 引用,加锁断播放;低危接受)。
- **V8【低】**:updateSong 动态列名 → 字段白名单。
- **V1**:passwordMatches 新增 **scrypt** 支持(`scrypt:<salt>:<hash>`),兼容 sha256/明文。start.cmd 仍是 sha256,可升级。
**测试脚本**:改从 `TEST_PASSWORD` 环境变量读密码(默认 `123test`)。
**回归**:38 项全绿。
**遗留**:V1 密码强度(弱密码救不了)、git 历史明文。
---
## 18. 第五轮渗透修复(v2.7.5,2026-10-05)
依据更新的 `SECURITY_PENTEST.md`:
- **V9【高危】/api/streams 写操作缺管理员** → **根治**:新增 `auth.requireAdminForWrites`(GET/HEAD/OPTIONS 放行,写要 admin),挂到 server.js 的 /api/music + /api/streams。
- **V10/V11** → 被同一中间件覆盖。
- **V12** SSE 无上限 → sse.js 加总 500 + 每 IP 10 上限,超限 503。
- **V13** 搜索限流被缓存绕过 → 限流检查前置。
- **V14** 日志 CRLF 注入 → logger.js 过滤控制字符。
- **V1** → start.cmd 升级为 **scrypt** 加盐慢哈希。
- **V5** 上轮已修;**V6** 评估后不改(切片被 m3u8 引用)。
**测试**:38 项全绿。
**遗留**:弱密码 `123test`(建议换强随机)。
---
## 19. 权限中间件细化(v2.7.6,2026-10-05)
**背景**:`requireAdminForWrites` 的"非 GET 即需 admin"太粗暴,误伤 `/api/music/detail`(POST 但是普通用户查详情用的)。另:要求**搜索仅管理员**,普通用户只能用 id/链接解析。
**改动**:
1. `auth.js` `requireAdminForWrites` 加 **POST 读白名单** `POST_READ_WHITELIST = {"/music/detail"}`——这些 POST 语义是读,放行给普通用户。
2. `routes/music.js` `/search` 单独加 `requireAdmin`(GET 也要 admin)。
3. `public/js/app.js` `searchMusic()`:普通用户输入非 id → 提示"请用 ID 或链接",不调 search。
**验证**:
- 普通用户:detail 200 ✅ / search 403 ✅
- 管理员:search 200 ✅
- 38 项回归全绿。
**权限模型现状**:
- 读(GET):普通用户可用(除非单独加 admin,如 search)
- 写(POST/DELETE/...):默认 admin,白名单里的"读语义 POST"(detail)例外
---
## 20. 前端误退档恢复(v2.7.6,2026-10-06)
**事件**:前端 `public/` 被误退档回 v2.6 旧版(丢了 StarLight 登录、CSRF、userName 显示、搜索适配)。
**恢复源**:`F:\music_online\demo_starlight_gitbackup_20261005_2206`(本喵清 git 历史前的完整快照,10-05 21:33~21:55)。
**恢复动作**:
1. 从备份拷回 `public/login.html`、`index.html`、`js/app.js`、`css/style.css`。
2. 备份缺的"搜索仅管理员"适配**重做**:`app.js` `searchMusic()` 加 `state.role === "admin"` 判断,普通用户提示用 id/链接。
3. `test_*.py` 从 `demo_starlight_backup_20261005_2130` 恢复后,**重做 CSRF 头 + TEST_PASSWORD 环境变量**适配。
**验证**:38 项全绿(api16/e2e17/write5)。
**教训**:**改动后及时 commit**(哪怕本地),别让工作区长期处于"未提交"状态——一旦误操作(退档/覆盖),未提交的改动最难找回。本次全靠"清 git 历史前的意外备份"救回。
---
## 21. 多角色账号登录修复(v2.7.7,2026-10-06)
**问题**:账号加了第 2 个角色(`Xiao_Tps`)后,StarLight **不再返回 `selectedProfile`**(改为返回 `availableProfiles` 数组),导致 `starlight.js` 误判"该账号尚未创建游戏角色",登录 401。
**修复**:`starlight.js` 角色选择逻辑改为——
1. 有 `selectedProfile` → 用它
2. 无但有 `availableProfiles` → **取第一个**(老板选方案 A,自动选第一个)
3. 都无 → 才报"无角色"
**验证**:多角色账号(1184070342@qq.com)登录 → 200,自动选 `Xiao_love`(role=admin)。
**踩坑**:修复后首次测试仍 401——**旧服务进程占着 3000 端口没清**(EADDRINUSE),新服务没起来,跑的还是旧代码。`taskkill /F /T /PID 106328` 后正常。
---
## 22. 设置页扩充 + 删关于页(v2.8.0,2026-10-06)
### 删关于页
- `index.html`:删导航 tab + `pageAbout` 页面(139 行);`app.js` `pageOrder` 去掉 pageAbout。
### 设置页热配置(真·管理员控制)
**新增 11 项可热改配置**(存 settings 表,**立即生效,无需重启**):
| 配置项 | key | 说明 |
|--------|-----|------|
| 切片时长 | hlsTime | 影响新流 |
| 滑动窗口 | slideWindow | 影响新流 |
| 缓存上限 MB | maxCacheMB | |
| 默认频道 | defaultStream | 存值(待接入默认流逻辑) |
| 日志级别 | logLevel | 热改 levelNum |
| 会话有效期 | sessionTtlHours | |
| 登录失败上限 | loginMaxFails | |
| 登录锁定时长 | loginLockMin | |
| 搜索缓存时长 | searchCacheTtlSec | |
| 搜索限流上限 | searchRateMax | |
| 搜索限流窗口 | searchRateWindowSec | |
**实现**:
- `config.js` 新增 `applyOverrides(settings)`——从 settings 覆盖 config。
- `logger.js` 新增 `setLevel(name)`(`levelNum` 从 const 改 let)。
- `routes/system.js` `POST /settings` 扩白名单 + 保存后调 `applyOverrides` + `setLevel`;`GET /config` 返回全部可配字段。
- `server.js` `bootstrap` 启动时加载 settings 覆盖。
- `public/index.html` 设置页"运行配置"只读 grid → **可编辑表单**(11 项)。
- `public/js/app.js` `loadSettings` 填充表单 + `btnSaveSettings` 保存。
- `public/css/style.css` 加 `.settings-form`/`.setting-row` 样式。
**验证**:POST 改 hlsTime/logLevel/searchRateMax → GET /config 立即反映新值。38 项回归全绿。
**注意**:HTTP 端口、FFmpeg 并发等**需重启的项不做**(老板要求只做热生效)。
---
## 23. 登录页风格统一(v2.8.1,2026-10-06)
**背景**:登录页原本是旧暗色主题(`#0d1117`),与主页面浅色主题(`#f4f5f9`)不一致。
**改动**:`public/login.html` 完全重写样式,套用主页面设计 token:
- 浅色背景 `--bg #f4f5f9` + 白卡片 `--surface` + 阴影 `--shadow-lg`
- 强调色统一 `--accent #4f6ef7`
- 字体 `Plus Jakarta Sans`
- 大圆角 `--radius-xl (28px)` + 卡片升起动画
- 输入框聚焦光晕 `--accent-glow`
- 主按钮 `.btn-primary`(蓝底白字)+ 应急按钮 `.btn-secondary`(描边)
- 引入 `bootstrap-icons.css`(登录/图标)
**功能不变**:StarLight 用户名+密码登录、管理员应急登录、`/api/auth/login` 调用。
**验证**:/login.html 200、38 项回归全绿。
---
## 24. 可视化权限管理(v2.8.2,2026-10-06)
**背景**:原管理员靠 `start.cmd` 的 `STARLIGHT_ADMINS` 环境变量(改要重启),老板问"总不能还去改文件"。
**方案**:设置页加"权限管理"——**用户列表 + 一键授予/取消管理员**,热生效。
**改动**:
- **`db.js`**:新增 `admins` 表(uuid PK, name, added_at);sessions 加 `starlight_uuid` 列(迁移)。
- **`store.js`**:`listAdmins/getAdmin/isAdminUuid/addAdmin/removeAdmin/listUsers`。
- **`auth.js`**:`loginViaStarLight` 判角色**先查 admins 表再查环境变量**;`createSession` 存 uuid;新增 `getStarlightUuid`。
- **`routes/auth.js`**:登录传 uuid 给 session;`/me` 返回 `myUuid`。
- **`routes/system.js`**:`GET /users`(登录过的用户+isAdmin)、`GET/POST/DELETE /admins`。
- **`public/index.html`**:设置页加"权限管理"卡片(用户列表)。
- **`public/js/app.js`**:`loadUsers()` 渲染用户列表 + 设为/取消管理员按钮。
- **`public/css/style.css`**:`.user-list/.user-item/.admin-badge/.btn-mini` 样式。
**判定优先级**:`admins` 表 ∪ 环境变量 `STARLIGHT_ADMINS`(UUID)。
**验证**:真实账号登录→`/me` 返回 myUuid;用户列表显示 isAdmin;加/删管理员正常。38 项回归全绿。
**注意**:旧 sessions 记录无 uuid(迁移前登录的),需**重新登录一次**补上。
---
## 25. 设置页布局优化(v2.8.3,2026-10-06)
**问题**:设置页"很挤,每个卡片都变成独立滚动"。
**根因**:`.section-body` 有 `flex:1; min-height:0; overflow-y:auto` → **每个卡片内容区各自滚动**,一堆小滚动条。
**修复**:
- `.section-body` 去掉 `flex:1 / min-height:0 / overflow-y:auto`(内容自然撑开)。
- `.page.active` 加 `overflow-y:auto`(**整页统一滚动**,一个滚动条)。
- 设置页布局优化:`#pageSettings` gap 加大、`.section-body` padding 加大、`.setting-row` 行距加大、日志区限高自身滚动、用户列表间距加大。
**验证**:CSS 括号平衡、38 项回归全绿。各页无独立滚动规则,统一滚动安全。
---
## 26. 设置页布局优化 v2(v2.8.4,2026-10-06)
**前情**:v2.8.3 第一次改崩(去掉 `.section-body` 的 flex+overflow 导致内容溢出),已回滚。
**本次正确解法**(理解布局链后):
- `.page.active`:+`overflow-y: auto` → **整页统一滚动**
- `.section-card`:+`flex-shrink: 0` → **卡片不被压缩**(关键!首次失败就漏了这个)
- `.section-body`:去掉 `flex:1 / min-height:0 / overflow-y:auto` → 内容自然撑开
**布局链**(勿乱动):
```
.page-container (flex:1; min-height:0)
└── .page.active (flex:1; min-height:0; overflow-y:auto) ← 整页滚
└── .section-card (flex-shrink:0) ← 不压缩
├── .section-header (flex-shrink:0)
└── .section-body (自然高度)
```
**验证**:CSS 括号平衡 395/395、38 项回归全绿。
**教训**:CSS 布局改动前先理解 flex/height/overflow 依赖链;用 python 精确替换 + `assert count==1` 防误伤;改前备份。
---
## 27. 全站改名:Starlight专属电台(v2.8.5,2026-10-06)
**改动**:"Xiao の 电台" / "音乐在线电台" → **"Starlight专属电台"**,全项目统一。
| 文件 | 位置 |
|------|------|
| `index.html` | title + 顶栏品牌 |
| `login.html` | title + h1 |
| `app.js` | 文件注释 |
| `package.json` | description |
| `README.md` | 标题 |
| `HANDOFF.md` | 标题 |
**验证**:全项目零旧名残留。
---
## 28. 循环池(手动歌单 + 随机不重复播放)(v2.9.0,2026-10-06)
**需求**:资源列表"重新匹配作者"按钮 → 换成"加入/移出循环池";队列播完空时从循环池随机抽(抽过标记,全播完重置);池空降级从全部资源随机。
**改动**:
- **`db.js`**:新增 `stream_pool` 表(stream_id, song_id, played, added_at, PK(stream_id,song_id))。
- **`store.js`**:`getPoolRows/listPoolSongIds/isInPool/addToPool/removeFromPool/drawFromPool/markPoolPlayed`。`drawFromPool` 抽未播的,全播过则重置。
- **`livestream.js`**:`ensureNotEmpty` 队列空→`drawFromPool`(抽到标记 played)→池空降级 `getAllSongs` 随机。
- **`routes/streams.js`**:`GET /:id/pool`、`POST /:id/pool/add|remove`。
- **`public/js/app.js`**:资源列表按钮 `js-enrich`→`js-pool`(切换加/移,active 高亮);`loadResources` 查池标记 `inPool`;`togglePool()`。
- **`public/css/style.css`**:`.js-pool.active` 样式。
**每流独立**池。**enrich 接口保留**(仅删资源列表入口)。
**验证**:池增删查正常;抽歌不重复+重置;38 项回归全绿。
---
## 29. 白盒安全审查 + 修复(v2.9.1,2026-10-06)
**审查范围**:全部 `src/`(2963 行)+ 前端。发现并修复:
### 🔴 P1 SSRF(metadata.js)✅
- `request()` / `downloadCover()` URL 来自网易云响应(HTTP 明文可劫持),**无域名校验** → 可请求内网/`file://`。
- **修**:加 `isAllowedHost()` 白名单(`*.music.163.com` / `*.music.126.net`),`request` 和 `downloadCover` 都校验。实测 `169.254.169.254` 被拒。
### 🟡 P3 SSE 计数泄漏(sse.js)✅
- `broadcast` 删失败 client 时未减 `ipCount` → 计数泄漏。
- **修**:client 打 `__sseIp` 标记,删除时同步减计数。
### 🟡 P4 空 catch 加日志 ✅
- `logger.js`(日志流)、`metadata.js`(时长/文件大小探测)、`media.js`(计数)、`routes/music.js`(删临时文件)——加 warn 日志。
- `util.js`(getDirSize 读不到文件)——保持静默 + 注释说明。
### ✅ 已确认无问题
权限挂载、SQL 参数化、输入校验(isValidFileName)、无命令注入(spawn 数组)、advance 同步无重入、TaskQueue 正确、FFmpeg 超时 kill、SSE 连接上限。
**验证**:38 项回归全绿 + SSRF 白名单实测。