dsh-whale-widget 0.3.14 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +15 -0
  2. package/lib/index.js +137 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -306,6 +306,19 @@ MeteorNOX/DeepSeek-Balance-Whale-Widget,或者我本地已经有这个插件
306
306
 
307
307
  > **不需要** `DEEPSEEK_PLATFORM_TOKEN`。早期版本的"实时·令牌"模式已下线,今日已用统一由**小鲸鱼记账**(余额差 + 会话事件)计算,零令牌开箱即用。
308
308
 
309
+ ### 安全边界(0.3.15 起,**请务必一读**)
310
+
311
+ 插件会把凭据交给谁、以及谁能改这件事的规则:
312
+
313
+ 1. **凭据只会发往"该厂商内置模板里写死的端点"**。
314
+ 自定义模型里如果填了一个**不在该厂商模板内**的接口地址(或者模板地址里用 `{base}` 指向你自己填的地址),插件**默认不会把凭据发过去**,而是返回一条明确提示。
315
+ - 自建网关(New API / 自托管 / Ollama 等)确实需要自定义地址:请**在这台机器本机**打开插件面板,对该模型勾选「**允许把凭据发送到自定义地址**」。这是按模型保存的一个显式标志。
316
+ 2. **配置与凭据的改动只能来自本机(回环地址)**。
317
+ 所有写请求(保存模型、写/删凭据、改外观设置等)如果来源不是 `127.0.0.1` / `localhost` / `[::1]`,一律 `403`。
318
+ - 为什么:任意持有 DSH Web 会话的人如果能写模型配置,就等于能决定"凭据被发往哪里" —— 那会把你的 API key 直接送到对方服务器上。**只读接口不受影响**(局域网里照常能看挂件)。
319
+ - 需要远端管理?用环境变量显式声明:`DSHW_ADMIN_HOSTS=192.0.2.10:3080,myhost.lan`(逗号分隔,可带端口;默认空)。
320
+ 3. 接口地址不允许携带用户名/密码(`https://user:pass@host/`),凭据名只允许字母、数字与下划线。
321
+
309
322
  添加自定义模型时,还会按需用到各自厂商的凭据名(都可不配,用到哪个配哪个):
310
323
 
311
324
  | 凭据名 | 用途 |
@@ -393,6 +406,8 @@ curl http://127.0.0.1:3080/dsh-whale/audio.json
393
406
  - **图片/音效不显示、没声音**:确认插件包内 `assets/` 完整(`DSniang1.png`、`*.mp3`、`minecraft-exp-orb.wav` 等);缺失时相关功能静默降级。
394
407
  - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 凭据里配置 API key;**或者**用「设置 → 账号与余额」登录 DeepSeek 账号(较新 DSH 支持)—— 没配 key 时插件会自动走账号态,不需要去 platform 建 key。
395
408
  - **余额拿到了、但提示「账本不可用 / 已停止写入以保护原记录」**:账本文件(`.dshw-usage.json`)被外部改坏或结构异常,插件**拒绝覆盖**它以免丢数据。先把它备份改名让插件重建,再按需用「余额校正」补账。
409
+ - **保存设置报 `403` / 在别的机器上改了配置不生效**:0.3.15 起**写操作只能在运行 DSH 的那台机器上、用 `127.0.0.1`(本机地址)打开界面进行**。从局域网或反向代理访问时,读没问题、写会被拒。需要远端管理就用 `DSHW_ADMIN_HOSTS` 显式授权那台机器(见「凭据 → 安全边界」)。
410
+ - **提示「该地址不是 … 的内置端点,已拒绝把凭据发出去」**:这是 0.3.15 的安全策略 —— **自定义接口地址默认拿不到凭据**。确认那是你自己的网关(New API / 自托管等)后,在**本机**打开面板,对该模型勾选「允许把凭据发送到自定义地址」。
396
411
  - **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
397
412
  - **今日已用与官网有差异**:先核对同一账户、同一币种与同一统计区间;若有充值或其它余额调整,用「小鲸鱼记账 → DeepSeek(内置)→ 设置 → 余额校正」填写实际到账金额。余额接口只返回余额快照、不提供充值流水,充值与消费发生在同一次刷新间隔时需要实际到账金额才能校正。
