Skip to content

Commit 805f8b9

Browse files
fix(security): sanitize md2html hrefs, topic names, and callback_data (#3173)
PR #3174 — approved by aegis-gh-agent[bot] (Argus) Security: URI scheme allowlist, topic name sanitization, callback_data length guard. 201 tests.
1 parent b996ac0 commit 805f8b9

2 files changed

Lines changed: 259 additions & 13 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/**
2+
* telegram-md2html-security.test.ts — Security tests for Issue #3173.
3+
*
4+
* Tests URI scheme allowlist (sanitizeHref), topic name sanitization
5+
* (sanitizeTopicName), and callback_data length guard (safeCallbackData).
6+
*/
7+
8+
import { describe, it, expect } from 'vitest';
9+
10+
// ── Inline copies of the helpers (they're private in telegram.ts) ──
11+
12+
const ALLOWED_HREF_SCHEMES = ['http:', 'https:', '#:', 'mailto:'];
13+
14+
function esc(text: string): string {
15+
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16+
}
17+
18+
function sanitizeHref(href: string): string {
19+
const trimmed = href.trim();
20+
if (trimmed.startsWith('#') || trimmed.startsWith('/')) return esc(trimmed);
21+
const scheme = trimmed.split(':')[0]?.toLowerCase() + ':';
22+
if (ALLOWED_HREF_SCHEMES.includes(scheme)) return esc(trimmed);
23+
return '#';
24+
}
25+
26+
function sanitizeTopicName(name: string): string {
27+
return name
28+
.replace(/[\u0000-\u001F\u007F-\u009F\u200E-\u200F\u202A-\u202E]/g, '')
29+
.replace(/\s+/g, ' ')
30+
.trim()
31+
.slice(0, 64);
32+
}
33+
34+
const MAX_CALLBACK_DATA_LENGTH = 64;
35+
36+
function safeCallbackData(data: string): string {
37+
if (Buffer.byteLength(data, 'utf-8') <= MAX_CALLBACK_DATA_LENGTH) return data;
38+
const prefix = data.substring(0, data.lastIndexOf(':') + 1);
39+
const value = data.substring(prefix.length);
40+
let lo = 0, hi = value.length;
41+
while (lo < hi) {
42+
const mid = Math.ceil((lo + hi) / 2);
43+
if (Buffer.byteLength(prefix + value.slice(0, mid), 'utf-8') <= MAX_CALLBACK_DATA_LENGTH) {
44+
lo = mid;
45+
} else {
46+
hi = mid - 1;
47+
}
48+
}
49+
return prefix + value.slice(0, lo);
50+
}
51+
52+
// ── Tests ──
53+
54+
describe('Security: md2html URI scheme allowlist (#3173)', () => {
55+
describe('sanitizeHref', () => {
56+
it('should allow http:// links', () => {
57+
expect(sanitizeHref('http://example.com')).toBe('http://example.com');
58+
});
59+
60+
it('should allow https:// links', () => {
61+
expect(sanitizeHref('https://example.com/path?q=1')).toBe('https://example.com/path?q=1');
62+
});
63+
64+
it('should allow mailto: links', () => {
65+
expect(sanitizeHref('mailto:user@example.com')).toBe('mailto:user@example.com');
66+
});
67+
68+
it('should allow anchor links (#)', () => {
69+
expect(sanitizeHref('#section')).toBe('#section');
70+
});
71+
72+
it('should allow relative links (/)', () => {
73+
expect(sanitizeHref('/path/to/page')).toBe('/path/to/page');
74+
});
75+
76+
it('should block tg:// deep links', () => {
77+
expect(sanitizeHref('tg://resolve?domain=attacker_bot')).toBe('#');
78+
});
79+
80+
it('should block javascript: URIs', () => {
81+
expect(sanitizeHref('javascript:alert(1)')).toBe('#');
82+
});
83+
84+
it('should block data: URIs', () => {
85+
expect(sanitizeHref('data:text/html,<script>alert(1)</script>')).toBe('#');
86+
});
87+
88+
it('should block file: URIs', () => {
89+
expect(sanitizeHref('file:///etc/passwd')).toBe('#');
90+
});
91+
92+
it('should block ftp:// URIs', () => {
93+
expect(sanitizeHref('ftp://evil.com/malware')).toBe('#');
94+
});
95+
96+
it('should block viber:// URIs', () => {
97+
expect(sanitizeHref('viber://chat?number=123')).toBe('#');
98+
});
99+
100+
it('should be case-insensitive for scheme check', () => {
101+
expect(sanitizeHref('TG://resolve?domain=bot')).toBe('#');
102+
expect(sanitizeHref('JavaScript:alert(1)')).toBe('#');
103+
});
104+
105+
it('should handle empty href', () => {
106+
expect(sanitizeHref('')).toBe('#');
107+
});
108+
109+
it('should handle whitespace-padded hrefs', () => {
110+
expect(sanitizeHref(' https://example.com ')).toBe('https://example.com');
111+
expect(sanitizeHref(' tg://evil ')).toBe('#');
112+
});
113+
});
114+
});
115+
116+
describe('Security: topic name sanitization (#3173)', () => {
117+
describe('sanitizeTopicName', () => {
118+
it('should preserve normal text', () => {
119+
expect(sanitizeTopicName('🤖 My Session')).toBe('🤖 My Session');
120+
});
121+
122+
it('should strip null bytes', () => {
123+
expect(sanitizeTopicName('ses\u0000sion')).toBe('session');
124+
});
125+
126+
it('should strip control characters', () => {
127+
expect(sanitizeTopicName('ses\u001Fsion')).toBe('session');
128+
});
129+
130+
it('should strip RTL override (U+202E)', () => {
131+
expect(sanitizeTopicName('hello\u202Eworld')).toBe('helloworld');
132+
});
133+
134+
it('should strip LRE/RLE/LRO/RLO/PDF', () => {
135+
const name = 'test\u202A\u202B\u202C\u202D\u202Ename';
136+
expect(sanitizeTopicName(name)).toBe('testname');
137+
});
138+
139+
it('should collapse multiple whitespace', () => {
140+
expect(sanitizeTopicName('hello world')).toBe('hello world');
141+
});
142+
143+
it('should trim leading/trailing whitespace', () => {
144+
expect(sanitizeTopicName(' session ')).toBe('session');
145+
});
146+
147+
it('should truncate to 64 characters', () => {
148+
const long = '🤖 ' + 'x'.repeat(100);
149+
expect(sanitizeTopicName(long).length).toBeLessThanOrEqual(64);
150+
});
151+
152+
it('should handle empty string', () => {
153+
expect(sanitizeTopicName('')).toBe('');
154+
});
155+
156+
it('should handle string that is only control chars', () => {
157+
expect(sanitizeTopicName('\u0000\u0001\u001F')).toBe('');
158+
});
159+
});
160+
});
161+
162+
describe('Security: callback_data length guard (#3173)', () => {
163+
describe('safeCallbackData', () => {
164+
it('should pass through short data unchanged', () => {
165+
const sid = '12345678-1234-1234-1234-123456789012';
166+
expect(safeCallbackData(`perm_approve:${sid}`)).toBe(`perm_approve:${sid}`);
167+
});
168+
169+
it('should truncate long option values', () => {
170+
const sid = '12345678-1234-1234-1234-123456789012';
171+
const longValue = 'a'.repeat(100);
172+
const result = safeCallbackData(`cb_option:${sid}:${longValue}`);
173+
expect(Buffer.byteLength(result, 'utf-8')).toBeLessThanOrEqual(64);
174+
});
175+
176+
it('should preserve prefix when truncating', () => {
177+
const sid = '12345678-1234-1234-1234-123456789012';
178+
const longValue = 'a'.repeat(100);
179+
const result = safeCallbackData(`cb_option:${sid}:${longValue}`);
180+
expect(result).toMatch(/^cb_option:/);
181+
});
182+
183+
it('should handle exactly 64-byte data', () => {
184+
const data = 'x'.repeat(64);
185+
expect(safeCallbackData(data)).toBe(data);
186+
});
187+
188+
it('should handle 65-byte data', () => {
189+
const data = 'x'.repeat(65);
190+
const result = safeCallbackData(data);
191+
expect(Buffer.byteLength(result, 'utf-8')).toBeLessThanOrEqual(64);
192+
});
193+
194+
it('should handle multi-byte UTF-8 characters', () => {
195+
const sid = '12345678-1234-1234-1234-123456789012';
196+
const emojiValue = '🎉'.repeat(30); // each emoji is 4 bytes
197+
const result = safeCallbackData(`cb_option:${sid}:${emojiValue}`);
198+
expect(Buffer.byteLength(result, 'utf-8')).toBeLessThanOrEqual(64);
199+
});
200+
});
201+
});

src/channels/telegram.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,51 @@ function formatSubAgentTree(text: string): string | null {
252252
* Handles: **bold**, `code`, ```blocks```, [links](url), tables
253253
* Must be called BEFORE wrapping in blockquote/pre tags.
254254
*/
255+
/** Allowlisted URI schemes for md2html() link hrefs. Prevents tg://, javascript:, data:, etc. */
256+
const ALLOWED_HREF_SCHEMES = ['http:', 'https:', '#:', 'mailto:'];
257+
258+
function sanitizeHref(href: string): string {
259+
const trimmed = href.trim();
260+
// Allow relative/anchor links (no colon before slash/hash)
261+
if (trimmed.startsWith('#') || trimmed.startsWith('/')) return esc(trimmed);
262+
const scheme = trimmed.split(':')[0]?.toLowerCase() + ':';
263+
if (ALLOWED_HREF_SCHEMES.includes(scheme)) return esc(trimmed);
264+
// Block everything else — show the link text but drop the dangerous href
265+
return '#';
266+
}
267+
268+
/** Strip control chars, RTL overrides, and truncate topic names for Telegram forum topics. */
269+
function sanitizeTopicName(name: string): string {
270+
return name
271+
// Strip control characters (C0, C1, RTL overrides LRE/RLE/LRO/RLO/PDF)
272+
.replace(/[\u0000-\u001F\u007F-\u009F\u200E-\u200F\u202A-\u202E]/g, '')
273+
// Collapse whitespace
274+
.replace(/\s+/g, ' ')
275+
.trim()
276+
.slice(0, 64);
277+
}
278+
279+
/** Max callback_data length per Telegram Bot API (64 bytes). */
280+
const MAX_CALLBACK_DATA_LENGTH = 64;
281+
282+
function safeCallbackData(data: string): string {
283+
if (Buffer.byteLength(data, 'utf-8') <= MAX_CALLBACK_DATA_LENGTH) return data;
284+
// Truncate value portion to fit within 64 bytes
285+
const prefix = data.substring(0, data.lastIndexOf(':') + 1);
286+
const value = data.substring(prefix.length);
287+
// Binary search for max value length that fits
288+
let lo = 0, hi = value.length;
289+
while (lo < hi) {
290+
const mid = Math.ceil((lo + hi) / 2);
291+
if (Buffer.byteLength(prefix + value.slice(0, mid), 'utf-8') <= MAX_CALLBACK_DATA_LENGTH) {
292+
lo = mid;
293+
} else {
294+
hi = mid - 1;
295+
}
296+
}
297+
return prefix + value.slice(0, lo);
298+
}
299+
255300
function md2html(md: string): string {
256301
let result = '';
257302
const lines = md.split('\n');
@@ -322,7 +367,7 @@ function md2html(md: string): string {
322367
processed = processed.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '<i>$1</i>');
323368

324369
// Links: [text](url)
325-
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
370+
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => '<a href="' + sanitizeHref(href) + '">' + text + '</a>');
326371

327372
// List bullets
328373
processed = processed.replace(/^(\s*)[-*]\s+/, '$1• ');
@@ -816,7 +861,7 @@ export class TelegramChannel implements Channel {
816861
}
817862

818863
async onSessionCreated(payload: SessionEventPayload): Promise<void> {
819-
const topicName = `🤖 ${payload.session.name}`;
864+
const topicName = sanitizeTopicName(`🤖 ${payload.session.name}`);
820865
const result = (await this.tgApi('createForumTopic', {
821866
chat_id: this.config.groupChatId,
822867
name: topicName,
@@ -1043,14 +1088,14 @@ export class TelegramChannel implements Channel {
10431088
for (const opt of options) {
10441089
buttons.push({
10451090
text: opt.label,
1046-
callback_data: `cb_option:${payload.session.id}:${opt.value}`,
1091+
callback_data: safeCallbackData(`cb_option:${payload.session.id}:${opt.value}`),
10471092
});
10481093
}
10491094
} else {
10501095
// Fallback: generic approve/reject
10511096
buttons.push(
1052-
{ text: '✅ Approve', callback_data: `perm_approve:${payload.session.id}` },
1053-
{ text: '❌ Reject', callback_data: `perm_reject:${payload.session.id}` },
1097+
{ text: '✅ Approve', callback_data: safeCallbackData(`perm_approve:${payload.session.id}`) },
1098+
{ text: '❌ Reject', callback_data: safeCallbackData(`perm_reject:${payload.session.id}`) },
10541099
);
10551100
}
10561101

@@ -1083,18 +1128,18 @@ export class TelegramChannel implements Channel {
10831128
for (const opt of options) {
10841129
buttons.push({
10851130
text: opt.label,
1086-
callback_data: `cb_option:${payload.session.id}:${opt.value}`,
1131+
callback_data: safeCallbackData(`cb_option:${payload.session.id}:${opt.value}`),
10871132
});
10881133
}
10891134
// Always add Skip for questions
10901135
if (buttons.length < 4) {
1091-
buttons.push({ text: '🤷 Skip', callback_data: `cb_skip:${payload.session.id}` });
1136+
buttons.push({ text: '🤷 Skip', callback_data: safeCallbackData(`cb_skip:${payload.session.id}`) });
10921137
}
10931138
} else {
10941139
buttons.push(
1095-
{ text: '✅ Yes', callback_data: `cb_yes:${payload.session.id}` },
1096-
{ text: '❌ No', callback_data: `cb_no:${payload.session.id}` },
1097-
{ text: '🤷 Skip', callback_data: `cb_skip:${payload.session.id}` },
1140+
{ text: '✅ Yes', callback_data: safeCallbackData(`cb_yes:${payload.session.id}`) },
1141+
{ text: '❌ No', callback_data: safeCallbackData(`cb_no:${payload.session.id}`) },
1142+
{ text: '🤷 Skip', callback_data: safeCallbackData(`cb_skip:${payload.session.id}`) },
10981143
);
10991144
}
11001145

@@ -1122,9 +1167,9 @@ export class TelegramChannel implements Channel {
11221167
reply_markup: {
11231168
inline_keyboard: [
11241169
[
1125-
{ text: '▶ Execute', callback_data: `plan_exec:${payload.session.id}` },
1126-
{ text: '⚡ Execute All', callback_data: `plan_exec_all:${payload.session.id}` },
1127-
{ text: '❌ Cancel', callback_data: `plan_cancel:${payload.session.id}` },
1170+
{ text: '▶ Execute', callback_data: safeCallbackData(`plan_exec:${payload.session.id}`) },
1171+
{ text: '⚡ Execute All', callback_data: safeCallbackData(`plan_exec_all:${payload.session.id}`) },
1172+
{ text: '❌ Cancel', callback_data: safeCallbackData(`plan_cancel:${payload.session.id}`) },
11281173
],
11291174
],
11301175
},

0 commit comments

Comments
 (0)