switchroom 0.21.11 → 0.21.13
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/dist/agent-scheduler/index.js +23 -0
- package/dist/auth-broker/index.js +24 -1
- package/dist/cli/notion-write-pretool.mjs +23 -0
- package/dist/cli/switchroom.js +3221 -1755
- package/dist/host-control/main.js +25 -2
- package/dist/vault/approvals/kernel-server.js +24 -1
- package/dist/vault/broker/server.js +24 -1
- package/package.json +3 -1
- package/skills/dev-protocol/SKILL.md +14 -10
- package/skills/switchroom-architecture/SKILL.md +11 -7
- package/skills/switchroom-architecture/cascade.md +29 -10
- package/skills/switchroom-architecture/sub-agents.md +27 -26
- package/skills/switchroom-architecture/telegram.md +44 -17
- package/skills/switchroom-release/SKILL.md +11 -5
- package/skills/switchroom-status/SKILL.md +17 -18
- package/skills/switchroom-status/scripts/status.sh +8 -17
- package/skills/telegram-test-harness/SKILL.md +6 -5
- package/telegram-plugin/dist/gateway/gateway.js +133 -5
- package/telegram-plugin/gateway/gateway.ts +2 -0
- package/telegram-plugin/shared/utf8-sanitize.ts +269 -0
- package/telegram-plugin/tests/utf8-sanitize-wire.test.ts +588 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level OUTCOME tests for the UTF-8 sanitiser (#4728).
|
|
3
|
+
*
|
|
4
|
+
* These reproduce the production drop, they do not merely exercise the
|
|
5
|
+
* helper. The stubbed transport below is a FAITHFUL stand-in for Telegram's
|
|
6
|
+
* decoder: it parses the serialized request body and answers
|
|
7
|
+
* `400 Bad Request: strings must be encoded in UTF-8` whenever any string in
|
|
8
|
+
* it contains an unpaired surrogate — which is exactly what the real server
|
|
9
|
+
* did to `gymbro`'s `permission_request` card on 2026-07-31 (twice), and why
|
|
10
|
+
* the operator never saw that approval card.
|
|
11
|
+
*
|
|
12
|
+
* `JSON.stringify` does NOT throw on a lone surrogate (well-formed
|
|
13
|
+
* `JSON.stringify`, ES2019) — it emits the `\udXXX` escape — so the bad body
|
|
14
|
+
* really does reach the wire, and the failure really is server-side.
|
|
15
|
+
*
|
|
16
|
+
* Delete `installUtf8Sanitizer(bot)` from `makeTelegramLikeBot` (or from
|
|
17
|
+
* `initGatewayBot`) and every `(a)`-`(d)` case below goes RED with the
|
|
18
|
+
* production GrammyError, because the send REJECTS instead of delivering.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect } from 'vitest'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { dirname, resolve } from 'node:path'
|
|
24
|
+
import { Bot, GrammyError } from 'grammy'
|
|
25
|
+
import { installTgPostLogger, installRichMarkdownGuard } from '../shared/bot-runtime.js'
|
|
26
|
+
import {
|
|
27
|
+
installUtf8Sanitizer,
|
|
28
|
+
sanitizeLoneSurrogates,
|
|
29
|
+
sanitizePayloadStrings,
|
|
30
|
+
hasLoneSurrogate,
|
|
31
|
+
} from '../shared/utf8-sanitize.js'
|
|
32
|
+
|
|
33
|
+
/** An unpaired HIGH surrogate — no valid UTF-8 encoding exists for it. */
|
|
34
|
+
const LONE_HIGH = '\uD800'
|
|
35
|
+
/** An unpaired LOW surrogate — the half every truncation site in the plugin
|
|
36
|
+
* fails to repair (they all only strip a TRAILING high surrogate). */
|
|
37
|
+
const LONE_LOW = '\uDC00'
|
|
38
|
+
/** A well-formed astral pair (🤖). Must survive untouched. */
|
|
39
|
+
const ASTRAL = '\u{1F916}'
|
|
40
|
+
|
|
41
|
+
function anyLoneSurrogate(v: unknown): boolean {
|
|
42
|
+
if (typeof v === 'string') return hasLoneSurrogate(v)
|
|
43
|
+
if (Array.isArray(v)) return v.some(anyLoneSurrogate)
|
|
44
|
+
if (v !== null && typeof v === 'object') return Object.values(v).some(anyLoneSurrogate)
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CapturedCall {
|
|
49
|
+
method: string
|
|
50
|
+
body: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A real grammy Bot wired with the production transformer stack, whose
|
|
55
|
+
* transport rejects a non-UTF-8-encodable body the way Telegram does.
|
|
56
|
+
*/
|
|
57
|
+
function makeTelegramLikeBot(now?: () => number): { bot: Bot; calls: CapturedCall[] } {
|
|
58
|
+
const calls: CapturedCall[] = []
|
|
59
|
+
const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
|
|
60
|
+
const method = String(url).split('/').pop() ?? ''
|
|
61
|
+
let body: Record<string, unknown> = {}
|
|
62
|
+
if (typeof init?.body === 'string') {
|
|
63
|
+
body = JSON.parse(init.body) as Record<string, unknown>
|
|
64
|
+
}
|
|
65
|
+
calls.push({ method, body })
|
|
66
|
+
if (anyLoneSurrogate(body)) {
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
status: 200,
|
|
70
|
+
json: async () => ({
|
|
71
|
+
ok: false,
|
|
72
|
+
error_code: 400,
|
|
73
|
+
description: 'Bad Request: strings must be encoded in UTF-8',
|
|
74
|
+
}),
|
|
75
|
+
} as unknown as Response
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
status: 200,
|
|
80
|
+
json: async () => ({
|
|
81
|
+
ok: true,
|
|
82
|
+
result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' } },
|
|
83
|
+
}),
|
|
84
|
+
} as unknown as Response
|
|
85
|
+
}) as unknown as typeof fetch
|
|
86
|
+
|
|
87
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
88
|
+
botInfo: {
|
|
89
|
+
id: 123456,
|
|
90
|
+
is_bot: true,
|
|
91
|
+
first_name: 'Test',
|
|
92
|
+
username: 'test_bot',
|
|
93
|
+
can_join_groups: false,
|
|
94
|
+
can_read_all_group_messages: false,
|
|
95
|
+
supports_inline_queries: false,
|
|
96
|
+
can_connect_to_business: false,
|
|
97
|
+
has_main_web_app: false,
|
|
98
|
+
},
|
|
99
|
+
client: { fetch: fakeFetch },
|
|
100
|
+
})
|
|
101
|
+
// Production ordering (initGatewayBot): sanitiser FIRST so it composes
|
|
102
|
+
// INNERMOST and is the last transformer to touch the payload.
|
|
103
|
+
installUtf8Sanitizer(bot, now)
|
|
104
|
+
installTgPostLogger(bot)
|
|
105
|
+
installRichMarkdownGuard(bot)
|
|
106
|
+
return { bot, calls }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function lastBody(calls: CapturedCall[]): Record<string, unknown> {
|
|
110
|
+
return calls[calls.length - 1].body
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('UTF-8 sanitiser — the dropped approval card (#4728)', () => {
|
|
114
|
+
it('(a) an approval-card body with a lone surrogate DELIVERS instead of 400ing', async () => {
|
|
115
|
+
const { bot, calls } = makeTelegramLikeBot()
|
|
116
|
+
// Shape of a real permission card: rich markdown + the Approve/Deny row.
|
|
117
|
+
const sent = await bot.api.sendRichMessage(
|
|
118
|
+
1,
|
|
119
|
+
{ markdown: `**Approve?**\n\`Bash\` — rm -rf ${LONE_HIGH}tmp` },
|
|
120
|
+
{
|
|
121
|
+
reply_markup: {
|
|
122
|
+
inline_keyboard: [[{ text: 'Approve', callback_data: 'perm:allow:1' }]],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
expect(sent.message_id).toBe(1)
|
|
127
|
+
expect(calls[calls.length - 1].method).toBe('sendRichMessage')
|
|
128
|
+
const markdown = (lastBody(calls).rich_message as { markdown: string }).markdown
|
|
129
|
+
expect(markdown).toBe('**Approve?**\n`Bash` — rm -rf �tmp')
|
|
130
|
+
expect(hasLoneSurrogate(markdown)).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('(b) an orphaned LOW surrogate — the half no truncation site repairs — also delivers', async () => {
|
|
134
|
+
const { bot, calls } = makeTelegramLikeBot()
|
|
135
|
+
const sent = await bot.api.sendRichMessage(1, { markdown: `${LONE_LOW} continued` })
|
|
136
|
+
expect(sent.message_id).toBe(1)
|
|
137
|
+
expect((lastBody(calls).rich_message as { markdown: string }).markdown).toBe('� continued')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('(c) a lone surrogate in an inline-keyboard LABEL delivers (whole payload, not one field)', async () => {
|
|
141
|
+
const { bot, calls } = makeTelegramLikeBot()
|
|
142
|
+
const sent = await bot.api.sendRichMessage(
|
|
143
|
+
1,
|
|
144
|
+
{ markdown: 'clean body' },
|
|
145
|
+
{
|
|
146
|
+
reply_markup: {
|
|
147
|
+
inline_keyboard: [[{ text: `Always allow ${LONE_HIGH}`, callback_data: 'perm:always:1' }]],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
expect(sent.message_id).toBe(1)
|
|
152
|
+
const kb = (lastBody(calls).reply_markup as {
|
|
153
|
+
inline_keyboard: { text: string }[][]
|
|
154
|
+
}).inline_keyboard
|
|
155
|
+
expect(kb[0][0].text).toBe('Always allow �')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('(d) an ORDINARY sendMessage with a lone surrogate delivers too', async () => {
|
|
159
|
+
const { bot, calls } = makeTelegramLikeBot()
|
|
160
|
+
const sent = await bot.api.sendMessage(1, `answer${LONE_HIGH}`)
|
|
161
|
+
expect(sent.message_id).toBe(1)
|
|
162
|
+
expect(lastBody(calls).text).toBe('answer�')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('(e) proves the harness is honest: an UNSANITISED bot really does 400', async () => {
|
|
166
|
+
// Same transport, sanitiser NOT installed — the pre-fix production path.
|
|
167
|
+
// Without this case, (a)-(d) could pass against a permissive stub.
|
|
168
|
+
const calls: CapturedCall[] = []
|
|
169
|
+
const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
|
|
170
|
+
const body = typeof init?.body === 'string'
|
|
171
|
+
? (JSON.parse(init.body) as Record<string, unknown>)
|
|
172
|
+
: {}
|
|
173
|
+
calls.push({ method: String(url).split('/').pop() ?? '', body })
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
status: 200,
|
|
177
|
+
json: async () => (anyLoneSurrogate(body)
|
|
178
|
+
? { ok: false, error_code: 400, description: 'Bad Request: strings must be encoded in UTF-8' }
|
|
179
|
+
: { ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' } } }),
|
|
180
|
+
} as unknown as Response
|
|
181
|
+
}) as unknown as typeof fetch
|
|
182
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
183
|
+
botInfo: {
|
|
184
|
+
id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
|
|
185
|
+
can_join_groups: false, can_read_all_group_messages: false,
|
|
186
|
+
supports_inline_queries: false, can_connect_to_business: false,
|
|
187
|
+
has_main_web_app: false,
|
|
188
|
+
},
|
|
189
|
+
client: { fetch: fakeFetch },
|
|
190
|
+
})
|
|
191
|
+
installTgPostLogger(bot)
|
|
192
|
+
installRichMarkdownGuard(bot)
|
|
193
|
+
await expect(
|
|
194
|
+
bot.api.sendRichMessage(1, { markdown: `**Approve?** ${LONE_HIGH}` }),
|
|
195
|
+
).rejects.toThrow(GrammyError)
|
|
196
|
+
await expect(
|
|
197
|
+
bot.api.sendRichMessage(1, { markdown: `**Approve?** ${LONE_HIGH}` }),
|
|
198
|
+
).rejects.toThrow(/strings must be encoded in UTF-8/)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('(f) a clean body is byte-identical and the payload object is not cloned', async () => {
|
|
202
|
+
const { bot, calls } = makeTelegramLikeBot()
|
|
203
|
+
const sent = await bot.api.sendRichMessage(1, { markdown: `all good ${ASTRAL} 100% ✓` })
|
|
204
|
+
expect(sent.message_id).toBe(1)
|
|
205
|
+
expect((lastBody(calls).rich_message as { markdown: string }).markdown)
|
|
206
|
+
.toBe(`all good ${ASTRAL} 100% ✓`)
|
|
207
|
+
// Identity no-op is the contract the transformer relies on to stay free.
|
|
208
|
+
const payload = { chat_id: 1, text: 'clean' }
|
|
209
|
+
expect(sanitizePayloadStrings(payload)).toBe(payload)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('(f2) a clean payload is not rebuilt at ANY depth — no container allocated, no property defined', () => {
|
|
213
|
+
// (f) only pins the identity of the TOP-level return, which a walk that
|
|
214
|
+
// clones every nested container and discards the clones still satisfies.
|
|
215
|
+
// This transformer is the innermost hop of every `bot.api.*` call and the
|
|
216
|
+
// draft-stream `editMessageText` path runs it several times a second, so
|
|
217
|
+
// "the clean case rebuilds nothing" is a real contract, not a comment.
|
|
218
|
+
const button = { text: `Approve ${ASTRAL}`, callback_data: 'perm:allow:1' }
|
|
219
|
+
const row = [button]
|
|
220
|
+
const keyboard = [row]
|
|
221
|
+
const markup = { inline_keyboard: keyboard }
|
|
222
|
+
const payload = { chat_id: 1, text: `all good ${ASTRAL} 100% ✓`, reply_markup: markup }
|
|
223
|
+
|
|
224
|
+
const realDefineProperty = Object.defineProperty
|
|
225
|
+
let definePropertyCalls = 0
|
|
226
|
+
Object.defineProperty = ((...args: Parameters<typeof Object.defineProperty>) => {
|
|
227
|
+
definePropertyCalls++
|
|
228
|
+
return realDefineProperty(...args)
|
|
229
|
+
}) as typeof Object.defineProperty
|
|
230
|
+
let out: typeof payload
|
|
231
|
+
try {
|
|
232
|
+
out = sanitizePayloadStrings(payload)
|
|
233
|
+
} finally {
|
|
234
|
+
Object.defineProperty = realDefineProperty
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Goes RED on an eager clone: the old walk defined one property per key
|
|
238
|
+
// at every level before deciding nothing had changed.
|
|
239
|
+
expect(definePropertyCalls).toBe(0)
|
|
240
|
+
// Every container comes back by identity, top level down to the button.
|
|
241
|
+
expect(out).toBe(payload)
|
|
242
|
+
expect(out.reply_markup).toBe(markup)
|
|
243
|
+
expect(out.reply_markup.inline_keyboard).toBe(keyboard)
|
|
244
|
+
expect(out.reply_markup.inline_keyboard[0]).toBe(row)
|
|
245
|
+
expect(out.reply_markup.inline_keyboard[0][0]).toBe(button)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('(f3) a DIRTY payload still rebuilds correctly from the lazily-materialised clone', () => {
|
|
249
|
+
// The lazy clone backfills the already-walked (clean) prefix. If that
|
|
250
|
+
// backfill were wrong, keys before the first repair would vanish.
|
|
251
|
+
const payload = {
|
|
252
|
+
chat_id: 1,
|
|
253
|
+
before_a: 'kept a',
|
|
254
|
+
before_b: 'kept b',
|
|
255
|
+
text: `boom${LONE_HIGH}`,
|
|
256
|
+
after: 'kept c',
|
|
257
|
+
nested: { keep: 'yes', bad: `${LONE_LOW}x` },
|
|
258
|
+
arr: ['keep 0', 'keep 1', `bad${LONE_HIGH}`, 'keep 3'],
|
|
259
|
+
}
|
|
260
|
+
const out = sanitizePayloadStrings(payload)
|
|
261
|
+
expect(out).not.toBe(payload)
|
|
262
|
+
expect(Object.keys(out)).toEqual(Object.keys(payload))
|
|
263
|
+
expect(out.before_a).toBe('kept a')
|
|
264
|
+
expect(out.before_b).toBe('kept b')
|
|
265
|
+
expect(out.text).toBe('boom�')
|
|
266
|
+
expect(out.after).toBe('kept c')
|
|
267
|
+
expect(out.nested).toEqual({ keep: 'yes', bad: '�x' })
|
|
268
|
+
expect(out.arr).toEqual(['keep 0', 'keep 1', 'bad�', 'keep 3'])
|
|
269
|
+
// Clone-on-write: the input itself is untouched.
|
|
270
|
+
expect(payload.text).toBe(`boom${LONE_HIGH}`)
|
|
271
|
+
expect(payload.arr[2]).toBe(`bad${LONE_HIGH}`)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('(g) a payload whose own enumerable getter THROWS still sends — the sanitiser fails open', async () => {
|
|
275
|
+
// The walk reads own enumerable properties, which invokes getters. This
|
|
276
|
+
// transformer sits on the innermost hop of EVERY `bot.api.*` call, so an
|
|
277
|
+
// exception escaping it would wedge the whole gateway, not one send.
|
|
278
|
+
//
|
|
279
|
+
// Ordering here is deliberately inverted relative to production (the
|
|
280
|
+
// recorder is installed first, so it is INNERMOST and answers without
|
|
281
|
+
// serialising) purely to isolate what the sanitiser hands downstream.
|
|
282
|
+
// Production ordering is pinned by the boot-wiring describe below.
|
|
283
|
+
const seen: unknown[] = []
|
|
284
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
285
|
+
botInfo: {
|
|
286
|
+
id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
|
|
287
|
+
can_join_groups: false, can_read_all_group_messages: false,
|
|
288
|
+
supports_inline_queries: false, can_connect_to_business: false,
|
|
289
|
+
has_main_web_app: false,
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
bot.api.config.use(async (_prev, _method, payload) => {
|
|
293
|
+
seen.push(payload)
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
result: { message_id: 7, date: 0, chat: { id: 1, type: 'private' } },
|
|
297
|
+
} as never
|
|
298
|
+
})
|
|
299
|
+
installUtf8Sanitizer(bot)
|
|
300
|
+
|
|
301
|
+
const payload: Record<string, unknown> = { chat_id: 1 }
|
|
302
|
+
Object.defineProperty(payload, 'text', {
|
|
303
|
+
enumerable: true,
|
|
304
|
+
configurable: true,
|
|
305
|
+
get() {
|
|
306
|
+
throw new Error('getter exploded')
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// Load-bearing: without the guard in `installUtf8Sanitizer` this is the
|
|
311
|
+
// exception that would escape into every outbound call.
|
|
312
|
+
expect(() => sanitizePayloadStrings(payload)).toThrow('getter exploded')
|
|
313
|
+
|
|
314
|
+
const sent = await bot.api.raw.sendMessage(payload as never)
|
|
315
|
+
expect(sent.message_id).toBe(7)
|
|
316
|
+
// Failed open: the ORIGINAL payload was passed through, not swallowed.
|
|
317
|
+
expect(seen).toHaveLength(1)
|
|
318
|
+
expect(seen[0]).toBe(payload)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('sanitizeLoneSurrogates / sanitizePayloadStrings — unit contract', () => {
|
|
323
|
+
it('preserves well-formed pairs, including adjacent ones', () => {
|
|
324
|
+
const s = `${ASTRAL}${ASTRAL}ok`
|
|
325
|
+
expect(sanitizeLoneSurrogates(s)).toBe(s)
|
|
326
|
+
expect(hasLoneSurrogate(s)).toBe(false)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('repairs both halves and is length-preserving', () => {
|
|
330
|
+
expect(sanitizeLoneSurrogates(`a${LONE_HIGH}b`)).toBe('a�b')
|
|
331
|
+
expect(sanitizeLoneSurrogates(`a${LONE_LOW}b`)).toBe('a�b')
|
|
332
|
+
expect(sanitizeLoneSurrogates(`a${LONE_HIGH}b`).length).toBe(3)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('repairs a high surrogate followed by another high surrogate (both lone)', () => {
|
|
336
|
+
expect(sanitizeLoneSurrogates(`${LONE_HIGH}${LONE_HIGH}`)).toBe('��')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('is idempotent', () => {
|
|
340
|
+
const once = sanitizeLoneSurrogates(`x${LONE_HIGH}y`)
|
|
341
|
+
expect(sanitizeLoneSurrogates(once)).toBe(once)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('is stateless across calls (the shared /g regex must not carry lastIndex)', () => {
|
|
345
|
+
for (let i = 0; i < 4; i++) {
|
|
346
|
+
expect(hasLoneSurrogate(`ab${LONE_HIGH}`)).toBe(true)
|
|
347
|
+
expect(sanitizeLoneSurrogates(`ab${LONE_HIGH}`)).toBe('ab�')
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('does not rebuild non-plain objects (an InputFile-like instance survives by identity)', () => {
|
|
352
|
+
class InputFileLike {
|
|
353
|
+
constructor(public filename: string) {}
|
|
354
|
+
}
|
|
355
|
+
const file = new InputFileLike('photo.png')
|
|
356
|
+
const payload = { chat_id: 1, photo: file, caption: `hi${LONE_HIGH}` }
|
|
357
|
+
const out = sanitizePayloadStrings(payload)
|
|
358
|
+
expect(out).not.toBe(payload)
|
|
359
|
+
expect(out.caption).toBe('hi�')
|
|
360
|
+
expect(out.photo).toBe(file)
|
|
361
|
+
expect(out.photo).toBeInstanceOf(InputFileLike)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('does not copy INHERITED enumerable keys into the clone (prototype pollution)', () => {
|
|
365
|
+
// The walk uses `for...in`, which unlike `Object.entries` also yields
|
|
366
|
+
// inherited enumerable keys. Without the own-property guard a polluted
|
|
367
|
+
// `Object.prototype` would add a field to the body sent to Telegram.
|
|
368
|
+
const proto = Object.prototype as unknown as Record<string, unknown>
|
|
369
|
+
Object.defineProperty(proto, '__polluted__', {
|
|
370
|
+
value: `evil${LONE_HIGH}`,
|
|
371
|
+
writable: true,
|
|
372
|
+
enumerable: true,
|
|
373
|
+
configurable: true,
|
|
374
|
+
})
|
|
375
|
+
try {
|
|
376
|
+
const payload = { chat_id: 1, text: `hi${LONE_HIGH}` }
|
|
377
|
+
const out = sanitizePayloadStrings(payload)
|
|
378
|
+
expect(out.text).toBe('hi�')
|
|
379
|
+
expect(Object.prototype.hasOwnProperty.call(out, '__polluted__')).toBe(false)
|
|
380
|
+
expect(Object.keys(out)).toEqual(['chat_id', 'text'])
|
|
381
|
+
expect(JSON.stringify(out)).toBe('{"chat_id":1,"text":"hi�"}')
|
|
382
|
+
} finally {
|
|
383
|
+
delete proto.__polluted__
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('leaves non-string scalars alone', () => {
|
|
388
|
+
const payload = { chat_id: 1, disable_notification: true, x: null, y: undefined }
|
|
389
|
+
expect(sanitizePayloadStrings(payload)).toBe(payload)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('handles a null/undefined payload without throwing (grammy passes those)', () => {
|
|
393
|
+
expect(sanitizePayloadStrings(undefined)).toBe(undefined)
|
|
394
|
+
expect(sanitizePayloadStrings(null)).toBe(null)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('keeps an own `__proto__` key as an own property of the clone', () => {
|
|
398
|
+
// `out['__proto__'] = v` hits the Object.prototype accessor and reassigns
|
|
399
|
+
// the clone's PROTOTYPE instead of creating an own property — the key
|
|
400
|
+
// would vanish from the payload entirely.
|
|
401
|
+
const payload: Record<string, unknown> = { chat_id: 1, text: `hi${LONE_HIGH}` }
|
|
402
|
+
Object.defineProperty(payload, '__proto__', {
|
|
403
|
+
value: `p${LONE_LOW}`,
|
|
404
|
+
writable: true,
|
|
405
|
+
enumerable: true,
|
|
406
|
+
configurable: true,
|
|
407
|
+
})
|
|
408
|
+
const out = sanitizePayloadStrings(payload)
|
|
409
|
+
expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true)
|
|
410
|
+
expect(Object.getOwnPropertyDescriptor(out, '__proto__')?.value).toBe('p�')
|
|
411
|
+
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
|
|
412
|
+
// And it survives serialisation, which is the only thing the wire sees.
|
|
413
|
+
expect(JSON.stringify(out)).toContain('"__proto__":"p�"')
|
|
414
|
+
expect(out.text).toBe('hi�')
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
describe('repair logging: real count, throttled per method', () => {
|
|
419
|
+
/**
|
|
420
|
+
* Swap `process.stderr.write` directly rather than through `vi`: this file
|
|
421
|
+
* runs under BOTH vitest and `bun test`, and the mock/timer halves of `vi`
|
|
422
|
+
* are not equivalent across the two.
|
|
423
|
+
*/
|
|
424
|
+
async function captureSanitizeLog(fn: (lines: string[]) => Promise<void>): Promise<void> {
|
|
425
|
+
const lines: string[] = []
|
|
426
|
+
const real = process.stderr.write.bind(process.stderr)
|
|
427
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
428
|
+
const s = String(chunk)
|
|
429
|
+
if (s.includes('utf8-sanitize')) lines.push(s)
|
|
430
|
+
return true
|
|
431
|
+
}) as typeof process.stderr.write
|
|
432
|
+
try {
|
|
433
|
+
await fn(lines)
|
|
434
|
+
} finally {
|
|
435
|
+
process.stderr.write = real
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
it('reports how many code units were repaired, and logs once per method per minute', async () => {
|
|
440
|
+
let clock = 1_000_000
|
|
441
|
+
await captureSanitizeLog(async lines => {
|
|
442
|
+
const { bot } = makeTelegramLikeBot(() => clock)
|
|
443
|
+
|
|
444
|
+
// Two lone surrogates in one body: a high with no follower, a low with
|
|
445
|
+
// no leader. The docblock promises a count, so the line must carry one.
|
|
446
|
+
await bot.api.sendMessage(1, `a${LONE_HIGH}b${LONE_LOW}`)
|
|
447
|
+
expect(lines).toHaveLength(1)
|
|
448
|
+
expect(lines[0]).toContain('repaired 2 lone surrogate(s)')
|
|
449
|
+
expect(lines[0]).toContain('method=sendMessage')
|
|
450
|
+
// Never the content itself.
|
|
451
|
+
expect(lines[0]).not.toContain('a�b')
|
|
452
|
+
|
|
453
|
+
// Same method inside the window — a persistently corrupt draft stream
|
|
454
|
+
// must not emit one line per edit.
|
|
455
|
+
clock += 500
|
|
456
|
+
await bot.api.sendMessage(1, `c${LONE_HIGH}`)
|
|
457
|
+
clock += 500
|
|
458
|
+
await bot.api.sendMessage(1, `d${LONE_HIGH}`)
|
|
459
|
+
expect(lines).toHaveLength(1)
|
|
460
|
+
|
|
461
|
+
// A different method has its own budget.
|
|
462
|
+
await bot.api.editMessageText(1, 1, `e${LONE_HIGH}`)
|
|
463
|
+
expect(lines).toHaveLength(2)
|
|
464
|
+
expect(lines[1]).toContain('method=editMessageText')
|
|
465
|
+
expect(lines[1]).toContain('repaired 1 lone surrogate(s)')
|
|
466
|
+
|
|
467
|
+
// Next window: the corruption is still diagnosable.
|
|
468
|
+
clock += 61_000
|
|
469
|
+
await bot.api.sendMessage(1, `f${LONE_HIGH}`)
|
|
470
|
+
expect(lines).toHaveLength(3)
|
|
471
|
+
expect(lines[2]).toContain('method=sendMessage')
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('failing open is LOGGED, not silent, and names the method', async () => {
|
|
476
|
+
// Fail-open is correct (see the docblock) but a silent fail-open turns a
|
|
477
|
+
// future walk bug into a bare Telegram 400 with no trail back here — the
|
|
478
|
+
// exact opacity #4728 exists to end. Goes RED on a bare `catch {}`.
|
|
479
|
+
let clock = 2_000_000
|
|
480
|
+
await captureSanitizeLog(async lines => {
|
|
481
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
482
|
+
botInfo: {
|
|
483
|
+
id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
|
|
484
|
+
can_join_groups: false, can_read_all_group_messages: false,
|
|
485
|
+
supports_inline_queries: false, can_connect_to_business: false,
|
|
486
|
+
has_main_web_app: false,
|
|
487
|
+
},
|
|
488
|
+
})
|
|
489
|
+
bot.api.config.use(async () => ({
|
|
490
|
+
ok: true,
|
|
491
|
+
result: { message_id: 7, date: 0, chat: { id: 1, type: 'private' } },
|
|
492
|
+
} as never))
|
|
493
|
+
installUtf8Sanitizer(bot, () => clock)
|
|
494
|
+
|
|
495
|
+
const explode = (): Record<string, unknown> => {
|
|
496
|
+
const p: Record<string, unknown> = { chat_id: 1 }
|
|
497
|
+
Object.defineProperty(p, 'text', {
|
|
498
|
+
enumerable: true,
|
|
499
|
+
configurable: true,
|
|
500
|
+
get() { throw new Error('getter exploded') },
|
|
501
|
+
})
|
|
502
|
+
return p
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Still sends (fail-open), and says so.
|
|
506
|
+
const sent = await bot.api.raw.sendMessage(explode() as never)
|
|
507
|
+
expect(sent.message_id).toBe(7)
|
|
508
|
+
expect(lines).toHaveLength(1)
|
|
509
|
+
expect(lines[0]).toContain('FAILED OPEN')
|
|
510
|
+
expect(lines[0]).toContain('method=sendMessage')
|
|
511
|
+
expect(lines[0]).toContain('getter exploded')
|
|
512
|
+
|
|
513
|
+
// Throttled on its own budget, like the repair line.
|
|
514
|
+
clock += 500
|
|
515
|
+
await bot.api.raw.sendMessage(explode() as never)
|
|
516
|
+
expect(lines).toHaveLength(1)
|
|
517
|
+
|
|
518
|
+
clock += 61_000
|
|
519
|
+
await bot.api.raw.sendMessage(explode() as never)
|
|
520
|
+
expect(lines).toHaveLength(2)
|
|
521
|
+
expect(lines[1]).toContain('FAILED OPEN')
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('logs nothing for a clean body', async () => {
|
|
526
|
+
await captureSanitizeLog(async lines => {
|
|
527
|
+
const { bot } = makeTelegramLikeBot()
|
|
528
|
+
await bot.api.sendMessage(1, `all good ${ASTRAL}`)
|
|
529
|
+
expect(lines).toHaveLength(0)
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
describe('boot wiring: the sanitiser is the INNERMOST transformer', () => {
|
|
535
|
+
const src = readFileSync(
|
|
536
|
+
resolve(dirname(fileURLToPath(import.meta.url)), '..', 'gateway', 'gateway.ts'),
|
|
537
|
+
'utf8',
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
it('installs it exactly once', () => {
|
|
541
|
+
const hits = src.match(/installUtf8Sanitizer\(bot\)/g) ?? []
|
|
542
|
+
expect(hits.length).toBe(1)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('there is exactly ONE bot instance for that single install to cover', () => {
|
|
546
|
+
// The ordering assertion below is textual. A second `new Bot(` in
|
|
547
|
+
// gateway.ts would get no sanitiser at all while every test here stayed
|
|
548
|
+
// green, so pin the instance count too.
|
|
549
|
+
expect((src.match(/new Bot\(/g) ?? []).length).toBe(1)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('installs it BEFORE every other transformer, so it composes innermost', () => {
|
|
553
|
+
// grammy: `call = trans(prev, ...)` — last installed is outermost, first
|
|
554
|
+
// installed is innermost. Innermost is the only position that guarantees
|
|
555
|
+
// no later transformer can reintroduce a lone surrogate behind our back.
|
|
556
|
+
const sanitizer = src.indexOf('installUtf8Sanitizer(bot)')
|
|
557
|
+
expect(sanitizer).toBeGreaterThan(-1)
|
|
558
|
+
for (const later of [
|
|
559
|
+
'installTgPostLogger(bot)',
|
|
560
|
+
'installRichMarkdownGuard(bot)',
|
|
561
|
+
'installSentTextCapture(bot)',
|
|
562
|
+
'installEditFloodFuse(bot',
|
|
563
|
+
]) {
|
|
564
|
+
expect(src.indexOf(later), later).toBeGreaterThan(sanitizer)
|
|
565
|
+
}
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('nothing at all is installed between `new Bot(` and the sanitiser', () => {
|
|
569
|
+
// The named-installer check above is one-sided: it pins four KNOWN
|
|
570
|
+
// installers as later, but someone adding `installFoo(bot)` between
|
|
571
|
+
// `new Bot(TOKEN)` and `installUtf8Sanitizer(bot)` composes INNER of the
|
|
572
|
+
// sanitiser, sends unsanitised, and leaves every test in this file green.
|
|
573
|
+
// So pin it from the other side: the sanitiser must be the FIRST thing
|
|
574
|
+
// wired onto the bot, full stop.
|
|
575
|
+
const botIdx = src.search(/new Bot\(/)
|
|
576
|
+
expect(botIdx).toBeGreaterThan(-1)
|
|
577
|
+
|
|
578
|
+
const WIRING = /install[A-Za-z0-9_]*\(\s*bot\b|\.api\.config\.use\(/g
|
|
579
|
+
WIRING.lastIndex = botIdx
|
|
580
|
+
const first = WIRING.exec(src)
|
|
581
|
+
expect(first, 'no bot wiring found after `new Bot(`').not.toBeNull()
|
|
582
|
+
expect(
|
|
583
|
+
first![0],
|
|
584
|
+
`first bot wiring after \`new Bot(\` was \`${first![0]}\` — the UTF-8 ` +
|
|
585
|
+
'sanitiser must be installed first so it composes innermost',
|
|
586
|
+
).toBe('installUtf8Sanitizer(bot')
|
|
587
|
+
})
|
|
588
|
+
})
|