@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 +16 -3
- package/lib/index.js +2 -4
- package/lib/routes/oauth.js +4 -0
- package/lib/routes/status.js +2 -2
- package/lib/subscriptions.js +61 -15
- package/package.json +1 -1
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()
|
|
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
|
|
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
|
|
446
|
-
|
|
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
|
package/lib/routes/oauth.js
CHANGED
|
@@ -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')
|
package/lib/routes/status.js
CHANGED
|
@@ -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')
|
package/lib/subscriptions.js
CHANGED
|
@@ -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://"))
|
|
23
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|