@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.
- package/lib/file-manager.js +53 -1
- package/lib/http.js +18 -5
- package/lib/index.js +5 -5
- package/lib/locales/index.js +11 -2
- package/lib/telegram-errors.js +0 -0
- package/lib/telegram-format.js +23 -2
- package/package.json +1 -1
package/lib/file-manager.js
CHANGED
|
@@ -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)
|
package/lib/locales/index.js
CHANGED
|
@@ -9,11 +9,20 @@ export function getLocaleDictionary(locale = 'en') {
|
|
|
9
9
|
return en
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function
|
|
12
|
+
export function escapeHtml(text) {
|
|
13
|
+
return String(text ?? '')
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
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
|
-
|
|
24
|
+
const val = escape ? escapeHtml(v) : String(v)
|
|
25
|
+
template = template.replaceAll(`{${k}}`, val)
|
|
17
26
|
}
|
|
18
27
|
return template
|
|
19
28
|
}
|
package/lib/telegram-errors.js
CHANGED
|
File without changes
|
package/lib/telegram-format.js
CHANGED
|
@@ -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(/&/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) =>
|
|
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) =>
|
|
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.
|
|
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",
|