Skip to content

Commit 2ae52db

Browse files
fix: strip Unicode bidi control characters from user content (#226)
- Add stripUserContent() function to remove HTML and bidi controls - Remove U+202A-U+202E (LRE, RLE, PDF, LRO, RLO) characters - Remove U+2066-U+2069 (LRI, RLI, FSI, PDI) characters - Preserve all printable Unicode, emoji, and ZWJ sequences - Add 26 comprehensive tests covering bidi removal and content preservation - Fix adversarial content vulnerability that breaks UI trust
1 parent 39e2256 commit 2ae52db

2 files changed

Lines changed: 231 additions & 1 deletion

File tree

Backend/src/common/utils/sanitize.spec.ts

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { stripHtml } from './sanitize';
1+
import { stripHtml, stripUserContent } from './sanitize';
22

33
describe('stripHtml', () => {
44
it('should strip HTML tags and script content', () => {
@@ -82,3 +82,202 @@ describe('stripHtml', () => {
8282
}
8383
});
8484
});
85+
86+
describe('stripUserContent', () => {
87+
// Bidi control character removal tests
88+
describe('Unicode bidi control character removal', () => {
89+
it('should remove U+202E (RLO - Right-to-Left Override)', () => {
90+
const input = 'hello\u202Egnignignignignignignignignignignigni';
91+
const output = stripUserContent(input);
92+
expect(output).toBe('hellognignignignignignignignignignignigni');
93+
expect(output).not.toContain('\u202E');
94+
});
95+
96+
it('should remove U+202A (LRE - Left-to-Right Embedding)', () => {
97+
const input = 'start\u202Amiddle\u202Cend';
98+
const output = stripUserContent(input);
99+
expect(output).toBe('startmiddleend');
100+
expect(output).not.toContain('\u202A');
101+
expect(output).not.toContain('\u202C');
102+
});
103+
104+
it('should remove U+202B (RLE - Right-to-Left Embedding)', () => {
105+
const input = 'test\u202Bembedded\u202Ctext';
106+
const output = stripUserContent(input);
107+
expect(output).toBe('testembeddedtext');
108+
expect(output).not.toContain('\u202B');
109+
});
110+
111+
it('should remove U+202C (PDF - Pop Directional Formatting)', () => {
112+
const input = 'abc\u202Cdef';
113+
const output = stripUserContent(input);
114+
expect(output).toBe('abcdef');
115+
expect(output).not.toContain('\u202C');
116+
});
117+
118+
it('should remove U+202D (LRO - Left-to-Right Override)', () => {
119+
const input = 'text\u202Doverride';
120+
const output = stripUserContent(input);
121+
expect(output).toBe('textoverride');
122+
expect(output).not.toContain('\u202D');
123+
});
124+
125+
it('should remove U+2066 (LRI - Left-to-Right Isolate)', () => {
126+
const input = 'before\u2066isolate\u2069after';
127+
const output = stripUserContent(input);
128+
expect(output).toBe('beforeisolateafter');
129+
expect(output).not.toContain('\u2066');
130+
});
131+
132+
it('should remove U+2067 (RLI - Right-to-Left Isolate)', () => {
133+
const input = 'start\u2067isolate\u2069end';
134+
const output = stripUserContent(input);
135+
expect(output).toBe('startisolateend');
136+
expect(output).not.toContain('\u2067');
137+
});
138+
139+
it('should remove U+2068 (FSI - First Strong Isolate)', () => {
140+
const input = 'text\u2068first\u2069strong';
141+
const output = stripUserContent(input);
142+
expect(output).toBe('textfirststrong');
143+
expect(output).not.toContain('\u2068');
144+
});
145+
146+
it('should remove U+2069 (PDI - Pop Directional Isolate)', () => {
147+
const input = 'pop\u2069test';
148+
const output = stripUserContent(input);
149+
expect(output).toBe('poptest');
150+
expect(output).not.toContain('\u2069');
151+
});
152+
153+
it('should remove multiple bidi characters in one string', () => {
154+
const input = 'a\u202Ab\u202Bc\u202Cd\u202De\u202Ef\u2066g\u2067h\u2068i\u2069j';
155+
const output = stripUserContent(input);
156+
expect(output).toBe('abcdefghij');
157+
// Verify none of the bidi characters remain
158+
expect(output).not.toMatch(/[\u202A-\u202E\u2066-\u2069]/);
159+
});
160+
});
161+
162+
// Preservation tests
163+
describe('Content preservation', () => {
164+
it('should preserve standard emoji', () => {
165+
const input = 'Hello 🌍 World 🎉 Test 👍';
166+
const output = stripUserContent(input);
167+
expect(output).toBe('Hello 🌍 World 🎉 Test 👍');
168+
});
169+
170+
it('should preserve emoji with skin tone modifiers', () => {
171+
const input = 'Wave 👋🏾 and thumbs up 👍🏽';
172+
const output = stripUserContent(input);
173+
expect(output).toBe('Wave 👋🏾 and thumbs up 👍🏽');
174+
});
175+
176+
it('should preserve emoji ZWJ sequences', () => {
177+
const input = 'Family 👨‍👩‍👧‍👦 and rainbow flag 🏳️‍🌈';
178+
const output = stripUserContent(input);
179+
expect(output).toBe('Family 👨‍👩‍👧‍👦 and rainbow flag 🏳️‍🌈');
180+
});
181+
182+
it('should preserve accented characters', () => {
183+
const input = 'Café résumé naïve';
184+
const output = stripUserContent(input);
185+
expect(output).toBe('Café résumé naïve');
186+
});
187+
188+
it('should preserve CJK characters', () => {
189+
const input = '日本語 中文 한국어';
190+
const output = stripUserContent(input);
191+
expect(output).toBe('日本語 中文 한국어');
192+
});
193+
194+
it('should preserve Arabic and Hebrew text', () => {
195+
const input = 'مرحبا שלום';
196+
const output = stripUserContent(input);
197+
expect(output).toBe('مرحبا שלום');
198+
});
199+
200+
it('should preserve mixed Unicode content', () => {
201+
const input = 'Test Ẽñõẽd 日本 🌟 café';
202+
const output = stripUserContent(input);
203+
expect(output).toBe('Test Ẽñõẽd 日本 🌟 café');
204+
});
205+
});
206+
207+
// Combined HTML + bidi removal
208+
describe('Combined HTML and bidi removal', () => {
209+
it('should strip both HTML and bidi characters', () => {
210+
const input = '<b>bold\u202Etext</b>';
211+
const output = stripUserContent(input);
212+
expect(output).toBe('boldtext');
213+
expect(output).not.toContain('<');
214+
expect(output).not.toContain('\u202E');
215+
});
216+
217+
it('should handle script tags with bidi characters', () => {
218+
const input = '<script>alert("xss")</script>safe\u202Etext';
219+
const output = stripUserContent(input);
220+
expect(output).toBe('safetext');
221+
expect(output).not.toContain('alert');
222+
expect(output).not.toContain('\u202E');
223+
});
224+
225+
it('should sanitize adversarial content from problem statement', () => {
226+
const input = 'hello\u202Egnignignignignignignignignignignigni';
227+
const output = stripUserContent(input);
228+
expect(output).toBe('hellognignignignignignignignignignignigni');
229+
expect(output).not.toContain('\u202E');
230+
});
231+
});
232+
233+
// Edge cases
234+
describe('Edge cases', () => {
235+
it('should handle empty string', () => {
236+
expect(stripUserContent('')).toBe('');
237+
});
238+
239+
it('should handle string with only bidi characters', () => {
240+
const input = '\u202A\u202B\u202C\u202D\u202E';
241+
const output = stripUserContent(input);
242+
expect(output).toBe('');
243+
});
244+
245+
it('should handle plain text without bidi or HTML', () => {
246+
const input = 'Plain text content';
247+
const output = stripUserContent(input);
248+
expect(output).toBe('Plain text content');
249+
});
250+
251+
it('should trim whitespace', () => {
252+
const input = ' hello\u202Eworld ';
253+
const output = stripUserContent(input);
254+
expect(output).toBe('helloworld');
255+
});
256+
});
257+
258+
// Regression test: old behavior would have accepted bidi
259+
describe('Regression tests', () => {
260+
it('should reject bidi characters that old stripHtml would accept', () => {
261+
const input = 'test\u202Econtent';
262+
// Old behavior: stripHtml would keep bidi characters
263+
const oldBehavior = stripHtml(input);
264+
expect(oldBehavior).toContain('\u202E'); // Verify old behavior
265+
266+
// New behavior: stripUserContent removes them
267+
const newBehavior = stripUserContent(input);
268+
expect(newBehavior).not.toContain('\u202E');
269+
expect(newBehavior).toBe('testcontent');
270+
});
271+
272+
it('should demonstrate bidi vulnerability in old handler', () => {
273+
const maliciousInput = 'filename\u202Etxt.exe';
274+
275+
// Old stripHtml preserves the bidi character
276+
expect(stripHtml(maliciousInput)).toContain('\u202E');
277+
278+
// New stripUserContent removes it
279+
expect(stripUserContent(maliciousInput)).not.toContain('\u202E');
280+
expect(stripUserContent(maliciousInput)).toBe('filenametxt.exe');
281+
});
282+
});
283+
});

Backend/src/common/utils/sanitize.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,34 @@ export function stripHtml(input: string): string {
3636
.replace(/&amp;/g, '&')
3737
.trim();
3838
}
39+
40+
/**
41+
* Strip user-generated content: removes HTML tags AND Unicode bidi control characters.
42+
*
43+
* Removes bidirectional text override characters (U+202A–U+202E, U+2066–U+2069)
44+
* that can flip display order and break perceived trust in anonymous platforms.
45+
*
46+
* Preserves:
47+
* - All printable Unicode (including accented characters, CJK, etc.)
48+
* - Emoji and emoji ZWJ sequences
49+
* - Standard whitespace and newlines
50+
*
51+
* @param input - Raw user content
52+
* @returns Sanitized plain text without HTML or bidi controls
53+
*/
54+
export function stripUserContent(input: string): string {
55+
// First strip HTML
56+
const noHtml = stripHtml(input);
57+
58+
// Remove Unicode bidi control characters:
59+
// U+202A (LRE) - Left-to-Right Embedding
60+
// U+202B (RLE) - Right-to-Left Embedding
61+
// U+202C (PDF) - Pop Directional Formatting
62+
// U+202D (LRO) - Left-to-Right Override
63+
// U+202E (RLO) - Right-to-Left Override
64+
// U+2066 (LRI) - Left-to-Right Isolate
65+
// U+2067 (RLI) - Right-to-Left Isolate
66+
// U+2068 (FSI) - First Strong Isolate
67+
// U+2069 (PDI) - Pop Directional Isolate
68+
return noHtml.replace(/[\u202A-\u202E\u2066-\u2069]/g, '');
69+
}

0 commit comments

Comments
 (0)