@transferwise/components 46.155.1 → 46.156.0
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/build/Markup/Markup.js +138 -0
- package/build/Markup/Markup.js.map +1 -0
- package/build/Markup/Markup.mjs +133 -0
- package/build/Markup/Markup.mjs.map +1 -0
- package/build/Markup/constants.js +21 -0
- package/build/Markup/constants.js.map +1 -0
- package/build/Markup/constants.mjs +15 -0
- package/build/Markup/constants.mjs.map +1 -0
- package/build/Markup/utils/parseMarkup.js +263 -0
- package/build/Markup/utils/parseMarkup.js.map +1 -0
- package/build/Markup/utils/parseMarkup.mjs +258 -0
- package/build/Markup/utils/parseMarkup.mjs.map +1 -0
- package/build/Markup/utils/sanitise.js +111 -0
- package/build/Markup/utils/sanitise.js.map +1 -0
- package/build/Markup/utils/sanitise.mjs +108 -0
- package/build/Markup/utils/sanitise.mjs.map +1 -0
- package/build/i18n/hu.json +1 -1
- package/build/i18n/hu.json.js +1 -1
- package/build/i18n/hu.json.mjs +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/index.mjs +1 -0
- package/build/index.mjs.map +1 -1
- package/build/main.css +35 -0
- package/build/styles/Markup/Markup.css +28 -0
- package/build/styles/Markup/_stories/Vulnerability/Vulnerability.css +69 -0
- package/build/styles/main.css +35 -0
- package/build/types/Body/Body.d.ts +2 -2
- package/build/types/IconButton/IconButton.d.ts +1 -1
- package/build/types/Markup/Markup.d.ts +18 -0
- package/build/types/Markup/Markup.d.ts.map +1 -0
- package/build/types/Markup/Markup.types.d.ts +77 -0
- package/build/types/Markup/Markup.types.d.ts.map +1 -0
- package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts +12 -0
- package/build/types/Markup/_stories/Vulnerability/Vulnerability.d.ts.map +1 -0
- package/build/types/Markup/constants.d.ts +6 -0
- package/build/types/Markup/constants.d.ts.map +1 -0
- package/build/types/Markup/index.d.ts +3 -0
- package/build/types/Markup/index.d.ts.map +1 -0
- package/build/types/Markup/utils/parseMarkup.d.ts +8 -0
- package/build/types/Markup/utils/parseMarkup.d.ts.map +1 -0
- package/build/types/Markup/utils/sanitise.d.ts +4 -0
- package/build/types/Markup/utils/sanitise.d.ts.map +1 -0
- package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
- package/build/types/Title/Title.d.ts +2 -2
- package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
- package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
- package/build/types/index.d.ts +2 -0
- package/build/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Markup/Markup.css +28 -0
- package/src/Markup/Markup.injection.test.tsx +163 -0
- package/src/Markup/Markup.less +29 -0
- package/src/Markup/Markup.test.tsx +502 -0
- package/src/Markup/Markup.tsx +175 -0
- package/src/Markup/Markup.types.ts +62 -0
- package/src/Markup/_stories/Markup.docs.mdx +75 -0
- package/src/Markup/_stories/Markup.security.docs.mdx +343 -0
- package/src/Markup/_stories/Markup.story.tsx +266 -0
- package/src/Markup/_stories/Vulnerability/Vulnerability.css +69 -0
- package/src/Markup/_stories/Vulnerability/Vulnerability.tsx +30 -0
- package/src/Markup/constants.ts +23 -0
- package/src/Markup/index.ts +2 -0
- package/src/Markup/utils/parseMarkup.test.ts +706 -0
- package/src/Markup/utils/parseMarkup.ts +265 -0
- package/src/Markup/utils/sanitise.test.ts +499 -0
- package/src/Markup/utils/sanitise.ts +129 -0
- package/src/i18n/hu.json +1 -1
- package/src/index.ts +2 -0
- package/src/main.css +35 -0
- package/src/main.less +1 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseMarkup,
|
|
3
|
+
MAX_NESTING_DEPTH,
|
|
4
|
+
MAX_INPUT_LENGTH,
|
|
5
|
+
MAX_ACCESSIBILITY_LABEL_LENGTH,
|
|
6
|
+
} from './parseMarkup';
|
|
7
|
+
|
|
8
|
+
describe('parseMarkup', () => {
|
|
9
|
+
it('returns a single text node for plain text', () => {
|
|
10
|
+
expect(parseMarkup('hello world')).toEqual([{ type: 'text', content: 'hello world' }]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns empty array for empty string', () => {
|
|
14
|
+
expect(parseMarkup('')).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('splits text on newlines', () => {
|
|
18
|
+
expect(parseMarkup('line one\nline two')).toEqual([
|
|
19
|
+
{ type: 'text', content: 'line one' },
|
|
20
|
+
{ type: 'newline' },
|
|
21
|
+
{ type: 'text', content: 'line two' },
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('preserves literal backslash-n as text (not treated as newline)', () => {
|
|
26
|
+
expect(parseMarkup('line one\\nline two')).toEqual([
|
|
27
|
+
{ type: 'text', content: 'line one\\nline two' },
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('link tags', () => {
|
|
32
|
+
it('parses <link> with href attribute (double quotes)', () => {
|
|
33
|
+
expect(parseMarkup('go to <link href="/settings">settings</link> page')).toEqual([
|
|
34
|
+
{ type: 'text', content: 'go to ' },
|
|
35
|
+
{ type: 'link', href: '/settings', children: [{ type: 'text', content: 'settings' }] },
|
|
36
|
+
{ type: 'text', content: ' page' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('parses <link> with href attribute (single quotes)', () => {
|
|
41
|
+
expect(parseMarkup("<link href='/path'>click</link>")).toEqual([
|
|
42
|
+
{ type: 'link', href: '/path', children: [{ type: 'text', content: 'click' }] },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('parses <link> with action attribute', () => {
|
|
47
|
+
expect(parseMarkup('<link action="save">Save now</link>')).toEqual([
|
|
48
|
+
{
|
|
49
|
+
type: 'link',
|
|
50
|
+
action: 'save',
|
|
51
|
+
children: [{ type: 'text', content: 'Save now' }],
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('parses <link> with href and target attributes', () => {
|
|
57
|
+
expect(parseMarkup('<link href="https://example.com" target="_blank">visit</link>')).toEqual([
|
|
58
|
+
{
|
|
59
|
+
type: 'link',
|
|
60
|
+
href: 'https://example.com',
|
|
61
|
+
target: '_blank',
|
|
62
|
+
children: [{ type: 'text', content: 'visit' }],
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('parses <a> as an alias for link', () => {
|
|
68
|
+
expect(parseMarkup('<a href="/page">click</a>')).toEqual([
|
|
69
|
+
{ type: 'link', href: '/page', children: [{ type: 'text', content: 'click' }] },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('nesting', () => {
|
|
75
|
+
it('supports emphasis inside link', () => {
|
|
76
|
+
expect(parseMarkup('<link href="/x"><important>bold link</important></link>')).toEqual([
|
|
77
|
+
{
|
|
78
|
+
type: 'link',
|
|
79
|
+
href: '/x',
|
|
80
|
+
children: [
|
|
81
|
+
{
|
|
82
|
+
type: 'important',
|
|
83
|
+
children: [{ type: 'text', content: 'bold link' }],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('supports link inside emphasis', () => {
|
|
91
|
+
expect(
|
|
92
|
+
parseMarkup('<important>Read the <link href="/terms">terms</link> carefully</important>'),
|
|
93
|
+
).toEqual([
|
|
94
|
+
{
|
|
95
|
+
type: 'important',
|
|
96
|
+
children: [
|
|
97
|
+
{ type: 'text', content: 'Read the ' },
|
|
98
|
+
{
|
|
99
|
+
type: 'link',
|
|
100
|
+
href: '/terms',
|
|
101
|
+
children: [{ type: 'text', content: 'terms' }],
|
|
102
|
+
},
|
|
103
|
+
{ type: 'text', content: ' carefully' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('supports multi-level nesting', () => {
|
|
110
|
+
expect(
|
|
111
|
+
parseMarkup('<positive><important><link href="/x">deep</link></important></positive>'),
|
|
112
|
+
).toEqual([
|
|
113
|
+
{
|
|
114
|
+
type: 'positive',
|
|
115
|
+
children: [
|
|
116
|
+
{
|
|
117
|
+
type: 'important',
|
|
118
|
+
children: [
|
|
119
|
+
{
|
|
120
|
+
type: 'link',
|
|
121
|
+
href: '/x',
|
|
122
|
+
children: [{ type: 'text', content: 'deep' }],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('malformed input', () => {
|
|
133
|
+
it('treats unclosed tags as literal text', () => {
|
|
134
|
+
expect(parseMarkup('<important>unclosed')).toEqual([
|
|
135
|
+
{ type: 'text', content: '<important>' },
|
|
136
|
+
{ type: 'text', content: 'unclosed' },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('treats unrecognized tags as literal text', () => {
|
|
141
|
+
expect(parseMarkup('<script>alert("xss")</script>')).toEqual([
|
|
142
|
+
{ type: 'text', content: '<script>alert("xss")</script>' },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('treats mismatched close tag as literal text', () => {
|
|
147
|
+
expect(parseMarkup('<important>text</positive>')).toEqual([
|
|
148
|
+
{ type: 'text', content: '<important>' },
|
|
149
|
+
{ type: 'text', content: 'text' },
|
|
150
|
+
{ type: 'text', content: '</positive>' },
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('ignores unknown attributes on link tags', () => {
|
|
155
|
+
expect(parseMarkup('<link href="/x" onclick="evil()">text</link>')).toEqual([
|
|
156
|
+
{ type: 'link', href: '/x', children: [{ type: 'text', content: 'text' }] },
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does not match tags that are prefixes of longer words', () => {
|
|
161
|
+
expect(parseMarkup('<stronger>text</stronger>')).toEqual([
|
|
162
|
+
{ type: 'text', content: '<stronger>text</stronger>' },
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('does not match <paragraphing> as <paragraph>', () => {
|
|
167
|
+
expect(parseMarkup('<paragraphing>text</paragraphing>')).toEqual([
|
|
168
|
+
{ type: 'text', content: '<paragraphing>text</paragraphing>' },
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('emphasis tags', () => {
|
|
174
|
+
it('parses <important> tag', () => {
|
|
175
|
+
expect(parseMarkup('this is <important>critical</important> info')).toEqual([
|
|
176
|
+
{ type: 'text', content: 'this is ' },
|
|
177
|
+
{ type: 'important', children: [{ type: 'text', content: 'critical' }] },
|
|
178
|
+
{ type: 'text', content: ' info' },
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('parses all emphasis tag variants', () => {
|
|
183
|
+
const result = parseMarkup('<positive>good</positive> <negative>bad</negative>');
|
|
184
|
+
expect(result).toEqual([
|
|
185
|
+
{ type: 'positive', children: [{ type: 'text', content: 'good' }] },
|
|
186
|
+
{ type: 'text', content: ' ' },
|
|
187
|
+
{ type: 'negative', children: [{ type: 'text', content: 'bad' }] },
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('strong and strikethrough tags', () => {
|
|
193
|
+
it('parses <strong> tag', () => {
|
|
194
|
+
expect(parseMarkup('this is <strong>bold</strong> text')).toEqual([
|
|
195
|
+
{ type: 'text', content: 'this is ' },
|
|
196
|
+
{ type: 'strong', children: [{ type: 'text', content: 'bold' }] },
|
|
197
|
+
{ type: 'text', content: ' text' },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('parses <strikethrough> tag', () => {
|
|
202
|
+
expect(parseMarkup('this is <strikethrough>removed</strikethrough> text')).toEqual([
|
|
203
|
+
{ type: 'text', content: 'this is ' },
|
|
204
|
+
{ type: 'strikethrough', children: [{ type: 'text', content: 'removed' }] },
|
|
205
|
+
{ type: 'text', content: ' text' },
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('paragraph tags', () => {
|
|
211
|
+
it('parses <paragraph> tag', () => {
|
|
212
|
+
expect(parseMarkup('<paragraph>A full paragraph.</paragraph>')).toEqual([
|
|
213
|
+
{ type: 'paragraph', children: [{ type: 'text', content: 'A full paragraph.' }] },
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('parses <p> as an alias for paragraph', () => {
|
|
218
|
+
expect(parseMarkup('<p>Short form.</p>')).toEqual([
|
|
219
|
+
{ type: 'paragraph', children: [{ type: 'text', content: 'Short form.' }] },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('accessibilityLabel', () => {
|
|
225
|
+
it('extracts accessibilityLabel from link tags', () => {
|
|
226
|
+
expect(
|
|
227
|
+
parseMarkup('<link href="/settings" accessibilityLabel="Go to settings">settings</link>'),
|
|
228
|
+
).toEqual([
|
|
229
|
+
{
|
|
230
|
+
type: 'link',
|
|
231
|
+
href: '/settings',
|
|
232
|
+
accessibilityLabel: 'Go to settings',
|
|
233
|
+
children: [{ type: 'text', content: 'settings' }],
|
|
234
|
+
},
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('extracts accessibilityLabel from emphasis tags', () => {
|
|
239
|
+
expect(parseMarkup('<important accessibilityLabel="Notice">critical</important>')).toEqual([
|
|
240
|
+
{
|
|
241
|
+
type: 'important',
|
|
242
|
+
accessibilityLabel: 'Notice',
|
|
243
|
+
children: [{ type: 'text', content: 'critical' }],
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('extracts accessibilityLabel from strong tags', () => {
|
|
249
|
+
expect(parseMarkup('<strong accessibilityLabel="Key point">bold</strong>')).toEqual([
|
|
250
|
+
{
|
|
251
|
+
type: 'strong',
|
|
252
|
+
accessibilityLabel: 'Key point',
|
|
253
|
+
children: [{ type: 'text', content: 'bold' }],
|
|
254
|
+
},
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('extracts accessibilityLabel from strikethrough tags', () => {
|
|
259
|
+
expect(
|
|
260
|
+
parseMarkup('<strikethrough accessibilityLabel="Was 50 GBP">£50</strikethrough>'),
|
|
261
|
+
).toEqual([
|
|
262
|
+
{
|
|
263
|
+
type: 'strikethrough',
|
|
264
|
+
accessibilityLabel: 'Was 50 GBP',
|
|
265
|
+
children: [{ type: 'text', content: '£50' }],
|
|
266
|
+
},
|
|
267
|
+
]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('does not extract accessibilityLabel from paragraph tags', () => {
|
|
271
|
+
expect(parseMarkup('<paragraph accessibilityLabel="ignored">text</paragraph>')).toEqual([
|
|
272
|
+
{ type: 'paragraph', children: [{ type: 'text', content: 'text' }] },
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('does not include accessibilityLabel key when attribute is absent', () => {
|
|
277
|
+
const result = parseMarkup('<important>text</important>');
|
|
278
|
+
expect(result[0]).not.toHaveProperty('accessibilityLabel');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('stack overflow via deep nesting (DoS)', () => {
|
|
283
|
+
it('handles deeply nested tags without crashing', () => {
|
|
284
|
+
const depth = 10000;
|
|
285
|
+
const input = `${'<important>'.repeat(depth)}x${'</important>'.repeat(depth)}`;
|
|
286
|
+
expect(() => parseMarkup(input)).not.toThrow();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('degrades gracefully at excessive depth', () => {
|
|
290
|
+
const depth = 10000;
|
|
291
|
+
const input = `${'<important>'.repeat(depth)}x${'</important>'.repeat(depth)}`;
|
|
292
|
+
const result = parseMarkup(input);
|
|
293
|
+
expect(result.length).toBeGreaterThan(0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('respects MAX_NESTING_DEPTH', () => {
|
|
297
|
+
const depth = MAX_NESTING_DEPTH + 20;
|
|
298
|
+
const input = `${'<important>'.repeat(depth)}x${'</important>'.repeat(depth)}`;
|
|
299
|
+
const result = parseMarkup(input);
|
|
300
|
+
const deepNode = JSON.stringify(result);
|
|
301
|
+
let nesting = 0;
|
|
302
|
+
let maxNesting = 0;
|
|
303
|
+
for (const char of deepNode) {
|
|
304
|
+
if (char === '{') nesting += 1;
|
|
305
|
+
if (char === '}') nesting -= 1;
|
|
306
|
+
maxNesting = Math.max(maxNesting, nesting);
|
|
307
|
+
}
|
|
308
|
+
expect(maxNesting).toBeLessThan(MAX_NESTING_DEPTH * 3);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('prototype pollution via action attribute', () => {
|
|
313
|
+
it('does not extract __proto__ as action value', () => {
|
|
314
|
+
const result = parseMarkup('<link action="__proto__">click</link>');
|
|
315
|
+
const linkNode = result[0];
|
|
316
|
+
expect(linkNode).toHaveProperty('type', 'link');
|
|
317
|
+
expect(linkNode).not.toHaveProperty('action');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('does not extract constructor as action value', () => {
|
|
321
|
+
const result = parseMarkup('<link action="constructor">click</link>');
|
|
322
|
+
const linkNode = result[0];
|
|
323
|
+
expect(linkNode).toHaveProperty('type', 'link');
|
|
324
|
+
expect(linkNode).not.toHaveProperty('action');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('does not extract toString as action value', () => {
|
|
328
|
+
const result = parseMarkup('<link action="toString">click</link>');
|
|
329
|
+
const linkNode = result[0];
|
|
330
|
+
expect(linkNode).toHaveProperty('type', 'link');
|
|
331
|
+
expect(linkNode).not.toHaveProperty('action');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('does not extract valueOf as action value', () => {
|
|
335
|
+
const result = parseMarkup('<link action="valueOf">click</link>');
|
|
336
|
+
const linkNode = result[0];
|
|
337
|
+
expect(linkNode).toHaveProperty('type', 'link');
|
|
338
|
+
expect(linkNode).not.toHaveProperty('action');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('does not extract hasOwnProperty as action value', () => {
|
|
342
|
+
const result = parseMarkup('<link action="hasOwnProperty">click</link>');
|
|
343
|
+
const linkNode = result[0];
|
|
344
|
+
expect(linkNode).toHaveProperty('type', 'link');
|
|
345
|
+
expect(linkNode).not.toHaveProperty('action');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('allows legitimate action values', () => {
|
|
349
|
+
const result = parseMarkup('<link action="save">click</link>');
|
|
350
|
+
const linkNode = result[0] as { type: string; action?: string };
|
|
351
|
+
expect(linkNode.action).toBe('save');
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('duplicate attribute injection', () => {
|
|
356
|
+
it('uses first href value when duplicated', () => {
|
|
357
|
+
const result = parseMarkup('<link href="/safe" href="javascript:alert(1)">click</link>');
|
|
358
|
+
const linkNode = result[0] as { type: string; href?: string };
|
|
359
|
+
expect(linkNode.href).toBe('/safe');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('uses first action value when duplicated', () => {
|
|
363
|
+
const result = parseMarkup('<link action="safe" action="evil">click</link>');
|
|
364
|
+
const linkNode = result[0] as { type: string; action?: string };
|
|
365
|
+
expect(linkNode.action).toBe('safe');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('uses first target value when duplicated', () => {
|
|
369
|
+
const result = parseMarkup('<link href="/x" target="_blank" target="_top">click</link>');
|
|
370
|
+
const linkNode = result[0] as { type: string; target?: string };
|
|
371
|
+
expect(linkNode.target).toBe('_blank');
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('HTML/script in accessibilityLabel', () => {
|
|
376
|
+
it('strips HTML from accessibilityLabel', () => {
|
|
377
|
+
const result = parseMarkup(
|
|
378
|
+
'<link href="/x" accessibilityLabel="click <here>">text</link>',
|
|
379
|
+
);
|
|
380
|
+
const linkNode = result[0] as { type: string; accessibilityLabel?: string };
|
|
381
|
+
expect(linkNode.accessibilityLabel).not.toContain('<');
|
|
382
|
+
expect(linkNode.accessibilityLabel).not.toContain('>');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('strips angle brackets from accessibilityLabel (defense-in-depth)', () => {
|
|
386
|
+
const result = parseMarkup(
|
|
387
|
+
'<important accessibilityLabel="test<b>bold</b>end">text</important>',
|
|
388
|
+
);
|
|
389
|
+
const node = result[0] as { type: string; accessibilityLabel?: string };
|
|
390
|
+
expect(node.accessibilityLabel).toBe('testbbold/bend');
|
|
391
|
+
expect(node.accessibilityLabel).not.toContain('<');
|
|
392
|
+
expect(node.accessibilityLabel).not.toContain('>');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('strips quotes from accessibilityLabel to prevent attribute injection', () => {
|
|
396
|
+
const result = parseMarkup(
|
|
397
|
+
'<important accessibilityLabel="x" onfocus="alert(1)">text</important>',
|
|
398
|
+
);
|
|
399
|
+
const node = result[0] as { type: string; accessibilityLabel?: string };
|
|
400
|
+
if (node.accessibilityLabel) {
|
|
401
|
+
expect(node.accessibilityLabel).not.toContain('"');
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('preserves safe accessibilityLabel text', () => {
|
|
406
|
+
const result = parseMarkup(
|
|
407
|
+
'<important accessibilityLabel="Navigate to settings page">text</important>',
|
|
408
|
+
);
|
|
409
|
+
const node = result[0] as { type: string; accessibilityLabel?: string };
|
|
410
|
+
expect(node.accessibilityLabel).toBe('Navigate to settings page');
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('unvalidated target attribute', () => {
|
|
415
|
+
it('allows target="_blank"', () => {
|
|
416
|
+
const result = parseMarkup('<link href="/x" target="_blank">text</link>');
|
|
417
|
+
const linkNode = result[0] as { type: string; target?: string };
|
|
418
|
+
expect(linkNode.target).toBe('_blank');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('allows target="_self"', () => {
|
|
422
|
+
const result = parseMarkup('<link href="/x" target="_self">text</link>');
|
|
423
|
+
const linkNode = result[0] as { type: string; target?: string };
|
|
424
|
+
expect(linkNode.target).toBe('_self');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('blocks target="_top" (iframe breakout)', () => {
|
|
428
|
+
const result = parseMarkup('<link href="/x" target="_top">text</link>');
|
|
429
|
+
const linkNode = result[0] as { type: string; target?: string };
|
|
430
|
+
expect(linkNode).not.toHaveProperty('target');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('blocks target="_parent" (iframe breakout)', () => {
|
|
434
|
+
const result = parseMarkup('<link href="/x" target="_parent">text</link>');
|
|
435
|
+
const linkNode = result[0] as { type: string; target?: string };
|
|
436
|
+
expect(linkNode).not.toHaveProperty('target');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('blocks arbitrary target values', () => {
|
|
440
|
+
const result = parseMarkup('<link href="/x" target="evil-frame">text</link>');
|
|
441
|
+
const linkNode = result[0] as { type: string; target?: string };
|
|
442
|
+
expect(linkNode).not.toHaveProperty('target');
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('slash as attribute separator bypass', () => {
|
|
447
|
+
it('does not parse attributes after / separator', () => {
|
|
448
|
+
const result = parseMarkup('<link/href="/evil">click</link>');
|
|
449
|
+
const linkNode = result[0] as { type: string; href?: string };
|
|
450
|
+
expect(linkNode).not.toHaveProperty('href');
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe('control characters in attribute values', () => {
|
|
455
|
+
it('strips control characters from href values', () => {
|
|
456
|
+
const result = parseMarkup('<link href="/path\x00evil">text</link>');
|
|
457
|
+
const linkNode = result[0] as { type: string; href?: string };
|
|
458
|
+
if (linkNode.href) {
|
|
459
|
+
expect(linkNode.href).not.toContain('\x00');
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('strips newlines from href values', () => {
|
|
464
|
+
const result = parseMarkup('<link href="/path\nevil">text</link>');
|
|
465
|
+
const linkNode = result[0] as { type: string; href?: string };
|
|
466
|
+
if (linkNode.href) {
|
|
467
|
+
expect(linkNode.href).not.toContain('\n');
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('strips tabs from href values', () => {
|
|
472
|
+
const result = parseMarkup('<link href="/path\tevil">text</link>');
|
|
473
|
+
const linkNode = result[0] as { type: string; href?: string };
|
|
474
|
+
if (linkNode.href) {
|
|
475
|
+
expect(linkNode.href).not.toContain('\t');
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('mismatched nesting (graceful degradation)', () => {
|
|
481
|
+
it('handles inner tag closed by outer close tag', () => {
|
|
482
|
+
const result = parseMarkup('<important><strong>text</important></strong>');
|
|
483
|
+
expect(result).toBeDefined();
|
|
484
|
+
expect(result.length).toBeGreaterThan(0);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('handles interleaved open/close tags', () => {
|
|
488
|
+
const result = parseMarkup('<important><strong>text</important>more</strong>');
|
|
489
|
+
expect(result).toBeDefined();
|
|
490
|
+
expect(result.length).toBeGreaterThan(0);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe('tag-like content inside attribute values', () => {
|
|
495
|
+
it('captures full href even when it contains > inside quotes', () => {
|
|
496
|
+
const result = parseMarkup('<link href="</link>">remaining text</link>');
|
|
497
|
+
// TAG_REGEX sees through > inside quotes — full href is captured
|
|
498
|
+
// sanitiseHref (at render time) blocks it because it contains <
|
|
499
|
+
expect(result).toEqual([
|
|
500
|
+
{
|
|
501
|
+
type: 'link',
|
|
502
|
+
href: '</link>',
|
|
503
|
+
children: [{ type: 'text', content: 'remaining text' }],
|
|
504
|
+
},
|
|
505
|
+
]);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe('empty tags and self-closing patterns', () => {
|
|
510
|
+
it('parses empty element', () => {
|
|
511
|
+
expect(parseMarkup('<important></important>')).toEqual([{ type: 'important', children: [] }]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('parses empty link', () => {
|
|
515
|
+
expect(parseMarkup('<link href="/x"></link>')).toEqual([
|
|
516
|
+
{ type: 'link', href: '/x', children: [] },
|
|
517
|
+
]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('handles self-closing slash as unclosed tag', () => {
|
|
521
|
+
// <important/> matches TAG_REGEX as open tag with attrs="/", lacks close → text fallback
|
|
522
|
+
const result = parseMarkup('<important/>');
|
|
523
|
+
expect(result).toEqual([{ type: 'text', content: '<important/>' }]);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe('SVG and IMG injection (regression — must remain as plain text)', () => {
|
|
528
|
+
it('treats <img> with onerror as plain text', () => {
|
|
529
|
+
expect(parseMarkup('<img src=x onerror=alert(1)>')).toEqual([
|
|
530
|
+
{ type: 'text', content: '<img src=x onerror=alert(1)>' },
|
|
531
|
+
]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('treats <img/> with onerror as plain text', () => {
|
|
535
|
+
expect(parseMarkup('<img/src=x onerror=alert(1)>')).toEqual([
|
|
536
|
+
{ type: 'text', content: '<img/src=x onerror=alert(1)>' },
|
|
537
|
+
]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('treats <svg> with onload as plain text', () => {
|
|
541
|
+
expect(parseMarkup('<svg onload=alert(1)>')).toEqual([
|
|
542
|
+
{ type: 'text', content: '<svg onload=alert(1)>' },
|
|
543
|
+
]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('treats <svg/> with onload as plain text', () => {
|
|
547
|
+
expect(parseMarkup('<svg/onload=alert(1)>')).toEqual([
|
|
548
|
+
{ type: 'text', content: '<svg/onload=alert(1)>' },
|
|
549
|
+
]);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('treats <svg><script>...</script></svg> as plain text', () => {
|
|
553
|
+
expect(parseMarkup('<svg><script>alert(1)</script></svg>')).toEqual([
|
|
554
|
+
{ type: 'text', content: '<svg><script>alert(1)</script></svg>' },
|
|
555
|
+
]);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('treats <img> inside allowed element as text child', () => {
|
|
559
|
+
const result = parseMarkup('<important><img src=x onerror=alert(1)></important>');
|
|
560
|
+
expect(result).toEqual([
|
|
561
|
+
{
|
|
562
|
+
type: 'important',
|
|
563
|
+
children: [{ type: 'text', content: '<img src=x onerror=alert(1)>' }],
|
|
564
|
+
},
|
|
565
|
+
]);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('treats <svg> inside allowed element as text child', () => {
|
|
569
|
+
const result = parseMarkup('<important><svg onload=alert(1)></important>');
|
|
570
|
+
expect(result).toEqual([
|
|
571
|
+
{
|
|
572
|
+
type: 'important',
|
|
573
|
+
children: [{ type: 'text', content: '<svg onload=alert(1)>' }],
|
|
574
|
+
},
|
|
575
|
+
]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('treats <img> inside link as text child', () => {
|
|
579
|
+
const result = parseMarkup('<link href="/safe"><img src=x onerror=alert(1)></link>');
|
|
580
|
+
expect(result).toEqual([
|
|
581
|
+
{
|
|
582
|
+
type: 'link',
|
|
583
|
+
href: '/safe',
|
|
584
|
+
children: [{ type: 'text', content: '<img src=x onerror=alert(1)>' }],
|
|
585
|
+
},
|
|
586
|
+
]);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('treats <img> between text as plain text', () => {
|
|
590
|
+
expect(parseMarkup('text <img src=x onerror=alert(1)> more text')).toEqual([
|
|
591
|
+
{ type: 'text', content: 'text <img src=x onerror=alert(1)> more text' },
|
|
592
|
+
]);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('treats <svg/onload> between text as plain text', () => {
|
|
596
|
+
expect(parseMarkup('before <svg/onload=alert(1)> after')).toEqual([
|
|
597
|
+
{ type: 'text', content: 'before <svg/onload=alert(1)> after' },
|
|
598
|
+
]);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('treats <iframe> as plain text', () => {
|
|
602
|
+
expect(parseMarkup('<iframe src="javascript:alert(1)">')).toEqual([
|
|
603
|
+
{ type: 'text', content: '<iframe src="javascript:alert(1)">' },
|
|
604
|
+
]);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('treats <object> as plain text', () => {
|
|
608
|
+
expect(parseMarkup('<object data="javascript:alert(1)">')).toEqual([
|
|
609
|
+
{ type: 'text', content: '<object data="javascript:alert(1)">' },
|
|
610
|
+
]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('treats <embed> as plain text', () => {
|
|
614
|
+
expect(parseMarkup('<embed src="javascript:alert(1)">')).toEqual([
|
|
615
|
+
{ type: 'text', content: '<embed src="javascript:alert(1)">' },
|
|
616
|
+
]);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('treats <body onload> as plain text', () => {
|
|
620
|
+
expect(parseMarkup('<body onload=alert(1)>')).toEqual([
|
|
621
|
+
{ type: 'text', content: '<body onload=alert(1)>' },
|
|
622
|
+
]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('treats <input> with event handlers as plain text', () => {
|
|
626
|
+
expect(parseMarkup('<input onfocus=alert(1) autofocus>')).toEqual([
|
|
627
|
+
{ type: 'text', content: '<input onfocus=alert(1) autofocus>' },
|
|
628
|
+
]);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('treats <details ontoggle> as plain text', () => {
|
|
632
|
+
expect(parseMarkup('<details open ontoggle=alert(1)>')).toEqual([
|
|
633
|
+
{ type: 'text', content: '<details open ontoggle=alert(1)>' },
|
|
634
|
+
]);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe('SVG/IMG in attribute values', () => {
|
|
639
|
+
it('extracts href containing img tag (blocked by sanitiseHref at render)', () => {
|
|
640
|
+
const result = parseMarkup('<link href="<img src=x onerror=alert(1)>">text</link>');
|
|
641
|
+
// TAG_REGEX now captures > inside quotes — full href is extracted
|
|
642
|
+
// sanitiseHref blocks it at render time (contains < and >)
|
|
643
|
+
expect(result).toEqual([
|
|
644
|
+
{
|
|
645
|
+
type: 'link',
|
|
646
|
+
href: '<img src=x onerror=alert(1)>',
|
|
647
|
+
children: [{ type: 'text', content: 'text' }],
|
|
648
|
+
},
|
|
649
|
+
]);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('strips HTML metacharacters from accessibilityLabel containing svg', () => {
|
|
653
|
+
const result = parseMarkup(
|
|
654
|
+
'<important accessibilityLabel="<svg onload=alert(1)>">text</important>',
|
|
655
|
+
);
|
|
656
|
+
// TAG_REGEX captures the full value; sanitizeAttributeValue strips < > " '
|
|
657
|
+
const node = result[0] as { type: string; accessibilityLabel?: string };
|
|
658
|
+
expect(node.accessibilityLabel).toBe('svg onload=alert(1)');
|
|
659
|
+
expect(node.accessibilityLabel).not.toContain('<');
|
|
660
|
+
expect(node.accessibilityLabel).not.toContain('>');
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe('input length limit (DoS prevention)', () => {
|
|
665
|
+
it('returns input as plain text when exceeding MAX_INPUT_LENGTH', () => {
|
|
666
|
+
const input = 'x'.repeat(MAX_INPUT_LENGTH + 1);
|
|
667
|
+
expect(parseMarkup(input)).toEqual([{ type: 'text', content: input }]);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('parses normally at exactly MAX_INPUT_LENGTH', () => {
|
|
671
|
+
const input = `<important>${'x'.repeat(MAX_INPUT_LENGTH - 24)}</important>`;
|
|
672
|
+
const result = parseMarkup(input);
|
|
673
|
+
expect(result[0]).toHaveProperty('type', 'important');
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('does not parse markup tags in oversized input', () => {
|
|
677
|
+
const input = `<important>text</important>${'x'.repeat(MAX_INPUT_LENGTH)}`;
|
|
678
|
+
const result = parseMarkup(input);
|
|
679
|
+
expect(result).toEqual([{ type: 'text', content: input }]);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe('accessibilityLabel length limit', () => {
|
|
684
|
+
it('truncates accessibilityLabel exceeding MAX_ACCESSIBILITY_LABEL_LENGTH', () => {
|
|
685
|
+
const longLabel = 'a'.repeat(MAX_ACCESSIBILITY_LABEL_LENGTH + 100);
|
|
686
|
+
const result = parseMarkup(`<important accessibilityLabel="${longLabel}">text</important>`);
|
|
687
|
+
const node = result[0] as { type: string; accessibilityLabel?: string };
|
|
688
|
+
expect(node.accessibilityLabel).toHaveLength(MAX_ACCESSIBILITY_LABEL_LENGTH);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('preserves label at exactly MAX_ACCESSIBILITY_LABEL_LENGTH', () => {
|
|
692
|
+
const exactLabel = 'b'.repeat(MAX_ACCESSIBILITY_LABEL_LENGTH);
|
|
693
|
+
const result = parseMarkup(`<important accessibilityLabel="${exactLabel}">text</important>`);
|
|
694
|
+
const node = result[0] as { type: string; accessibilityLabel?: string };
|
|
695
|
+
expect(node.accessibilityLabel).toBe(exactLabel);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('preserves short labels unchanged', () => {
|
|
699
|
+
const result = parseMarkup(
|
|
700
|
+
'<important accessibilityLabel="Navigate to settings">text</important>',
|
|
701
|
+
);
|
|
702
|
+
const node = result[0] as { type: string; accessibilityLabel?: string };
|
|
703
|
+
expect(node.accessibilityLabel).toBe('Navigate to settings');
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
});
|