@@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest';
77// Test the formatting logic directly (these mirror the internal functions)
88
99function esc ( text : string ) : string {
10- return text . replace ( / & / g, '&' ) . replace ( / < / g, '<' ) . replace ( / > / g, '>' ) . replace ( / " / g, '"' ) ;
10+ return text . replace ( / & / g, '&' ) . replace ( / < / g, '<' ) . replace ( / > / g, '>' ) . replace ( / " / g, '"' ) . replace ( / ' / g , ''' ) ;
1111}
1212
1313function truncate ( text : string , maxLen : number ) : string {
@@ -21,6 +21,17 @@ function shortPath(path: string): string {
2121 return '…/' + parts . slice ( - 2 ) . join ( '/' ) ;
2222}
2323
24+ // Mirror sanitizeHref from telegram.ts for testing
25+ const ALLOWED_HREF_SCHEMES = [ 'http:' , 'https:' , 'tg:' , '#:' , 'mailto:' ] ;
26+
27+ function sanitizeHref ( href : string ) : string {
28+ const trimmed = href . trim ( ) ;
29+ if ( trimmed . startsWith ( '#' ) || trimmed . startsWith ( '/' ) ) return esc ( trimmed ) ;
30+ const scheme = trimmed . split ( ':' ) [ 0 ] ?. toLowerCase ( ) + ':' ;
31+ if ( ALLOWED_HREF_SCHEMES . includes ( scheme ) ) return esc ( trimmed ) ;
32+ return '#' ;
33+ }
34+
2435describe ( 'Telegram formatting (Issue #43)' , ( ) => {
2536 describe ( 'Truncation limits' , ( ) => {
2637 it ( 'should allow 500 chars for assistant messages (not 150)' , ( ) => {
@@ -174,6 +185,72 @@ describe('Telegram formatting (Issue #43)', () => {
174185 } ) ;
175186 } ) ;
176187
188+ // ── Security: HTML injection vectors (#3219) ─────────────────────────────
189+ describe ( 'Security: HTML injection prevention (#3219)' , ( ) => {
190+ it ( 'should escape single quotes' , ( ) => {
191+ expect ( esc ( "it's a test" ) ) . toBe ( 'it's a test' ) ;
192+ } ) ;
193+
194+ it ( 'should escape script tags' , ( ) => {
195+ expect ( esc ( '<script>alert("xss")</script>' ) ) . toBe (
196+ '<script>alert("xss")</script>'
197+ ) ;
198+ } ) ;
199+
200+ it ( 'should escape img onerror injection' , ( ) => {
201+ expect ( esc ( '<img src=x onerror=alert(1)>' ) ) . toBe (
202+ '<img src=x onerror=alert(1)>'
203+ ) ;
204+ } ) ;
205+
206+ it ( 'should escape event handler attributes' , ( ) => {
207+ expect ( esc ( '<div onload="alert(1)" onclick=\'alert(2)\'>' ) ) . toBe (
208+ '<div onload="alert(1)" onclick='alert(2)'>'
209+ ) ;
210+ } ) ;
211+
212+ it ( 'should handle nested/malformed tag injection' , ( ) => {
213+ // <scr<script>ipt> → after escaping, both layers are safe
214+ expect ( esc ( '<scr<script>ipt>' ) ) . toBe ( '<scr<script>ipt>' ) ;
215+ } ) ;
216+
217+ it ( 'should handle null bytes in content' , ( ) => {
218+ const withNull = 'before\x00after' ;
219+ // esc() should not crash on null bytes
220+ expect ( ( ) => esc ( withNull ) ) . not . toThrow ( ) ;
221+ expect ( esc ( withNull ) ) . toBe ( 'before\x00after' ) ;
222+ } ) ;
223+
224+ it ( 'should prevent double-encoding attack (entity smuggling)' , ( ) => {
225+ // Attacker tries to inject via pre-encoded entities
226+ expect ( esc ( '<script>' ) ) . toBe ( '&lt;script&gt;' ) ;
227+ } ) ;
228+
229+ it ( 'should escape all five HTML-special characters in a single string' , ( ) => {
230+ expect ( esc ( '<>&"\'' ) ) . toBe ( '<>&"'' ) ;
231+ } ) ;
232+
233+ it ( 'should escape mixed content: code + HTML injection' , ( ) => {
234+ const malicious = 'const x = "<script>alert(\'xss\')</script>"' ;
235+ expect ( esc ( malicious ) ) . toBe (
236+ 'const x = "<script>alert('xss')</script>"'
237+ ) ;
238+ } ) ;
239+
240+ it ( 'should handle unicode content safely' , ( ) => {
241+ expect ( esc ( '日本語 <tag> & "quotes"' ) ) . toBe (
242+ '日本語 <tag> & "quotes"'
243+ ) ;
244+ } ) ;
245+
246+ it ( 'should escape very long injection strings without truncation' , ( ) => {
247+ const long = '<script>' + 'A' . repeat ( 10000 ) + '</script>' ;
248+ const escaped = esc ( long ) ;
249+ expect ( escaped . startsWith ( '<script>' ) ) . toBe ( true ) ;
250+ expect ( escaped . endsWith ( '</script>' ) ) . toBe ( true ) ;
251+ } ) ;
252+ } ) ;
253+
177254 describe ( 'Path shortening' , ( ) => {
178255 it ( 'should shorten deep paths' , ( ) => {
179256 expect ( shortPath ( '/home/user/projects/aegis/src/server.ts' ) ) . toBe ( '…/src/server.ts' ) ;
@@ -183,4 +260,61 @@ describe('Telegram formatting (Issue #43)', () => {
183260 expect ( shortPath ( 'src/index.ts' ) ) . toBe ( 'src/index.ts' ) ;
184261 } ) ;
185262 } ) ;
263+
264+ // ── Security: URL scheme validation (#3219) ──────────────────────────────
265+ describe ( 'Security: sanitizeHref URL scheme validation (#3219)' , ( ) => {
266+ it ( 'should allow http:// URLs' , ( ) => {
267+ expect ( sanitizeHref ( 'http://example.com' ) ) . toBe ( 'http://example.com' ) ;
268+ } ) ;
269+
270+ it ( 'should allow https:// URLs' , ( ) => {
271+ expect ( sanitizeHref ( 'https://example.com/path' ) ) . toBe ( 'https://example.com/path' ) ;
272+ } ) ;
273+
274+ it ( 'should allow tg:// Telegram deep links' , ( ) => {
275+ expect ( sanitizeHref ( 'tg://resolve?domain=test' ) ) . toBe ( 'tg://resolve?domain=test' ) ;
276+ } ) ;
277+
278+ it ( 'should allow relative paths starting with /' , ( ) => {
279+ expect ( sanitizeHref ( '/path/to/page' ) ) . toBe ( '/path/to/page' ) ;
280+ } ) ;
281+
282+ it ( 'should allow anchor links starting with #' , ( ) => {
283+ expect ( sanitizeHref ( '#section' ) ) . toBe ( '#section' ) ;
284+ } ) ;
285+
286+ it ( 'should allow mailto: links' , ( ) => {
287+ expect ( sanitizeHref ( 'mailto:user@example.com' ) ) . toBe ( 'mailto:user@example.com' ) ;
288+ } ) ;
289+
290+ it ( 'should BLOCK javascript: URLs' , ( ) => {
291+ expect ( sanitizeHref ( 'javascript:alert(1)' ) ) . toBe ( '#' ) ;
292+ } ) ;
293+
294+ it ( 'should BLOCK javascript: URLs with mixed case' , ( ) => {
295+ expect ( sanitizeHref ( 'JaVaScRiPt:alert(1)' ) ) . toBe ( '#' ) ;
296+ } ) ;
297+
298+ it ( 'should BLOCK data: URLs' , ( ) => {
299+ expect ( sanitizeHref ( 'data:text/html,<script>alert(1)</script>' ) ) . toBe ( '#' ) ;
300+ } ) ;
301+
302+ it ( 'should BLOCK vbscript: URLs' , ( ) => {
303+ expect ( sanitizeHref ( 'vbscript:MsgBox("xss")' ) ) . toBe ( '#' ) ;
304+ } ) ;
305+
306+ it ( 'should BLOCK file: URLs' , ( ) => {
307+ expect ( sanitizeHref ( 'file:///etc/passwd' ) ) . toBe ( '#' ) ;
308+ } ) ;
309+
310+ it ( 'should sanitize quotes in allowed URLs' , ( ) => {
311+ // An attacker tries to break out of the href attribute
312+ expect ( sanitizeHref ( 'https://example.com/"onclick="alert(1)' ) ) . not . toContain ( '"' ) ;
313+ expect ( sanitizeHref ( 'https://example.com/\'onload=\'alert(1)' ) ) . not . toContain ( "'" ) ;
314+ } ) ;
315+
316+ it ( 'should handle whitespace-padded javascript: URLs' , ( ) => {
317+ expect ( sanitizeHref ( ' javascript:alert(1)' ) ) . toBe ( '#' ) ;
318+ } ) ;
319+ } ) ;
186320} ) ;
0 commit comments