398
413
  - **充值后消费数字没变**:这是预期行为——充值不会增加消费;数字上方会出现「待核对余额调整」,提示你补充本统计区间的累计到账金额。
package/lib/index.js CHANGED
@@ -692,6 +692,38 @@ export default {
692
692
  + 'DSHW_TRUSTED_HOSTS 声明允许的 Host(逗号分隔,可带端口)。')
693
693
  } catch (err) {}
694
694
  }
695
+ // v761(安全修复 S1):**写请求(POST/PUT/PATCH/DELETE)必须来自本机(回环)**。
696
+ // 为什么:任意持有 DSH Web 会话的人都能 `POST /dsh-whale/api-models.json` 写一个自定义模型,
697
+ // 而余额 / 探活 / 额度会**带着解析出来的真实凭据**去请求模型里那个 URL ⇒ 凭据外带。
698
+ // 「改配置」等于「决定把凭据发往哪里」,所以它必须与"坐在这台机器前"绑定;
699
+ // **只读接口不受影响**(局域网里仍能正常看挂件)。
700
+ // 运维若确实需要远端管理,显式声明 DSHW_ADMIN_HOSTS(逗号分隔,可带端口),默认为空。
701
+ const ADMIN_AUTHORITIES = String(process.env.DSHW_ADMIN_HOSTS || '')
702
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
703
+ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
704
+ function writeRejection(req) {
705
+ try {
706
+ const method = String((req && req.method) || 'GET').toUpperCase()
707
+ if (!WRITE_METHODS.has(method)) return null
708
+ const headers = (req && req.headers) || {}
709
+ let hostUrl = null
710
+ try { hostUrl = new URL('http://' + String(headers.host || '')) } catch (err) { return 403 }
711
+ if (isLoopbackHostname(hostUrl.hostname)) return null
712
+ const authority = hostUrl.host.toLowerCase()
713
+ const hostname = hostUrl.hostname.toLowerCase()
714
+ if (ADMIN_AUTHORITIES.some((e) => (e.indexOf(':') >= 0 ? e === authority : e === hostname))) return null
715
+ if (!writeRejection.warned) {
716
+ writeRejection.warned = true
717
+ try {
718
+ console.warn('[whale-balance] 已拒绝一个来自非本机的**写请求**(Host=' + authority + ')。'
719
+ + '自 0.3.15 起,配置与凭据的改动只能在**本机**(回环地址)打开界面进行 —— '
720
+ + '否则任何拿到 Web 会话的人都能把凭据改指向自己的服务器,从而把真实 API key 带走。'
721
+ + '确需远端管理,请用环境变量 DSHW_ADMIN_HOSTS 声明允许的管理主机。')
722
+ } catch (err) {}
723
+ }
724
+ return 403
725
+ } catch (err) { return 403 } // 自身出错 → 拒绝(fail-closed)
726
+ }
695
727
  function rejected(req, res) {
696
728
  const deny = (code) => {
697
729
  try { res.statusCode = code || 403; res.end() } catch (err) {}
@@ -701,6 +733,9 @@ export default {
701
733
  // 先算栅栏可用性,再自校验:非回环 Host 在"栅栏可用"时交给栅栏判(它知道 --trusted-host)
702
734
  const self = selfRejection(req, { fenceAvailable: !!conn })
703
735
  if (self !== null) return deny(self)
736
+ // v761(安全修复 S1):写完再放行 —— 读请求不受影响
737
+ const write = writeRejection(req)
738
+ if (write !== null) return deny(write)
704
739
  if (!conn) return false // 已通过自校验;此刻没有宿主栅栏可委托(加载时已 warn)
705
740
  let code
706
741
  try {
@@ -1153,7 +1188,7 @@ export default {
1153
1188
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
1154
1189
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
1155
1190
  return {
1156
- ok: true, version: '0.3.14', today: todayData, days7,
1191
+ ok: true, version: '0.3.15', today: todayData, days7,
1157
1192
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
1158
1193
  all: {
1159
1194
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1732,6 +1767,71 @@ export default {
1732
1767
  if (a === 0) throw new Error('禁止访问该地址')
1733
1768
  }
1734
1769
  if (/^fe[89ab][0-9a-f]:/i.test(host)) throw new Error('禁止访问 link-local 地址')
1770
+ // v761(安全修复 S1):URL 里不允许带 userinfo(`https://user:pass@host/`)——
1771
+ // 它是把凭据塞进 URL 的另一种写法,也会让日志/报错里出现凭据
1772
+ if (parsed.username || parsed.password) throw new Error('接口地址不允许携带用户名/密码')
1773
+ }
1774
+ // —— v761(安全修复 S1):**凭据目的地白名单** ——
1775
+ // 规则:只有当目标 origin **恰好等于该厂商内置模板里写死的端点**时,才允许携带凭据;
1776
+ // 其它地址(用户自定义 URL、以及由 `{base}` 展开出来的地址)都必须带 `model.allowCustomHost === true`
1777
+ // 才放行 —— 而该标志**只能由本机(回环)来源的写请求设置**(见 writeRejection),
1778
+ // 因此远端会话即使改了配置,也拿不到凭据。
1779
+ // 注意:**含 `{base}` 的模板 URL 不算可信**(base 是用户可填字段),一律按自定义地址处理。
1780
+ function originOfUrl(u) {
1781
+ try {
1782
+ const p = new URL(String(u))
1783
+ if (p.protocol !== 'http:' && p.protocol !== 'https:') return ''
1784
+ return p.origin.toLowerCase()
1785
+ } catch (err) { return '' }
1786
+ }
1787
+ function templateOrigins(tpl) {
1788
+ const out = new Set()
1789
+ const add = (u) => {
1790
+ const s = String(u == null ? '' : u)
1791
+ if (!s || s.indexOf('{base}') >= 0) return
1792
+ const o = originOfUrl(s.replace('{key}', 'placeholder'))
1793
+ if (o) out.add(o)
1794
+ }
1795
+ add(tpl && tpl.probeUrl)
1796
+ add(tpl && tpl.balance && tpl.balance.url)
1797
+ add(tpl && tpl.balance && tpl.balance.usage && tpl.balance.usage.url)
1798
+ add(tpl && tpl.quota && tpl.quota.url)
1799
+ return out
1800
+ }
1801
+ function credentialMayGoTo(model, tpl, url) {
1802
+ const o = originOfUrl(url)
1803
+ if (!o) return false
1804
+ if (templateOrigins(tpl).has(o)) return true
1805
+ return !!(model && model.allowCustomHost === true)
1806
+ }
1807
+ function warnCredDestinationOnce(url) {
1808
+ try {
1809
+ const o = originOfUrl(url) || '(无效地址)'
1810
+ if (!warnCredDestinationOnce.seen) warnCredDestinationOnce.seen = new Set()
1811
+ if (warnCredDestinationOnce.seen.has(o)) return
1812
+ warnCredDestinationOnce.seen.add(o)
1813
+ console.warn('[whale-balance] 已拒绝把凭据发送到非内置端点:' + o
1814
+ + '(若这是你自建/自托管的网关,请在**本机**打开插件面板,对该模型勾选「允许把凭据发送到自定义地址」)')
1815
+ } catch (err) {}
1816
+ }
1817
+ // 解析凭据 + 目的地校验:所有"带凭据发请求"的地方都必须走这里
1818
+ async function keyForDestination(model, tpl, url) {
1819
+ let key = ''
1820
+ try {
1821
+ const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1822
+ key = cred && cred.value ? String(cred.value) : ''
1823
+ } catch (err) { key = '' }
1824
+ if (!key) return { key: '', code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1825
+ if (!credentialMayGoTo(model, tpl, url)) {
1826
+ warnCredDestinationOnce(url)
1827
+ return {
1828
+ key: '', code: 'DEST_NOT_ALLOWED',
1829
+ error: '该地址不是 ' + String((tpl && tpl.name) || model.provider || '该厂商') + ' 的内置端点,'
1830
+ + '已拒绝把凭据发出去。若这是你自建/自托管的网关,请**在本机**打开插件面板,'
1831
+ + '对该模型勾选「允许把凭据发送到自定义地址」后重试。',
1832
+ }
1833
+ }
1834
+ return { key, code: '', error: '' }
1735
1835
  }
1736
1836
  async function apiFetchJson(url, auth, key) {
1737
1837
  const headers = {}
@@ -1774,16 +1874,14 @@ export default {
1774
1874
  }
1775
1875
  return { ok: false, error: (cs && cs.error) || '未找到 Codex 会话目录' }
1776
1876
  }
1777
- let key = ''
1778
- try {
1779
- const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1780
- key = cred && cred.value ? String(cred.value) : ''
1781
- } catch (err) { key = '' }
1782
- if (!key) return { ok: false, error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1783
1877
  const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
1784
1878
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
1785
1879
  const url = String(tpl.probeUrl || b.url || (base ? base + '/v1/models' : '')).replace('{base}', base)
1786
1880
  if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
1881
+ // v761(安全修复 S1):只在目的地可信时才带上凭据
1882
+ const k = await keyForDestination(model, tpl, url)
1883
+ if (!k.key) return { ok: false, code: k.code, error: k.error }
1884
+ const key = k.key
1787
1885
  try {
1788
1886
  const data = await apiFetchJson(url, b.auth, key)
1789
1887
  const bizErr = apiBusinessError(data)
@@ -1805,14 +1903,12 @@ export default {
1805
1903
  const tpl = apiTemplateOf(model.provider) || {}
1806
1904
  const q = tpl.quota
1807
1905
  if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
1808
- let key = ''
1809
- try {
1810
- const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1811
- key = cred && cred.value ? String(cred.value) : ''
1812
- } catch (err) { key = '' }
1813
- if (!key) return { ok: false, code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1814
1906
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
1815
1907
  const url = String(q.url).replace('{base}', base)
1908
+ // v761(安全修复 S1):额度 URL 虽来自模板,但 `{base}` 会被用户字段改写,同样要校验目的地
1909
+ const k = await keyForDestination(model, tpl, url)
1910
+ if (!k.key) return { ok: false, code: k.code, error: k.error }
1911
+ const key = k.key
1816
1912
  try {
1817
1913
  const data = await apiFetchJson(url, q.auth, key)
1818
1914
  const bizErr = apiBusinessError(data)
@@ -1899,14 +1995,12 @@ export default {
1899
1995
  const tpl = apiTemplateOf(model.provider) || {}
1900
1996
  const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
1901
1997
  if (!b.url) return { ok: false, code: 'NO_URL', error: '未配置余额接口地址' }
1902
- let key = ''
1903
- try {
1904
- const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1905
- key = cred && cred.value ? String(cred.value) : ''
1906
- } catch (err) { key = '' }
1907
- if (!key) return { ok: false, code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1908
1998
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
1909
1999
  const url = String(b.url).replace('{base}', base)
2000
+ // v761(安全修复 S1):凭据只在目的地可信(厂商内置端点 / 本机确认过的自定义地址)时才带上
2001
+ const k = await keyForDestination(model, tpl, url)
2002
+ if (!k.key) return { ok: false, code: k.code, error: k.error }
2003
+ const key = k.key
1910
2004
  try {
1911
2005
  const data = await apiFetchJson(url, b.auth, key)
1912
2006
  const j = b.json || {}
@@ -1918,9 +2012,13 @@ export default {
1918
2012
  if (j.used) { const v = apiNum(pickJsonPath(data, j.used), j.scale); if (v !== null) used += v }
1919
2013
  if (b.usage && b.usage.url) {
1920
2014
  const u = b.usage
1921
- const d2 = await apiFetchJson(String(u.url).replace('{base}', base), u.auth || b.auth, key)
2015
+ // v761(安全修复 S1):usage 是可选增强 —— 目的地不可信就跳过,绝不把凭据发出去
2016
+ const usageUrl = String(u.url).replace('{base}', base)
2017
+ if (credentialMayGoTo(model, tpl, usageUrl)) {
2018
+ const d2 = await apiFetchJson(usageUrl, u.auth || b.auth, key)
1922
2019
  const j2 = u.json || {}
1923
2020
  if (j2.used) { const v = apiNum(pickJsonPath(d2, j2.used), j2.scale); if (v !== null) used += v }
2021
+ } else { warnCredDestinationOnce(usageUrl) }
1924
2022
  }
1925
2023
  if (remaining === null && total !== null) remaining = total - used
1926
2024
  if (remaining === null) return { ok: false, code: 'SHAPE', error: '接口返回里没找到配置的字段(检查 JSON 路径)' }
@@ -2124,6 +2222,8 @@ export default {
2124
2222
  if (!tpl) return { ok: false, error: '未知的厂商模板' }
2125
2223
  const name = String(p.name || '').trim().slice(0, 30) || tpl.name
2126
2224
  const keyRef = String(p.keyRef || tpl.keyRef || '').trim().slice(0, 64) || tpl.keyRef
2225
+ // v761(安全修复 S1):凭据名加白名单 —— 原实现接受任意字符串,理论上能写到别的键名上
2226
+ if (!/^[A-Za-z0-9_]{1,64}$/.test(keyRef)) return { ok: false, error: '凭据名只能包含字母、数字与下划线(≤64 字符)' }
2127
2227
  const id = p.id ? String(p.id) : ('api_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7))
2128
2228
  const reg = readApiRegistry()
2129
2229
  let model = reg.models.find((m) => m && m.id === id)
@@ -2135,6 +2235,11 @@ export default {
2135
2235
  model.provider = p.provider
2136
2236
  model.currency = String(p.currency || tpl.currency || 'CNY').toUpperCase().slice(0, 8)
2137
2237
  model.keyRef = keyRef
2238
+ // v761(安全修复 S1):**允许把凭据发往自定义地址**(自建/自托管网关需要)。
2239
+ // 该标志是"凭据目的地白名单"的唯一例外,而所有写请求都被限制为**本机来源**(见 writeRejection),
2240
+ // 所以远端会话即使能改配置,也无法给自己开这个口子。缺省不动(前端局部更新不会误抹)。
2241
+ if (p.allowCustomHost === true) model.allowCustomHost = true
2242
+ else if (p.allowCustomHost === false) delete model.allowCustomHost
2138
2243
  if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
2139
2244
  if (p.balance && typeof p.balance === 'object') {
2140
2245
  // v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
@@ -2214,6 +2319,17 @@ export default {
2214
2319
  let entry = {
2215
2320
  id: m.id, name: m.name, provider: m.provider, currency: m.currency,
2216
2321
  keyRef: m.keyRef, builtin: !!m.builtin, baseUrl: m.baseUrl || '',
2322
+ // v761(安全修复 S1):该模型当前的目的地拿不到凭据(非厂商内置端点且未在本机确认过),
2323
+ // 前端据此提示用户"要去本机面板确认",而不是让余额莫名其妙地空着。
2324
+ needsHostConfirm: (() => {
2325
+ try {
2326
+ const tpl2 = apiTemplateOf(m.provider) || {}
2327
+ const b2 = mergeNonEmpty(tpl2.balance, m.balance) || {}
2328
+ const base2 = String(m.baseUrl || "").replace(/\/+$/, "")
2329
+ const u2 = String(tpl2.probeUrl || b2.url || "").replace("{base}", base2)
2330
+ return !!u2 && !credentialMayGoTo(m, tpl2, u2)
2331
+ } catch (err) { return false }
2332
+ })(),
2217
2333
  canAdjustBalance: canAdjustBuiltinBalance(m),
2218
2334
  matchIds: m.matchIds || [], settings: settings.models && settings.models[m.id] ? settings.models[m.id] : null,
2219
2335
  price: m.price || null,
@@ -2353,7 +2469,7 @@ export default {
2353
2469
  const summary = daySummary(led, todayKey())
2354
2470
  const nowSec = Math.floor(Date.now() / 1000)
2355
2471
  return {
2356
- ...visible, version: '0.3.14', isPeak: isPeakTime(nowSec),
2472
+ ...visible, version: '0.3.15', isPeak: isPeakTime(nowSec),
2357
2473
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2358
2474
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2359
2475
  peakHolidays: HOLIDAY_VALLEY_LIST,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",