@goodandready/dsh-messenger-gateway 0.4.9 → 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
@@ -380,7 +380,7 @@ export function apply(ctx, config) {
380
380
  kind: 'exact', path: '/dsh-messenger-gateway/smoke',
381
381
  handler: async (req, res) => {
382
382
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
383
- 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' })
384
384
  const gw = getGw()
385
385
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
386
386
  const parsed = await readJsonSafe(req, res, 65536)
@@ -457,7 +457,7 @@ export function apply(ctx, config) {
457
457
  handler: async (req, res) => {
458
458
  if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
459
459
  if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
460
- 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' })
461
461
  if (!settingsApi?.isReady?.()) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
462
462
  const parsedConfig = await readJsonSafe(req, res, 1_048_576)
463
463
  if (!parsedConfig.ok) return
@@ -499,7 +499,7 @@ export function apply(ctx, config) {
499
499
  kind: 'exact', path: '/dsh-messenger-gateway/pairing',
500
500
  handler: async (req, res) => {
501
501
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
502
- 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' })
503
503
  const gw = getGw()
504
504
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
505
505
  writeJson(res, 200, {
@@ -514,7 +514,7 @@ export function apply(ctx, config) {
514
514
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
515
515
  handler: async (req, res) => {
516
516
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
517
- 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' })
518
518
  const gw = getGw()
519
519
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
520
520
  const parsed = await readJsonSafe(req, res, 65536)
@@ -528,7 +528,7 @@ export function apply(ctx, config) {
528
528
  kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
529
529
  handler: async (req, res) => {
530
530
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
531
- 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' })
532
532
  const gw = getGw()
533
533
  if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
534
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
  }
File without changes
@@ -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.9",
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",