@goodandready/dsh-messenger-gateway 0.4.5 → 0.4.7

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,8 +1,25 @@
1
1
  import { timingSafeEqual } from 'node:crypto'
2
2
 
3
- export async function readBody(req) {
3
+ export async function readBody(req, maxBytes = 1_048_576) {
4
+ const cl = Number(req?.headers?.['content-length'])
5
+ if (Number.isFinite(cl) && cl > maxBytes) {
6
+ const err = new Error(`Payload Too Large: content-length ${cl} exceeds ${maxBytes}`)
7
+ err.status = 413
8
+ err.statusCode = 413
9
+ throw err
10
+ }
4
11
  const chunks = []
5
- for await (const chunk of req) chunks.push(chunk)
12
+ let total = 0
13
+ for await (const chunk of req) {
14
+ total += chunk.length
15
+ if (total > maxBytes) {
16
+ const err = new Error(`Payload Too Large: exceeded ${maxBytes} bytes`)
17
+ err.status = 413
18
+ err.statusCode = 413
19
+ throw err
20
+ }
21
+ chunks.push(chunk)
22
+ }
6
23
  return Buffer.concat(chunks)
7
24
  }
8
25
 
@@ -12,21 +29,67 @@ export function writeJson(res, status, body) {
12
29
  res.end(JSON.stringify(body))
13
30
  }
14
31
 
32
+ export function isLoopback(value) {
33
+ const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
34
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
35
+ || address.startsWith('127.')
36
+ || address.startsWith('::ffff:127.')
37
+ }
38
+
15
39
  export function isTrustedSettingsRequest(req) {
16
- const host = String(req.headers?.host || '')
40
+ const remote = req?.socket?.remoteAddress
41
+ const host = String(req?.headers?.host || '')
17
42
  if (!host) return false
18
43
 
19
- const origin = String(req.headers?.origin || '')
44
+ const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
45
+ if (site === 'cross-site') return false
46
+
47
+ const origin = String(req?.headers?.origin || '')
20
48
  if (origin) {
21
- try { return new URL(origin).host === host } catch { return false }
49
+ try {
50
+ const url = new URL(origin)
51
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
52
+ return url.host.toLowerCase() === host.toLowerCase()
53
+ } catch {
54
+ return false
55
+ }
22
56
  }
23
57
 
24
- const secFetchSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
25
- if (secFetchSite === 'same-origin') return true
26
-
27
- const referer = String(req.headers?.referer || '')
58
+ const referer = String(req?.headers?.referer || '')
28
59
  if (referer) {
29
- try { return new URL(referer).host === host } catch { return false }
60
+ try {
61
+ const url = new URL(referer)
62
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
63
+ return url.host.toLowerCase() === host.toLowerCase()
64
+ } catch {
65
+ return false
66
+ }
67
+ }
68
+
69
+ if (site === 'same-origin') {
70
+ if (remote !== undefined && !isLoopback(remote)) return false
71
+ return true
72
+ }
73
+
74
+ return isLoopback(remote)
75
+ }
76
+
77
+ export function isAuthorizedMessengerRequest(req, webhooksConfig) {
78
+ const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
79
+ if (site === 'cross-site') return false
80
+
81
+ if (isTrustedSettingsRequest(req)) return true
82
+ if (isLoopback(req?.socket?.remoteAddress)) return true
83
+
84
+ const secret = String(webhooksConfig?.secret || '').trim()
85
+ if (secret) {
86
+ const authHeader = req?.headers?.authorization || ''
87
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
88
+ const tokenHeader = req?.headers?.['x-webhook-secret'] || ''
89
+ const provided = bearer || tokenHeader
90
+ if (provided && timingSafeCompare(provided, secret)) {
91
+ return true
92
+ }
30
93
  }
31
94
 
32
95
  return false
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { join, dirname, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
5
  import { Gateway } from './gateway.js'
6
- import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare } from './http.js'
6
+ import { readBody, writeJson, isTrustedSettingsRequest, timingSafeCompare, isAuthorizedMessengerRequest } from './http.js'
7
7
  import { registerPluginUpdater } from './updater.js'
8
8
  import {
9
9
  createMessengerService, dispatchMessenger, httpStatusForError,
@@ -11,6 +11,7 @@ import {
11
11
  } from './messenger-api.js'
12
12
  import { listHomes } from './homes.js'
13
13
  import { Config } from './config.js'
14
+ import { setupSettings } from './settings-bridge.js'
14
15
 
15
16
  export const name = '@goodandready/dsh-messenger-gateway'
16
17
  export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments', 'tools']
@@ -90,14 +91,32 @@ function publicConfig(cfg) {
90
91
  }
91
92
  }
92
93
 
93
- function registerMessengerRoute(ctx, getGw, path, action) {
94
+ async function readJsonSafe(req, res, maxBytes = 1_048_576) {
95
+ try {
96
+ const raw = await readBody(req, maxBytes)
97
+ const text = raw.toString('utf8').trim()
98
+ return { ok: true, data: text ? JSON.parse(text) : {} }
99
+ } catch (err) {
100
+ if (err.statusCode === 413 || err.status === 413) {
101
+ writeJson(res, 413, { ok: false, error: 'payload too large' })
102
+ return { ok: false }
103
+ }
104
+ writeJson(res, 400, { ok: false, error: 'invalid json' })
105
+ return { ok: false }
106
+ }
107
+ }
108
+
109
+ function registerMessengerRoute(ctx, getGw, path, action, source) {
94
110
  ctx.effect(() => (ctx.get?.('webServer') || ctx.webServer).register({
95
111
  kind: 'exact', path,
96
112
  handler: async (req, res) => {
97
113
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
114
+ if (!isAuthorizedMessengerRequest(req, source?.()?.webhooks)) {
115
+ return writeJson(res, 403, { ok: false, error: 'forbidden' })
116
+ }
98
117
  let payload
99
118
  try {
100
- payload = parseMessengerBody((await readBody(req)).toString('utf8'))
119
+ payload = parseMessengerBody((await readBody(req, 1_048_576)).toString('utf8'))
101
120
  } catch (err) {
102
121
  return writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
103
122
  }
@@ -133,7 +152,7 @@ export function apply(ctx, config) {
133
152
  if (effective.enabled === false) return
134
153
  gateway = new Gateway(ctx, effective, {
135
154
  persistAllowedUserIds: async (ids) => {
136
- if (!settingsApi) return
155
+ if (!settingsApi?.isReady?.()) return
137
156
  const cur = source()
138
157
  await settingsApi.replace(Config({
139
158
  ...cur,
@@ -141,7 +160,7 @@ export function apply(ctx, config) {
141
160
  }))
142
161
  },
143
162
  persistHome: async ({ chatId, threadId }) => {
144
- if (!settingsApi) throw new Error('settings not ready')
163
+ if (!settingsApi?.isReady?.()) throw new Error('settings not ready')
145
164
  const cur = source()
146
165
  await settingsApi.replace(Config({
147
166
  ...cur,
@@ -149,7 +168,7 @@ export function apply(ctx, config) {
149
168
  }))
150
169
  },
151
170
  persistHomes: async (nextTelegram) => {
152
- if (!settingsApi) throw new Error('settings not ready')
171
+ if (!settingsApi?.isReady?.()) throw new Error('settings not ready')
153
172
  const cur = source()
154
173
  await settingsApi.replace(Config({
155
174
  ...cur,
@@ -157,7 +176,7 @@ export function apply(ctx, config) {
157
176
  }))
158
177
  },
159
178
  persistAgentModel: async ({ provider, model }) => {
160
- if (!settingsApi) return
179
+ if (!settingsApi?.isReady?.()) return
161
180
  const cur = source()
162
181
  await settingsApi.replace(Config({
163
182
  ...cur,
@@ -168,11 +187,28 @@ export function apply(ctx, config) {
168
187
  gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
169
188
  }
170
189
 
171
- const settings = ctx.get?.('settings') || ctx.settings
172
- if (settings?.register) {
173
- settingsApi = settings.register(SETTINGS_NAMESPACE, Config, { base: config || {} })
174
- source = () => resolveConfig(settingsApi.get() ?? config ?? {})
175
- ctx.effect(() => settingsApi.watch(sync), 'dsh-messenger-gateway: settings')
190
+ const settingsBridge = setupSettings(ctx, {
191
+ namespace: SETTINGS_NAMESPACE,
192
+ Config,
193
+ resolveConfig,
194
+ baseConfig: config,
195
+ sync,
196
+ logger: ctx.logger,
197
+ })
198
+ source = () => settingsBridge.getSource()
199
+ settingsApi = {
200
+ get: () => settingsBridge.getSettingsApi()?.get(),
201
+ replace: (next) => {
202
+ const api = settingsBridge.getSettingsApi()
203
+ if (!api) throw new Error('settings not ready')
204
+ return api.replace(next)
205
+ },
206
+ update: (patch) => {
207
+ const api = settingsBridge.getSettingsApi()
208
+ if (!api) throw new Error('settings not ready')
209
+ return api.update(patch)
210
+ },
211
+ isReady: () => Boolean(settingsBridge.getSettingsApi()),
176
212
  }
177
213
 
178
214
  const getGw = () => gateway
@@ -251,7 +287,7 @@ export function apply(ctx, config) {
251
287
  ctx.effect(() => {
252
288
  const onSkillsChange = () => {
253
289
  const gw = getGw()
254
- if (gw) gw.syncTelegramCommands().catch(() => {})
290
+ if (gw) gw.syncTelegramCommands().catch((err) => ctx.logger?.debug?.(`sync telegram commands on skills change: ${err?.message || err}`))
255
291
  }
256
292
  const unbind = ctx.on('skills/change', onSkillsChange)
257
293
  return () => { if (typeof unbind === 'function') unbind() }
@@ -344,14 +380,9 @@ export function apply(ctx, config) {
344
380
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
345
381
  const gw = getGw()
346
382
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
347
- let payload = {}
348
- try {
349
- const raw = (await readBody(req)).toString('utf8').trim()
350
- if (raw) payload = JSON.parse(raw)
351
- } catch {
352
- return writeJson(res, 400, { ok: false, error: 'invalid json' })
353
- }
354
- const timeoutMs = Number(payload.timeoutMs) || 10000
383
+ const parsed = await readJsonSafe(req, res, 65536)
384
+ if (!parsed.ok) return
385
+ const timeoutMs = Number(parsed.data?.timeoutMs) || 10000
355
386
  const result = await gw.probeTelegram(timeoutMs)
356
387
  writeJson(res, result.ok ? 200 : 502, result)
357
388
  },
@@ -371,12 +402,6 @@ export function apply(ctx, config) {
371
402
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
372
403
  const gw = getGw()
373
404
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not ready' })
374
- let payload
375
- try {
376
- payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
377
- } catch {
378
- return writeJson(res, 400, { ok: false, error: 'invalid json' })
379
- }
380
405
 
381
406
  if (source().webhooks?.enabled === false) {
382
407
  return writeJson(res, 404, { ok: false, error: 'webhooks disabled' })
@@ -385,12 +410,28 @@ export function apply(ctx, config) {
385
410
  if (!expectedSecret) {
386
411
  return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
387
412
  }
413
+
414
+ // Check header authentication BEFORE reading the body to avoid unbounded buffering
388
415
  const authHeader = req.headers?.authorization || ''
389
416
  const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
390
417
  const tokenHeader = req.headers?.['x-webhook-secret'] || ''
391
- const provided = bearer || tokenHeader || payload.secret
392
- if (!timingSafeCompare(provided, expectedSecret)) {
393
- return writeJson(res, 401, { ok: false, error: 'unauthorized' })
418
+ const headerProvided = bearer || tokenHeader
419
+
420
+ if (headerProvided) {
421
+ if (!timingSafeCompare(headerProvided, expectedSecret)) {
422
+ return writeJson(res, 401, { ok: false, error: 'unauthorized' })
423
+ }
424
+ }
425
+
426
+ const parsed = await readJsonSafe(req, res, 1_048_576)
427
+ if (!parsed.ok) return
428
+ const payload = parsed.data
429
+
430
+ if (!headerProvided) {
431
+ const bodySecret = String(payload.secret || '').trim()
432
+ if (!timingSafeCompare(bodySecret, expectedSecret)) {
433
+ return writeJson(res, 401, { ok: false, error: 'unauthorized' })
434
+ }
394
435
  }
395
436
 
396
437
  const { target, text, home, chatId, threadId, platform = 'telegram', files } = payload
@@ -414,9 +455,10 @@ export function apply(ctx, config) {
414
455
  if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
415
456
  if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
416
457
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
417
- if (!settingsApi) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
418
- let payload
419
- try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
458
+ if (!settingsApi?.isReady?.()) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
459
+ const parsedConfig = await readJsonSafe(req, res, 1_048_576)
460
+ if (!parsedConfig.ok) return
461
+ let payload = parsedConfig.data
420
462
  if (payload && typeof payload.config === 'object') payload = payload.config
421
463
  try {
422
464
  const cur = source()
@@ -472,9 +514,9 @@ export function apply(ctx, config) {
472
514
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
473
515
  const gw = getGw()
474
516
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
475
- let payload
476
- try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
477
- const resu = await gw.approvePairingCode(payload.code)
517
+ const parsed = await readJsonSafe(req, res, 65536)
518
+ if (!parsed.ok) return
519
+ const resu = await gw.approvePairingCode(parsed.data.code)
478
520
  writeJson(res, resu.ok ? 200 : 400, resu)
479
521
  },
480
522
  }), 'dsh-messenger-gateway: pairing approve')
@@ -486,9 +528,9 @@ export function apply(ctx, config) {
486
528
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
487
529
  const gw = getGw()
488
530
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
489
- let payload
490
- try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
491
- const resu = gw.rejectPairingCode(payload.code)
531
+ const parsed = await readJsonSafe(req, res, 65536)
532
+ if (!parsed.ok) return
533
+ const resu = gw.rejectPairingCode(parsed.data.code)
492
534
  writeJson(res, resu.ok ? 200 : 400, resu)
493
535
  },
494
536
  }), 'dsh-messenger-gateway: pairing reject')
@@ -515,10 +557,9 @@ export function apply(ctx, config) {
515
557
  const gw = getGw()
516
558
  const adapter = gw?.getAdapter?.('telegram')
517
559
  if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
518
- let update
519
- try { update = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch {
520
- return writeJson(res, 400, { ok: false, error: 'invalid json' })
521
- }
560
+ const parsed = await readJsonSafe(req, res, 2_097_152)
561
+ if (!parsed.ok) return
562
+ const update = parsed.data
522
563
  try {
523
564
  await adapter.handleWebhookUpdate(update)
524
565
  writeJson(res, 200, { ok: true })
@@ -528,9 +569,9 @@ export function apply(ctx, config) {
528
569
  },
529
570
  }), 'dsh-messenger-gateway: telegram webhook')
530
571
 
531
- registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send')
532
- registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress')
533
- registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask')
572
+ registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send', source)
573
+ registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress', source)
574
+ registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask', source)
534
575
 
535
576
  const manifestPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json')
536
577
  ctx.effect(() => registerPluginUpdater(ctx, {
@@ -191,6 +191,7 @@ export function resolveAskTimeoutMs(timeoutMs) {
191
191
  }
192
192
 
193
193
  export function httpStatusForError(err) {
194
+ if (err?.statusCode) return err.statusCode
194
195
  if (err?.status) return err.status
195
196
  const msg = String(err?.message || '')
196
197
  if (msg.includes('timed out')) return 504