feat(auth): 流代理升级短时签名 URL——?exp=&sig=(HMAC 派生 10 分钟有效)替代裸 ?key=,秘钥不再落入 URL/日志

This commit is contained in:
snowy-yang committed 2026-10-02 20:39:25 +08:00
1 parent 415a04dea8
commit cf0874fc15
5 files changed
+78 -21

No files matched your search

+3 -3
View File
@@ -81,11 +81,11 @@ pnpm typecheck # 仅类型检查,不运行
设置环境变量 `AUTH_KEY` 后,**全部路径**(含文档页、健康检查、不存在的路径)都要求调用秘钥——未授权请求统一返回 401,不暴露 404/401 差异,防止接口被扫描探测。未设置则不鉴权。
```
X-Server-Key: <AUTH_KEY> # 统一验证头
?key=<AUTH_KEY> # 仅 /stream/* 流代理接受(播放器拉流无法带自定义头)
X-Server-Key: <AUTH_KEY> # 统一验证头(全部路径)
?exp=&sig= # 仅 /stream/* 流代理:短时签名(/url 返回的流地址自动附带)
```
启用鉴权时 `/url` 返回的代理流地址(如 qs 解密流)会自动附带 `?key=`。
流代理场景(播放器/下载器直接 GET、无法带自定义头)用**短时签名**而非裸秘钥:`/url` 返回的流地址自动附带 `?exp=&sig=`(HMAC 派生,10 分钟有效)——签名泄露仅能在时效窗口内拉当条流,无法反推秘钥或调用其它接口;能带头的客户端仍可对 /stream/* 使用 X-Server-Key。
### 限流(RATE_LIMIT)
+17 -6
View File
@@ -1,10 +1,14 @@
// 全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权)
// 验证头统一为 X-Server-Key: <AUTH_KEY>;全部路径(含文档页/健康检查/404)都要求秘钥,
// 未授权请求一律 401,避免被扫描器区分出接口存在性(404/401 差异)。
// 唯一例外:/stream/* 代理流额外接受 ?key=(播放器直接拉流无法携带自定义头)。
// CORS 预检(OPTIONS)无响应体、不泄露内容,豁免。
//
// /stream/* 流代理例外:播放器/下载器直接 GET 媒体地址、无法携带自定义头,
// 改用短时签名(?exp=&sig=,见 stream-sign.ts)——不落裸秘钥到 URL/日志,签名泄露仅能
// 在时效窗口内拉当条流;能带头的客户端(curl 等)仍可用 X-Server-Key。
import crypto from 'node:crypto'
import type { FastifyRequest, FastifyReply } from 'fastify'
import { verifyStreamSign } from './stream-sign.ts'
const safeEqual = (a: string, b: string) => {
const bufA = Buffer.from(a)
@@ -15,15 +19,22 @@ const safeEqual = (a: string, b: string) => {
export const registerAuth = (fastify: any, authKey: string) => {
fastify.addHook('onRequest', async(request: FastifyRequest, reply: FastifyReply) => {
if (request.method === 'OPTIONS') return
// /stream/* 流媒体代理:X-Server-Key 或 ?key=(播放器拉流兼容)
const pathname = new URL(request.url, 'http://localhost').pathname
const provided = pathname.startsWith('/stream/')
? (String(request.headers['x-server-key'] || '') || String((request.query as any)?.key || ''))
: String(request.headers['x-server-key'] || '')
// /stream/* 流媒体代理:X-Server-Key 优先,或短时签名 ?exp=&sig=(播放器拉流兼容)
if (pathname.startsWith('/stream/')) {
const header = String(request.headers['x-server-key'] || '')
const q = request.query as any
const ok = (header && safeEqual(header, authKey))
|| (!header && verifyStreamSign(authKey, pathname, q?.exp, q?.sig))
if (ok) return
return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:X-Server-Key 头,或 /url 返回流地址自带的 exp/sig 签名' })
}
const provided = String(request.headers['x-server-key'] || '')
if (!provided || !safeEqual(provided, authKey)) {
// 统一 401 文案(不区分路径存在与否,防扫描探测)
return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:请在 X-Server-Key 请求头携带(流代理可用 ?key=)' })
return reply.code(401).send({ code: 401, msg: '无效或缺少调用秘钥:请在 X-Server-Key 请求头携带' })
}
})
}
+27
View File
@@ -0,0 +1,27 @@
// 流代理签名 —— /stream/* 的无头客户端兼容方案(播放器/下载器直接 GET,无法携带自定义头)
// /url 返回流地址时附加 ?exp=<秒级时间戳>&sig=<HMAC-SHA256(authKey, "path:exp") 前 32 位>
// 与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口
import crypto from 'node:crypto'
// 流地址有效期(毫秒),与 qs 流缓存 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
export const STREAM_SIGN_TTL = 10 * 60_000
const hmac = (authKey: string, data: string) =>
crypto.createHmac('sha256', authKey).update(data).digest('hex').slice(0, 32)
/** 生成流地址的签名参数(path 为不含 query 的路径,如 /stream/qs/123) */
export const signStream = (authKey: string, path: string) => {
const exp = Math.floor((Date.now() + STREAM_SIGN_TTL) / 1000)
return `exp=${exp}&sig=${hmac(authKey, `${path}:${exp}`)}`
}
/** 校验流地址签名(timingSafeEqual + 时效窗口) */
export const verifyStreamSign = (authKey: string, path: string, exp: unknown, sig: unknown): boolean => {
const expNum = Number(exp)
const sigStr = String(sig || '')
if (!Number.isFinite(expNum) || !sigStr) return false
if (Date.now() / 1000 > expNum) return false // 过期即拒(客户端重新 /url 取新地址)
const expected = Buffer.from(hmac(authKey, `${path}:${expNum}`))
const provided = Buffer.from(sigStr)
return expected.length === provided.length && crypto.timingSafeEqual(expected, provided)
}
+9 -4
View File
@@ -4,6 +4,7 @@
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
import { notFound, upstream } from './http/api-error.ts'
import { signStream } from './http/stream-sign.ts'
import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit']
@@ -137,10 +138,14 @@ export const musicRoutes = async(fastify: any) => {
const finish = (res: any, via: string) => {
if (!res?.url) throw new Error('获取播放地址失败')
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
// 启用 AUTH_KEY 时附带 ?key=(播放器直接拉流无法携带自定义头)
const url = String(res.url).startsWith('/')
? `${request.protocol}://${request.headers.host}${res.url}${process.env.AUTH_KEY ? `${String(res.url).includes('?') ? '&' : '?'}key=${encodeURIComponent(process.env.AUTH_KEY)}` : ''}`
: res.url
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
let url = String(res.url)
if (url.startsWith('/')) {
const suffix = process.env.AUTH_KEY
? `${url.includes('?') ? '&' : '?'}${signStream(process.env.AUTH_KEY, url.split('?')[0])}`
: ''
url = `${request.protocol}://${request.headers.host}${url}${suffix}`
}
// type 用实际音质(kw 等通道可能降级);via 标记兜底链中真正命中的通道
return { type: res.type || quality, url, ...(via !== source ? { via } : {}) }
}
+22 -8
View File
@@ -646,14 +646,28 @@ if (!externalServer && !process.env.AUTH_KEY) {
if (r.status !== 401) throw new Error(`${path} 期望 401,实际 ${r.status}(应全路径要求秘钥)`)
}
await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => 'ok')
// /stream/* 流代理额外接受 ?key=(播放器拉流兼容),其它路径 ?key= 无效
await test('AUTH_KEY ?key= 仅限 /stream/*', async() => {
const normal = await authFetch(`/sources?key=${AUTH_TEST_KEY}`)
if (normal.status !== 401) throw new Error(`普通路径 ?key= 应 401,实际 ${normal.status}`)
const stream = await authFetch(`/stream/qs/123?key=${AUTH_TEST_KEY}`)
// 带对 key 的 stream 请求应通过鉴权(后续业务可能 502,但不能是 401)
if (stream.status === 401) throw new Error('stream ?key= 应通过鉴权')
return `stream 状态 ${stream.status}(过鉴权)`
// /stream/* 流代理:支持 X-Server-Key 头,或短时签名 ?exp=&sig=(不落裸秘钥到 URL);裸 ?key= 已废弃
await test('AUTH_KEY 流代理签名机制', async() => {
// 裸秘钥 ?key= 已不生效
const rawKey = await authFetch(`/stream/qs/123?key=${AUTH_TEST_KEY}`)
if (rawKey.status !== 401) throw new Error(`裸 ?key= 应 401,实际 ${rawKey.status}`)
// X-Server-Key 头可通过鉴权(后续业务可能 502,但不能是 401)
const withHeader = await authFetch('/stream/qs/123', { headers: keyHeaders })
if (withHeader.status === 401) throw new Error('X-Server-Key 应通过流代理鉴权')
// 正确签名通过;错签名 401
const exp = Math.floor(Date.now() / 1000) + 600
const crypto = await import('node:crypto')
const sig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/qs/123:${exp}`).digest('hex').slice(0, 32)
const signed = await authFetch(`/stream/qs/123?exp=${exp}&sig=${sig}`)
if (signed.status === 401) throw new Error('有效签名应通过流代理鉴权')
const badSig = await authFetch(`/stream/qs/123?exp=${exp}&sig=${'0'.repeat(32)}`)
if (badSig.status !== 401) throw new Error(`错签名应 401,实际 ${badSig.status}`)
// 过期签名 401
const oldExp = Math.floor(Date.now() / 1000) - 10
const oldSig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/qs/123:${oldExp}`).digest('hex').slice(0, 32)
const expired = await authFetch(`/stream/qs/123?exp=${oldExp}&sig=${oldSig}`)
if (expired.status !== 401) throw new Error(`过期签名应 401,实际 ${expired.status}`)
return '头/签名通过,裸key/错签/过期签均 401'
})
// 限流(独立低阈值实例验证 429)