@goodandready/dsh-subscriptions 0.6.54 → 0.6.55

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.
package/lib/http.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+
1
3
  export function writeJson(res, code, body) {
2
4
  try {
3
5
  res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
@@ -12,6 +14,14 @@ export function writeHtml(res, code, html) {
12
14
  } catch { /* socket closed */ }
13
15
  }
14
16
 
17
+ export function timingSafeCompare(a, b) {
18
+ if (typeof a !== 'string' || typeof b !== 'string') return false
19
+ const bufA = Buffer.from(a, 'utf8')
20
+ const bufB = Buffer.from(b, 'utf8')
21
+ if (bufA.length !== bufB.length) return false
22
+ return timingSafeEqual(bufA, bufB)
23
+ }
24
+
15
25
  export function readBody(req, maxBytes = 64 * 1024) {
16
26
  return new Promise((resolve, reject) => {
17
27
  const chunks = []
@@ -126,11 +136,11 @@ export function isTrustedSettingsRequest(request, targetCtx) {
126
136
  // 5. Loopback request (CLI, local daemon, localhost test)
127
137
  if (isLoopback(request)) return true
128
138
 
129
- // 6. Verified server secret token with exact cookie matching (#374)
139
+ // 6. Verified server secret token with exact cookie matching (#374, #492)
130
140
  const expectedToken = process.env.DSH_AUTH_TOKEN
131
141
  if (expectedToken) {
132
142
  const auth = headers.authorization
133
- if (typeof auth === 'string' && auth.startsWith('Bearer ') && auth.slice(7).trim() === expectedToken) {
143
+ if (typeof auth === 'string' && auth.startsWith('Bearer ') && timingSafeCompare(auth.slice(7).trim(), expectedToken)) {
134
144
  return true
135
145
  }
136
146
  const cookieHeader = headers.cookie
@@ -142,7 +152,7 @@ export function isTrustedSettingsRequest(request, targetCtx) {
142
152
  cookies[part.slice(0, eq).trim()] = part.slice(eq + 1).trim()
143
153
  }
144
154
  }
145
- if (cookies.token === expectedToken || cookies.dsh_token === expectedToken) {
155
+ if (timingSafeCompare(cookies.token, expectedToken) || timingSafeCompare(cookies.dsh_token, expectedToken)) {
146
156
  return true
147
157
  }
148
158
  }
@@ -261,10 +271,12 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
261
271
  clearTimeout(timer)
262
272
  return res
263
273
  }
274
+ if (typeof timer?.unref === 'function') timer.unref()
264
275
  const origJson = typeof res.json === 'function' ? res.json.bind(res) : null
265
276
  const origText = typeof res.text === 'function' ? res.text.bind(res) : null
266
277
  if (origJson) {
267
278
  res.json = () => {
279
+ if (typeof timer?.ref === 'function') timer.ref()
268
280
  if (controller.signal.aborted) {
269
281
  return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
270
282
  }
@@ -292,6 +304,7 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
292
304
  }
293
305
  if (origText) {
294
306
  res.text = () => {
307
+ if (typeof timer?.ref === 'function') timer.ref()
295
308
  if (controller.signal.aborted) {
296
309
  return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
297
310
  }
package/lib/index.js CHANGED
@@ -442,10 +442,8 @@ export function apply(ctx, rawConfig) {
442
442
  }
443
443
  }
444
444
  tick().catch(() => {})
445
- const timer = setInterval
446
- const clear = clearInterval
447
- const id = timer(() => { tick().catch(() => {}) }, 60 * 1000)
448
- return () => clear(id)
445
+ const id = setInterval(() => { tick().catch(() => {}) }, 60 * 1000)
446
+ return () => clearInterval(id)
449
447
  }, 'dsh-subscriptions: refresh ahead')
450
448
 
451
449
  // Background health-check probe loop
@@ -31,6 +31,10 @@ export function registerOauthRoutes(ctx, state) {
31
31
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
32
32
  return
33
33
  }
34
+ if (!isTrustedSettingsRequest(req, ctx)) {
35
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
36
+ return
37
+ }
34
38
  const q = queryOf(req)
35
39
  const provider = q.get('provider') || ''
36
40
  const index = Number(q.get('index') || '1')
@@ -354,7 +354,7 @@ export function registerStatusRoutes(ctx, state) {
354
354
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
355
355
  return
356
356
  }
357
- const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
357
+ const limit = Math.max(1, Math.min(Number(queryOf(req).get('limit') || '10'), 100))
358
358
  writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
359
359
  }),
360
360
  }), 'dsh-subscriptions: /history')
@@ -502,7 +502,7 @@ export function registerStatusRoutes(ctx, state) {
502
502
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
503
503
  return
504
504
  }
505
- const limit = Math.min(Number(queryOf(req).get('limit') || '50'), 100)
505
+ const limit = Math.max(1, Math.min(Number(queryOf(req).get('limit') || '50'), 100))
506
506
  writeJson(res, 200, { ok: true, alerts: getRecentAlerts(limit) })
507
507
  }),
