@goodandready/dsh-messenger-gateway 0.4.8 → 0.4.10

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.
@@ -1,5 +1,6 @@
1
+ import { realpathSync } from 'node:fs'
1
2
  import { readdir, stat, readFile } from 'node:fs/promises'
2
- import { resolve, normalize, relative, join, extname, isAbsolute } from 'node:path'
3
+ import { resolve, normalize, relative, join, extname, isAbsolute, sep, dirname } from 'node:path'
3
4
 
4
5
  export function formatFileSize(bytes) {
5
6
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
@@ -9,6 +10,17 @@ export function formatFileSize(bytes) {
9
10
  return `${val} ${units[i]}`
10
11
  }
11
12
 
13
+ function isSubpath(parent, child) {
14
+ const normParent = normalize(parent)
15
+ const normChild = normalize(child)
16
+ if (process.platform === 'win32') {
17
+ const p = normParent.toLowerCase()
18
+ const c = normChild.toLowerCase()
19
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep)
20
+ }
21
+ return normChild === normParent || normChild.startsWith(normParent.endsWith(sep) ? normParent : normParent + sep)
22
+ }
23
+
12
24
  export function resolveSafePath(baseCwd, userPath = '.') {
13
25
  const base = resolve(normalize(baseCwd || process.cwd()))
14
26
  const cleanUser = String(userPath || '').trim() || '.'
@@ -21,6 +33,46 @@ export function resolveSafePath(baseCwd, userPath = '.') {
21
33
  err.code = 'ERR_PATH_TRAVERSAL'
22
34
  throw err
23
35
  }
36
+
37
+ try {
38
+ const realBase = realpathSync(base)
39
+ try {
40
+ const realTarget = realpathSync(target)
41
+ if (!isSubpath(realBase, realTarget)) {
42
+ const err = new Error('Access denied: symlink resolves out of workspace')
43
+ err.code = 'ERR_PATH_TRAVERSAL'
44
+ throw err
45
+ }
46
+ } catch (targetErr) {
47
+ if (targetErr?.code === 'ERR_PATH_TRAVERSAL') throw targetErr
48
+ if (targetErr?.code === 'ENOENT') {
49
+ let curr = dirname(target)
50
+ while (curr === base || isSubpath(base, curr)) {
51
+ try {
52
+ const realCurr = realpathSync(curr)
53
+ if (!isSubpath(realBase, realCurr)) {
54
+ const err = new Error('Access denied: symlink ancestor resolves out of workspace')
55
+ err.code = 'ERR_PATH_TRAVERSAL'
56
+ throw err
57
+ }
58
+ break
59
+ } catch (ancestorErr) {
60
+ if (ancestorErr?.code === 'ERR_PATH_TRAVERSAL') throw ancestorErr
61
+ if (ancestorErr?.code === 'ENOENT') {
62
+ const parent = dirname(curr)
63
+ if (parent === curr) break
64
+ curr = parent
65
+ continue
66
+ }
67
+ break
68
+ }
69
+ }
70
+ }
71
+ }
72
+ } catch (baseErr) {
73
+ if (baseErr?.code === 'ERR_PATH_TRAVERSAL') throw baseErr
74
+ }
75
+
24
76
  return { base, target, relativePath: rel || '.' }
25
77
  }
26
78
 
package/lib/http.js CHANGED
@@ -36,7 +36,7 @@ export function isLoopback(value) {
36
36
  || address.startsWith('::ffff:127.')
37
37
  }
38
38
 
