diff --git a/README.md b/README.md index 250f2e7..3d133f3 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,11 @@ pnpm typecheck # 仅类型检查,不运行 设置环境变量 `AUTH_KEY` 后,**全部路径**(含文档页、健康检查、不存在的路径)都要求调用秘钥——未授权请求统一返回 401,不暴露 404/401 差异,防止接口被扫描探测。未设置则不鉴权。 ``` -X-Server-Key: # 统一验证头 -?key= # 仅 /stream/* 流代理接受(播放器拉流无法带自定义头) +X-Server-Key: # 统一验证头(全部路径) +?exp=&sig= # 仅 /stream/* 流代理:短时签名(/url 返回的流地址自动附带) ``` -启用鉴权时 `/url` 返回的代理流地址(如 qs 解密流)会自动附带 `?key=`。 +流代理场景(播放器/下载器直接 GET、无法带自定义头)用**短时签名**而非裸秘钥:`/url` 返回的流地址自动附带 `?exp=&sig=`(HMAC 派生,10 分钟有效)——签名泄露仅能在时效窗口内拉当条流,无法反推秘钥或调用其它接口;能带头的客户端仍可对 /stream/* 使用 X-Server-Key。 ### 限流(RATE_LIMIT) diff --git a/src/http/auth-gate.ts b/src/http/auth-gate.ts index 9b09453..3fc53c9 100644 --- a/src/http/auth-gate.ts +++ b/src/http/auth-gate.ts @@ -1,10 +1,14 @@ // 全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权) // 验证头统一为 X-Server-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 请求头携带' }) } }) } diff --git a/src/http/stream-sign.ts b/src/http/stream-sign.ts new file mode 100644 index 0000000..12d772d --- /dev/null +++ b/src/http/stream-sign.ts @@ -0,0 +1,27 @@ +// 流代理签名 —— /stream/* 的无头客户端兼容方案(播放器/下载器直接 GET,无法携带自定义头) +// /url 返回流地址时附加 ?exp=<秒级时间戳>&sig= +// 与裸 ?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) +} diff --git a/src/routes.ts b/src/routes.ts index 487917c..5f6a3d8 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -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 } : {}) } } diff --git a/test/smoke-test.ts b/test/smoke-test.ts index a6b78c3..11c722b 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -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)