508
508
  }), 'dsh-subscriptions: /alerts')
@@ -18,9 +18,16 @@ export function isAllowed(provider, path) {
18
18
  let p = String(path || "")
19
19
  // strip query and hash
20
20
  p = p.split("?")[0].split("#")[0]
21
+ if (p.startsWith("//")) return false
21
22
  try {
22
- if (p.startsWith("http://") || p.startsWith("https://")) p = new URL(p).pathname
23
- } catch { /* ignore subscriptions error */ }
23
+ if (p.startsWith("http://") || p.startsWith("https://")) {
24
+ p = new URL(p).pathname
25
+ } else {
26
+ p = new URL(p.startsWith("/") ? p : "/" + p, "http://localhost").pathname
27
+ }
28
+ } catch {
29
+ return false
30
+ }
24
31
  if (!p.startsWith("/")) p = "/" + p
25
32
  for (const allowed of list) {
26
33
  const a = allowed.split("?")[0]
@@ -88,6 +95,11 @@ export function createSubscriptionsService(deps) {
88
95
  throw err
89
96
  }
90
97
  const rawPath = String(path || "")
98
+ if (rawPath.startsWith("//")) {
99
+ const err = new Error("protocol-relative URL rejected: " + rawPath)
100
+ err.code = "FORBIDDEN"
101
+ throw err
102
+ }
91
103
  if (rawPath.startsWith("http://") || rawPath.startsWith("https://")) {
92
104
  const initialCfg = typeof deps.vendorConfig === "function" ? deps.vendorConfig(provider) : {}
93
105
  const initialBase = baseFor(provider, initialCfg || {})
@@ -131,23 +143,57 @@ export function createSubscriptionsService(deps) {
131
143
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
132
144
  const cfg = deps.vendorConfig(provider)
133
145
  const base = baseFor(provider, cfg)
134
- // normalize path
135
- let p = String(path)
136
- if (p.startsWith("http://") || p.startsWith("https://")) {
137
- // #445: enforce provider origin policy before Bearer dispatch
138
- let targetOrigin, baseOrigin
139
- try { targetOrigin = new URL(p).origin } catch { /* ignore */ }
140
- try { baseOrigin = new URL(base).origin } catch { /* ignore */ }
141
- if (!targetOrigin || !baseOrigin || targetOrigin.toLowerCase() !== baseOrigin.toLowerCase()) {
142
- const err = new Error("external origin rejected for provider " + provider + ": " + p)
146
+
147
+ // normalize path and resolve target URL (#487)
148
+ let targetUrl
149
+ let baseOrigin = ""
150
+ let basePath = ""
151
+ try {
152
+ const bUrl = new URL(base)
153
+ baseOrigin = bUrl.origin
154
+ basePath = bUrl.pathname.replace(/\/$/, "")
155
+ } catch { /* ignore */ }
156
+
157
+ const isAbsolute = rawPath.startsWith("http://") || rawPath.startsWith("https://")
158
+ try {
159
+ if (isAbsolute) {
160
+ targetUrl = new URL(rawPath)
161
+ } else {
162
+ const cleanRel = rawPath.startsWith("/") ? rawPath : "/" + rawPath
163
+ if (basePath && (cleanRel === basePath || cleanRel.startsWith(basePath + "/"))) {
164
+ targetUrl = new URL(baseOrigin + cleanRel)
165
+ } else {
166
+ const cleanBase = base.replace(/\/$/, "")
167
+ targetUrl = new URL(cleanBase + cleanRel)
168
+ }
169
+ }
170
+ } catch {
171
+ const err = new Error("invalid URL for " + provider + ": " + rawPath)
172
+ err.code = "FORBIDDEN"
173
+ throw err
174
+ }
175
+
176
+ if (!baseOrigin || targetUrl.origin.toLowerCase() !== baseOrigin.toLowerCase()) {
177
+ const err = new Error("external origin rejected for provider " + provider + ": " + rawPath)
178
+ err.code = "FORBIDDEN"
179
+ throw err
180
+ }
181
+
182
+ if (!isAbsolute) {
183
+ if (basePath && targetUrl.pathname !== basePath && !targetUrl.pathname.startsWith(basePath + "/")) {
184
+ const err = new Error("path traversal rejected for provider " + provider + ": " + rawPath)
143
185
  err.code = "FORBIDDEN"
144
186
  throw err
145
187
  }
146
- } else {
147
- if (!p.startsWith("/")) p = "/" + p
148
- p = base + p
149
188
  }
150
- const url = p
189
+
190
+ if (!isAllowed(provider, targetUrl.pathname)) {
191
+ const err = new Error("path not allowed for " + provider + ": " + targetUrl.pathname)
192
+ err.code = "FORBIDDEN"
193
+ throw err
194
+ }
195
+
196
+ const url = targetUrl.toString()
151
197
  const extraHeaders = headersFor(provider, blob, cfg)
152
198
  const fetchImpl = pickFetch(deps, account.ref)
153
199
  const requestTimeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.54",
3
+ "version": "0.6.55",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",