39
- export function isTrustedSettingsRequest(req) {
39
+ export function isTrustedSettingsRequest(req, expectedSecret) {
40
40
  const remote = req?.socket?.remoteAddress
41
41
  const host = String(req?.headers?.host || '')
42
42
  if (!host) return false
@@ -44,6 +44,21 @@ export function isTrustedSettingsRequest(req) {
44
44
  const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
45
45
  if (site === 'cross-site') return false
46
46
 
47
+ const secret = String(expectedSecret || '').trim()
48
+ if (secret) {
49
+ const authHeader = req?.headers?.authorization || ''
50
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
51
+ const tokenHeader = req?.headers?.['x-webhook-secret'] || ''
52
+ const provided = bearer || tokenHeader
53
+ if (provided && timingSafeCompare(provided, secret)) {
54
+ return true
55
+ }
56
+ }
57
+
58
+ if (remote !== undefined && !isLoopback(remote)) {
59
+ return false
60
+ }
61
+
47
62
  const origin = String(req?.headers?.origin || '')
48
63
  if (origin) {
49
64
  try {
@@ -67,7 +82,6 @@ export function isTrustedSettingsRequest(req) {
67
82
  }
68
83
 
69
84
  if (site === 'same-origin') {
70
- if (remote !== undefined && !isLoopback(remote)) return false
71
85
  return true
72
86
  }
73
87
 
@@ -78,10 +92,9 @@ export function isAuthorizedMessengerRequest(req, webhooksConfig) {
78
92
  const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
79
93
  if (site === 'cross-site') return false
80
94
 
81
- if (isTrustedSettingsRequest(req)) return true
82
- if (isLoopback(req?.socket?.remoteAddress)) return true
83
-
84
95
  const secret = String(webhooksConfig?.secret || '').trim()
96
+ if (isTrustedSettingsRequest(req, secret)) return true
97
+ if (isLoopback(req?.socket?.remoteAddress)) return true
85
98
  if (secret) {
86
99
  const authHeader = req?.headers?.authorization || ''
87
100
  const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
package/lib/index.js CHANGED
@@ -22,7 +22,8 @@ export { Config }
22
22
 
23
23
  function resolveConfig(raw) {
24
24
  // Unwrap once here: everything downstream reads plain values.
25
- const cfg = plainConfig(Config(raw))
25
+ const plain = plainConfig(raw)
26
+ const cfg = plainConfig(Config(plain))
26
27
  const home = process.env.DSH_HOME || join(homedir(), '.dsh')
27
28
  if (!cfg.media.cacheDir) {
28
29
  cfg.media = { ...cfg.media, cacheDir: join(home, 'messenger-gateway', 'cache') }
@@ -141,7 +142,7 @@ function isExcludedSession(sessionId, prefixes) {
141
142
  }
142
143
 
143
144
  export function apply(ctx, config) {
144
- const entry = resolveConfig(structuredClone(config || {}))
145
+ const entry = resolveConfig(structuredClone(plainConfig(config || {})))
145
146
  let gateway
146
147
  let source = () => entry
147
148
  let settingsApi
@@ -379,7 +380,7 @@ export function apply(ctx, config) {
379
380
  kind: 'exact', path: '/dsh-messenger-gateway/smoke',
380
381
  handler: async (req, res) => {
381
382
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
382
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
383
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
383
384
  const gw = getGw()
384
385
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
385
386
  const parsed = await readJsonSafe(req, res, 65536)
@@ -456,7 +457,7 @@ export function apply(ctx, config) {
456
457
  handler: async (req, res) => {
457
458
  if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
458
459
  if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
459
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
460
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
460
461
  if (!settingsApi?.isReady?.()) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
461
462
  const parsedConfig = await readJsonSafe(req, res, 1_048_576)
462
463
  if (!parsedConfig.ok) return
@@ -498,7 +499,7 @@ export function apply(ctx, config) {
498
499
  kind: 'exact', path: '/dsh-messenger-gateway/pairing',
499
500
  handler: async (req, res) => {
500
501
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
501
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
502
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
502
503
  const gw = getGw()
503
504
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
504
505
  writeJson(res, 200, {
@@ -513,7 +514,7 @@ export function apply(ctx, config) {
513
514
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
514
515
  handler: async (req, res) => {
515
516
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
516
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
517
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
517
518
  const gw = getGw()
518
519
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
519
520
  const parsed = await readJsonSafe(req, res, 65536)
@@ -527,7 +528,7 @@ export function apply(ctx, config) {
527
528
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
528
529
  handler: async (req, res) => {
529
530
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
530
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
531
+ if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
531
532
  const gw = getGw()
532
533
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
533
534
  const parsed = await readJsonSafe(req, res, 65536)
@@ -9,11 +9,20 @@ export function getLocaleDictionary(locale = 'en') {
9
9
  return en
10
10
  }
11
11
 
12
- export function t(key, params = {}, locale = 'en') {
12
+ export function escapeHtml(text) {
13
+ return String(text ?? '')
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ }
18
+
19
+ export function t(key, params = {}, locale = 'en', options = {}) {
13
20
  const dict = getLocaleDictionary(locale)
14
21
  let template = dict[key] || en[key] || key
22
+ const escape = options.escape !== false
15
23
  for (const [k, v] of Object.entries(params)) {
16
- template = template.replaceAll(`{${k}}`, String(v))
24
+ const val = escape ? escapeHtml(v) : String(v)
25
+ template = template.replaceAll(`{${k}}`, val)
17
26
  }
18
27
  return template
19
28
  }
@@ -1,6 +1,30 @@
1
1
  export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfig, sync, logger }) {
2
2
  let settingsApi
3
- let source = () => resolveConfig(baseConfig ?? {})
3
+ let currentConfig = resolveConfig(ctx.fiber?.config ?? baseConfig ?? {})
4
+ let source = () => currentConfig
5
+ const watchers = new Set()
6
+ let readLive = () => null
7
+
8
+ const applyLiveConfig = (nextRaw) => {
9
+ const live = nextRaw ?? readLive() ?? ctx.fiber?.config ?? baseConfig
10
+ if (live) {
11
+ currentConfig = resolveConfig(live)
12
+ sync()
13
+ for (const cb of watchers) {
14
+ try { cb(currentConfig) } catch (err) { logger?.debug?.(`${namespace}: watcher error:`, err?.message || err) }
15
+ }
16
+ }
17
+ }
18
+
19
+ const unbinds = []
20
+ if (typeof ctx?.on === 'function') {
21
+ const u1 = ctx.on('loader/volatile-update', () => applyLiveConfig())
22
+ if (typeof u1 === 'function') unbinds.push(u1)
23
+ const u2 = ctx.on('internal/update', (cfg) => applyLiveConfig(cfg))
24
+ if (typeof u2 === 'function') unbinds.push(u2)
25
+ const u3 = ctx.on('app-boot/config-reload', () => applyLiveConfig())
26
+ if (typeof u3 === 'function') unbinds.push(u3)
27
+ }
4
28
 
5
29
  const mountSettings = (sctx) => {
6
30
  const service = sctx?.settings || (typeof sctx?.get === 'function' ? sctx.get('settings') : null)
@@ -26,7 +50,8 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
26
50
  const getRevision = () => {
27
51
  try {
28
52
  return service.describe?.()?.find?.((row) => row.ns === namespace)?.revision
29
- } catch {
53
+ } catch (err) {
54
+ logger?.debug?.(`${namespace}: getRevision failed:`, err?.message || err)
30
55
  return undefined
31
56
  }
32
57
  }
@@ -47,8 +72,9 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
47
72
  return null
48
73
  }
49
74
 
75
+ readLive = readLiveSettings
50
76
  const liveVal = readLiveSettings()
51
- let currentConfig = resolveConfig(liveVal ?? baseConfig ?? {})
77
+ if (liveVal) currentConfig = resolveConfig(liveVal)
52
78
  source = () => currentConfig
53
79
 
54
80
  const persist = async (next) => {
@@ -64,6 +90,9 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
64
90
  }
65
91
  currentConfig = resolveConfig(payload)
66
92
  sync()
93
+ for (const cb of watchers) {
94
+ try { cb(currentConfig) } catch (err) { logger?.debug?.(`${namespace}: watcher error:`, err?.message || err) }
95
+ }
67
96
  return currentConfig
68
97
  }
69
98
 
@@ -72,27 +101,28 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
72
101
  replace: async (next) => persist(next),
73
102
  update: async (patch) => persist({ ...source(), ...patch }),
74
103
  watch: (cb) => {
75
- if (typeof service.watch === 'function') return service.watch(cb)
76
- return () => {}
104
+ if (typeof cb === 'function') watchers.add(cb)
105
+ const unwatchService = typeof service.watch === 'function' ? service.watch(cb) : null
106
+ return () => {
107
+ watchers.delete(cb)
108
+ if (typeof unwatchService === 'function') unwatchService()
109
+ }
77
110
  },
78
111
  }
79
112
 
80
113
  const onFn = typeof sctx?.on === 'function' ? sctx.on.bind(sctx) : (typeof ctx?.on === 'function' ? ctx.on.bind(ctx) : null)
81
- const disposeDocUpdated = onFn
82
- ? onFn('settings/document-updated', (updatedNs) => {
83
- if (updatedNs === namespace) {
84
- const nextVal = readLiveSettings()
85
- if (nextVal) {
86
- currentConfig = resolveConfig(nextVal)
87
- sync()
88
- }
89
- }
90
- })
91
- : null
114
+ if (onFn) {
115
+ const d1 = onFn('settings/document-updated', (updatedNs) => {
116
+ if (updatedNs === namespace) applyLiveConfig()
117
+ })
118
+ if (typeof d1 === 'function') unbinds.push(d1)
119
+ }
92
120
 
93
121
  if (effect) {
94
122
  effect(() => () => {
95
- if (typeof disposeDocUpdated === 'function') disposeDocUpdated()
123
+ for (const unbind of unbinds) {
124
+ try { unbind() } catch (err) { logger?.debug?.(`${namespace}: unbind error:`, err?.message || err) }
125
+ }
96
126
  settingsApi = undefined
97
127
  }, `${namespace}: settings`)
98
128
  }
@@ -7,12 +7,33 @@ export function escapeHtml(text) {
7
7
 
8
8
  const PH = '\uE000'
9
9
 
10
+ const SAFE_SCHEMES = new Set(['https:', 'http:', 'tg:'])
11
+
12
+ export function isSafeUrl(rawUrl) {
13
+ if (!rawUrl || typeof rawUrl !== 'string') return false
14
+ const clean = rawUrl.replace(/&amp;/g, '&').trim()
15
+ try {
16
+ const parsed = new URL(clean)
17
+ return SAFE_SCHEMES.has(parsed.protocol.toLowerCase())
18
+ } catch {
19
+ if (/^tg:\/\//i.test(clean)) return true
20
+ return false
21
+ }
22
+ }
23
+
24
+ export function formatSafeLink(label, url) {
25
+ if (!isSafeUrl(url)) {
26
+ return `${label} (${url})`
27
+ }
28
+ return `<a href="${url.replace(/"/g, '%22')}">${label}</a>`
29
+ }
30
+
10
31
  // Render inline markdown (bold/italic/code/links) inside already-HTML-escaped text.
11
32
  function renderInline(t) {
12
33
  let s = escapeHtml(t)
13
34
  s = s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
14
35
  s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>')
15
- s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${url.replace(/"/g, '%22')}">${label}</a>`)
36
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => formatSafeLink(label, url))
16
37
  s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<i>$2</i>')
17
38
  return s
18
39
  }
@@ -123,7 +144,7 @@ export function markdownToTelegramHtml(markdown) {
123
144
  text = convertDetails(text, stash)
124
145
  text = convertTaskLists(text, stash)
125
146
  text = escapeHtml(text)
126
- text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => `<a href="${url.replace(/"/g, '%22')}">${label}</a>`)
147
+ text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => formatSafeLink(label, url))
127
148
  text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
128
149
  text = text.replace(/__([^_\n]+)__/g, '<b>$1</b>')
129
150
  text = text.replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
5
5
  "license": "MIT",
6
6
  "type": "module",