@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,499 @@
|
|
|
1
|
+
import { isHrefAllowedByMode, sanitiseHref } from './sanitise';
|
|
2
|
+
|
|
3
|
+
describe('sanitiseHref', () => {
|
|
4
|
+
describe('allowed protocols', () => {
|
|
5
|
+
it('allows https URLs', () => {
|
|
6
|
+
expect(sanitiseHref('https://example.com')).toBeDefined();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('allows mailto URLs', () => {
|
|
10
|
+
expect(sanitiseHref('mailto:user@example.com')).toBeDefined();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('blocks http URLs', () => {
|
|
14
|
+
expect(sanitiseHref('http://example.com')).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('blocks tel URLs', () => {
|
|
18
|
+
expect(sanitiseHref('tel:+1234567890')).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('allowed relative paths', () => {
|
|
23
|
+
it('allows root-relative paths', () => {
|
|
24
|
+
expect(sanitiseHref('/settings')).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('allows hash fragments', () => {
|
|
28
|
+
expect(sanitiseHref('#section')).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('blocked relative paths', () => {
|
|
33
|
+
it('blocks dot-relative paths', () => {
|
|
34
|
+
expect(sanitiseHref('./page')).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('blocks parent-relative paths', () => {
|
|
38
|
+
expect(sanitiseHref('../page')).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('blocks dot-relative paths with query parameters', () => {
|
|
42
|
+
expect(sanitiseHref('./page?time=10:30')).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('blocks bare relative paths with colons in segments', () => {
|
|
46
|
+
expect(sanitiseHref('page?key=value:other')).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('blocked protocols', () => {
|
|
51
|
+
it('blocks javascript: protocol', () => {
|
|
52
|
+
// eslint-disable-next-line no-script-url
|
|
53
|
+
expect(sanitiseHref('javascript:alert(1)')).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('blocks javascript: with mixed case', () => {
|
|
57
|
+
// eslint-disable-next-line no-script-url
|
|
58
|
+
expect(sanitiseHref('JavaScript:alert(1)')).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('blocks data: protocol', () => {
|
|
62
|
+
expect(sanitiseHref('data:text/html,<script>alert(1)</script>')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('blocks vbscript: protocol', () => {
|
|
66
|
+
expect(sanitiseHref('vbscript:MsgBox("xss")')).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('blocks unknown protocols', () => {
|
|
70
|
+
expect(sanitiseHref('ftp://evil.com')).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('protocol-relative URLs', () => {
|
|
75
|
+
it('blocks protocol-relative URLs', () => {
|
|
76
|
+
expect(sanitiseHref('//evil.com/phishing')).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('blocks protocol-relative URLs with whitespace', () => {
|
|
80
|
+
expect(sanitiseHref(' //evil.com ')).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('edge cases', () => {
|
|
85
|
+
it('handles empty string', () => {
|
|
86
|
+
expect(sanitiseHref('')).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('handles whitespace-padded URLs', () => {
|
|
90
|
+
expect(sanitiseHref(' https://example.com ')).toBe('https://example.com');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('handles whitespace-padded dangerous URLs', () => {
|
|
94
|
+
expect(sanitiseHref(' javascript:alert(1) ')).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('control characters in scheme — XSS bypass via URL parser throw', () => {
|
|
99
|
+
it('blocks null byte mid-scheme (java\\x00script:)', () => {
|
|
100
|
+
expect(sanitiseHref('java\x00script:alert(1)')).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('blocks null byte between s and c (javas\\x00cript:)', () => {
|
|
104
|
+
expect(sanitiseHref('javas\x00cript:alert(1)')).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('blocks SOH prefix before javascript:', () => {
|
|
108
|
+
expect(sanitiseHref('\x01javascript:alert(1)')).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('blocks vertical tab prefix before javascript:', () => {
|
|
112
|
+
expect(sanitiseHref('\x0Bjavascript:alert(1)')).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('blocks form feed prefix before javascript:', () => {
|
|
116
|
+
expect(sanitiseHref('\x0Cjavascript:alert(1)')).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('blocks tab mid-scheme (java\\tscript:)', () => {
|
|
120
|
+
expect(sanitiseHref('java\tscript:alert(1)')).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('blocks CRLF mid-scheme (java\\r\\nscript:)', () => {
|
|
124
|
+
expect(sanitiseHref('java\r\nscript:alert(1)')).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('blocks LF mid-scheme (j\\navascript:)', () => {
|
|
128
|
+
expect(sanitiseHref('j\navascript:alert(1)')).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('zero-width and invisible character bypass', () => {
|
|
133
|
+
it('blocks zero-width space prefix before javascript:', () => {
|
|
134
|
+
expect(sanitiseHref('javascript:alert(1)')).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('blocks zero-width space only (invisible link)', () => {
|
|
138
|
+
expect(sanitiseHref('')).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('blocks BOM-only string (invisible link)', () => {
|
|
142
|
+
expect(sanitiseHref('')).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('blocks BOM prefix before javascript:', () => {
|
|
146
|
+
expect(sanitiseHref('javascript:alert(1)')).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('blocks zero-width non-joiner prefix before javascript:', () => {
|
|
150
|
+
expect(sanitiseHref('javascript:alert(1)')).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('blocks zero-width joiner prefix before javascript:', () => {
|
|
154
|
+
expect(sanitiseHref('javascript:alert(1)')).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('non-ASCII whitespace prefix bypass', () => {
|
|
159
|
+
it('blocks NBSP prefix before javascript:', () => {
|
|
160
|
+
expect(sanitiseHref(' javascript:alert(1)')).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('blocks line separator prefix before javascript:', () => {
|
|
164
|
+
expect(sanitiseHref('\u2028javascript:alert(1)')).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('blocks paragraph separator prefix before javascript:', () => {
|
|
168
|
+
expect(sanitiseHref('\u2029javascript:alert(1)')).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('blocks NBSP before protocol-relative URL', () => {
|
|
172
|
+
expect(sanitiseHref(' //evil.com')).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('blocks NBSP-only string', () => {
|
|
176
|
+
expect(sanitiseHref(' ')).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('backslash-based open redirect', () => {
|
|
181
|
+
it('blocks /\\evil.com (browsers normalize \\ to /)', () => {
|
|
182
|
+
expect(sanitiseHref('/\\evil.com')).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('blocks /\\\\evil.com (double backslash)', () => {
|
|
186
|
+
expect(sanitiseHref('/\\\\evil.com')).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('allows /path\\with\\backslash (not at position 1)', () => {
|
|
190
|
+
expect(sanitiseHref('/path\\with\\backslash')).toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('URL-encoded dangerous schemes', () => {
|
|
195
|
+
it('blocks javascript with URL-encoded colon (%3A)', () => {
|
|
196
|
+
expect(sanitiseHref('javascript%3Aalert(1)')).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('blocks javascript with lowercase URL-encoded colon (%3a)', () => {
|
|
200
|
+
expect(sanitiseHref('javascript%3aalert(1)')).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('credential confusion / phishing via userinfo', () => {
|
|
205
|
+
it('blocks https URLs with misleading userinfo (good.com@evil.com)', () => {
|
|
206
|
+
expect(sanitiseHref('https://good.com@evil.com')).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('blocks https URLs with port-like credentials', () => {
|
|
210
|
+
expect(sanitiseHref('https://evil.com:443@good.com')).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('allows https URLs without userinfo', () => {
|
|
214
|
+
expect(sanitiseHref('https://example.com/path')).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('allows mailto with @ (expected in email addresses)', () => {
|
|
218
|
+
expect(sanitiseHref('mailto:user@example.com')).toBeDefined();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('degenerate HTTPS URLs', () => {
|
|
223
|
+
it('blocks https:evil.com (no slashes — ambiguous host)', () => {
|
|
224
|
+
expect(sanitiseHref('https:evil.com')).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('blocks https:/evil.com (single slash — ambiguous)', () => {
|
|
228
|
+
expect(sanitiseHref('https:/evil.com')).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('blocks https:// (no host)', () => {
|
|
232
|
+
expect(sanitiseHref('https://')).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('blocks https:/// (empty host)', () => {
|
|
236
|
+
expect(sanitiseHref('https:///')).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('internal network / SSRF via allowed HTTPS', () => {
|
|
241
|
+
it('blocks https://127.0.0.1 (localhost)', () => {
|
|
242
|
+
expect(sanitiseHref('https://127.0.0.1/')).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('blocks https://[::1] (IPv6 localhost)', () => {
|
|
246
|
+
expect(sanitiseHref('https://[::1]/')).toBeUndefined();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('blocks hex-encoded IP (0x7f000001)', () => {
|
|
250
|
+
expect(sanitiseHref('https://0x7f000001/')).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('blocks decimal IP (2130706433)', () => {
|
|
254
|
+
expect(sanitiseHref('https://2130706433/')).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('blocks octal IP (0177.0.0.1)', () => {
|
|
258
|
+
expect(sanitiseHref('https://0177.0.0.1/')).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('blocks AWS metadata endpoint', () => {
|
|
262
|
+
expect(sanitiseHref('https://169.254.169.254/')).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('blocks GCP metadata endpoint', () => {
|
|
266
|
+
expect(sanitiseHref('https://metadata.google.internal/')).toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('blocks localhost with trailing dot', () => {
|
|
270
|
+
expect(sanitiseHref('https://localhost./')).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('blocks .internal host with trailing dot', () => {
|
|
274
|
+
expect(sanitiseHref('https://metadata.google.internal./')).toBeUndefined();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('blocks fully expanded IPv6 loopback', () => {
|
|
278
|
+
expect(sanitiseHref('https://[0:0:0:0:0:0:0:1]/')).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('HTML metacharacters in fragment and path (inert in React rendering context)', () => {
|
|
283
|
+
it('allows angle brackets in hash fragment (React escapes via DOM property)', () => {
|
|
284
|
+
expect(sanitiseHref('#<img onerror=alert(1)>')).toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('allows angle brackets in path (React escapes via DOM property)', () => {
|
|
288
|
+
expect(sanitiseHref('/<img onerror=alert(1)>')).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('allows quotes in hash fragment (React sets href via property, not innerHTML)', () => {
|
|
292
|
+
expect(sanitiseHref('#" onclick="alert(1)')).toBeDefined();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('allows normal hash fragments', () => {
|
|
296
|
+
expect(sanitiseHref('#section-1')).toBeDefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('allows normal root-relative paths', () => {
|
|
300
|
+
expect(sanitiseHref('/normal/path?query=value')).toBeDefined();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('UNC paths (Windows network shares)', () => {
|
|
305
|
+
it('blocks UNC path to external server', () => {
|
|
306
|
+
expect(sanitiseHref('\\\\evil.com\\share')).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('space before colon in scheme', () => {
|
|
311
|
+
it('blocks "javascript :alert(1)" (space before colon)', () => {
|
|
312
|
+
expect(sanitiseHref('javascript :alert(1)')).toBeUndefined();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('mailto abuse', () => {
|
|
317
|
+
it('blocks mailto with XSS in body parameter', () => {
|
|
318
|
+
expect(sanitiseHref('mailto:evil@evil.com?body=<script>alert(1)</script>')).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('blocks mailto with header injection (cc/bcc)', () => {
|
|
322
|
+
expect(
|
|
323
|
+
sanitiseHref('mailto:evil@evil.com?cc=victim@good.com&bcc=spy@evil.com'),
|
|
324
|
+
).toBeUndefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('blocks mailto with no recipient (social engineering)', () => {
|
|
328
|
+
expect(sanitiseHref('mailto:?subject=urgent&body=click%20here')).toBeUndefined();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('allows simple mailto with just an address', () => {
|
|
332
|
+
expect(sanitiseHref('mailto:support@example.com')).toBeDefined();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('additional blocked protocols (regression)', () => {
|
|
337
|
+
it('blocks file: protocol', () => {
|
|
338
|
+
expect(sanitiseHref('file:///etc/passwd')).toBeUndefined();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('blocks ws: protocol', () => {
|
|
342
|
+
expect(sanitiseHref('ws://evil.com')).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('blocks wss: protocol', () => {
|
|
346
|
+
expect(sanitiseHref('wss://evil.com')).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('blocks blob: protocol', () => {
|
|
350
|
+
expect(sanitiseHref('blob:https://evil.com/uuid')).toBeUndefined();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('blocks data: with SVG XSS', () => {
|
|
354
|
+
expect(sanitiseHref('data:image/svg+xml,<svg onload=alert(1)>')).toBeUndefined();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('blocks data: with base64-encoded script', () => {
|
|
358
|
+
expect(
|
|
359
|
+
sanitiseHref('data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='),
|
|
360
|
+
).toBeUndefined();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('blocks view-source: protocol', () => {
|
|
364
|
+
expect(sanitiseHref('view-source:https://evil.com')).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('blocks custom protocol (foo:bar)', () => {
|
|
368
|
+
expect(sanitiseHref('foo:bar')).toBeUndefined();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('blocks minimal protocol pattern (a:b)', () => {
|
|
372
|
+
expect(sanitiseHref('a:b')).toBeUndefined();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('blocks customprotocol:payload', () => {
|
|
376
|
+
expect(sanitiseHref('customprotocol:payload')).toBeUndefined();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('whitespace-only inputs', () => {
|
|
381
|
+
it('blocks spaces only', () => {
|
|
382
|
+
expect(sanitiseHref(' ')).toBeUndefined();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('blocks tab/newline/CR only', () => {
|
|
386
|
+
expect(sanitiseHref('\t\n\r')).toBeUndefined();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('isHrefAllowedByMode', () => {
|
|
392
|
+
describe('internal mode', () => {
|
|
393
|
+
it('allows root-relative paths', () => {
|
|
394
|
+
expect(isHrefAllowedByMode('/accounts', 'internal')).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('allows deep root-relative paths', () => {
|
|
398
|
+
expect(isHrefAllowedByMode('/accounts/123/details', 'internal')).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('allows wise.com URLs', () => {
|
|
402
|
+
expect(isHrefAllowedByMode('https://wise.com/help', 'internal')).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('allows wise.com deep paths', () => {
|
|
406
|
+
expect(isHrefAllowedByMode('https://wise.com/gb/send-money', 'internal')).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('blocks external domains', () => {
|
|
410
|
+
expect(isHrefAllowedByMode('https://example.com', 'internal')).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('blocks domains with wise.com as substring', () => {
|
|
414
|
+
expect(isHrefAllowedByMode('https://wise.company.com', 'internal')).toBe(false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('blocks hash fragments', () => {
|
|
418
|
+
expect(isHrefAllowedByMode('#section', 'internal')).toBe(false);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('blocks mailto links', () => {
|
|
422
|
+
expect(isHrefAllowedByMode('mailto:user@wise.com', 'internal')).toBe(false);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('URL-parser normalization bypass prevention', () => {
|
|
426
|
+
it('blocks /\\evil.com (backslash normalised to slash by browsers)', () => {
|
|
427
|
+
expect(isHrefAllowedByMode('/\\evil.com', 'internal')).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('blocks /\\\\evil.com (double backslash)', () => {
|
|
431
|
+
expect(isHrefAllowedByMode('/\\\\evil.com', 'internal')).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('allows /%2f/evil.com (percent-encoded slash stays as path segment)', () => {
|
|
435
|
+
expect(isHrefAllowedByMode('/%2f/evil.com', 'internal')).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('allows normal path with encoded characters', () => {
|
|
439
|
+
expect(isHrefAllowedByMode('/path/%E2%9C%93/done', 'internal')).toBe(true);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('blocks path that resolves to a different origin via URL parser', () => {
|
|
443
|
+
expect(isHrefAllowedByMode('/\\@evil.com', 'internal')).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('allows root path (/)', () => {
|
|
447
|
+
expect(isHrefAllowedByMode('/', 'internal')).toBe(true);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('allows paths with query parameters', () => {
|
|
451
|
+
expect(isHrefAllowedByMode('/search?q=hello', 'internal')).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('allows paths with hash fragments', () => {
|
|
455
|
+
expect(isHrefAllowedByMode('/page#section', 'internal')).toBe(true);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe('all mode', () => {
|
|
461
|
+
it('allows root-relative paths', () => {
|
|
462
|
+
expect(isHrefAllowedByMode('/settings', 'all')).toBe(true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('allows external domains', () => {
|
|
466
|
+
expect(isHrefAllowedByMode('https://example.com', 'all')).toBe(true);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('allows wise.com URLs', () => {
|
|
470
|
+
expect(isHrefAllowedByMode('https://wise.com/help', 'all')).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('allows hash fragments', () => {
|
|
474
|
+
expect(isHrefAllowedByMode('#section', 'all')).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('allows mailto links', () => {
|
|
478
|
+
expect(isHrefAllowedByMode('mailto:user@example.com', 'all')).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('none mode', () => {
|
|
483
|
+
it('blocks root-relative paths', () => {
|
|
484
|
+
expect(isHrefAllowedByMode('/accounts', 'none')).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('blocks wise.com URLs', () => {
|
|
488
|
+
expect(isHrefAllowedByMode('https://wise.com/help', 'none')).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('blocks external domains', () => {
|
|
492
|
+
expect(isHrefAllowedByMode('https://example.com', 'none')).toBe(false);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('blocks hash fragments', () => {
|
|
496
|
+
expect(isHrefAllowedByMode('#section', 'none')).toBe(false);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ALLOWED_PROTOCOLS } from '../constants';
|
|
2
|
+
|
|
3
|
+
const INVISIBLE_CHARS = /[\u200b\u200c\u200d\ufeff\u00ad\u2060\u180e]/g;
|
|
4
|
+
// eslint-disable-next-line regexp/no-control-character
|
|
5
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
6
|
+
const DANGEROUS_SCHEME = /^(?:javascript|vbscript|data|jscript)\s*:/i;
|
|
7
|
+
// eslint-disable-next-line regexp/no-useless-non-capturing-group
|
|
8
|
+
const ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;
|
|
9
|
+
const IPV6_BRACKETS = /^\[|\]$/g;
|
|
10
|
+
const NUMERIC_IP = /^[\d.]+$/;
|
|
11
|
+
const HEX_IP = /^0x[0-9a-f]+$/i;
|
|
12
|
+
|
|
13
|
+
export function sanitiseHref(href: string): string | undefined {
|
|
14
|
+
const cleaned = href.replace(INVISIBLE_CHARS, '');
|
|
15
|
+
const trimmed = cleaned.trim();
|
|
16
|
+
|
|
17
|
+
if (!trimmed) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (CONTROL_CHARS.test(trimmed)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (DANGEROUS_SCHEME.test(trimmed)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (ENCODED_COLON_SCHEME.test(trimmed)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (trimmed.startsWith('//')) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (trimmed.startsWith('\\')) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (trimmed.startsWith('/')) {
|
|
42
|
+
return trimmed[1] !== '\\' ? trimmed : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (trimmed.startsWith('#')) {
|
|
46
|
+
return trimmed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(trimmed);
|
|
55
|
+
|
|
56
|
+
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (url.protocol === 'https:') {
|
|
61
|
+
if (!trimmed.startsWith('https://')) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!url.hostname) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (url.username || url.password) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isInternalHost(url.hostname)) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (url.protocol === 'mailto:') {
|
|
79
|
+
if (url.search) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!url.pathname) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return trimmed;
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type AllowLinksMode = 'internal' | 'all' | 'none';
|
|
95
|
+
|
|
96
|
+
// Synthetic origin used to resolve relative paths via the WHATWG URL parser.
|
|
97
|
+
// If the resolved origin still equals this sentinel, the path stays on-origin.
|
|
98
|
+
const SSR_SENTINEL_ORIGIN = 'https://internal.invalid';
|
|
99
|
+
|
|
100
|
+
export function isHrefAllowedByMode(href: string, mode: AllowLinksMode): boolean {
|
|
101
|
+
switch (mode) {
|
|
102
|
+
case 'none':
|
|
103
|
+
return false;
|
|
104
|
+
case 'all':
|
|
105
|
+
return true;
|
|
106
|
+
case 'internal':
|
|
107
|
+
try {
|
|
108
|
+
if (!href.startsWith('/')) {
|
|
109
|
+
const parsed = new URL(href);
|
|
110
|
+
return parsed.protocol === 'https:' && parsed.hostname === 'wise.com';
|
|
111
|
+
}
|
|
112
|
+
const parsed = new URL(href, SSR_SENTINEL_ORIGIN);
|
|
113
|
+
return parsed.origin === SSR_SENTINEL_ORIGIN;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isInternalHost(hostname: string): boolean {
|
|
121
|
+
const stripped = hostname.replace(IPV6_BRACKETS, '');
|
|
122
|
+
const host = stripped.endsWith('.') ? stripped.slice(0, -1) : stripped;
|
|
123
|
+
if (NUMERIC_IP.test(host)) return true;
|
|
124
|
+
if (HEX_IP.test(host)) return true;
|
|
125
|
+
if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;
|
|
126
|
+
if (host === 'localhost') return true;
|
|
127
|
+
if (host.endsWith('.internal')) return true;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
package/src/i18n/hu.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"neptune.Field.characterCount": "Felhasználva {current}/{max} karakter",
|
|
24
24
|
"neptune.FlowNavigation.back": "vissza az előző lépéshez",
|
|
25
25
|
"neptune.Info.ariaLabel": "További információ",
|
|
26
|
-
"neptune.Label.optional": "(
|
|
26
|
+
"neptune.Label.optional": "(Választható)",
|
|
27
27
|
"neptune.Link.opensInNewTab": "(új lapon nyílik meg)",
|
|
28
28
|
"neptune.MoneyInput.Select.placeholder": "Válassz ki egy lehetőséget...",
|
|
29
29
|
"neptune.MoneyInput.Select.searchPlaceholder": "Írj be egy pénznemet vagy országot",
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,7 @@ export type { LegacyListItemProps } from './_deprecated/LegacyListItem';
|
|
|
94
94
|
export type { LinkProps } from './Link';
|
|
95
95
|
export type { LoaderProps } from './Loader';
|
|
96
96
|
export type { MarkdownProps } from './Markdown';
|
|
97
|
+
export type { MarkupActionHandler, MarkupActions, MarkupNode, MarkupProps } from './Markup';
|
|
97
98
|
export type { ModalProps } from './Modal';
|
|
98
99
|
export type { MoneyProps } from './Money';
|
|
99
100
|
export type {
|
|
@@ -253,6 +254,7 @@ export { default as LegacyListItem } from './_deprecated/LegacyListItem';
|
|
|
253
254
|
export { default as Loader } from './Loader';
|
|
254
255
|
export { default as Logo } from './Logo';
|
|
255
256
|
export { default as Markdown } from './Markdown';
|
|
257
|
+
export { default as Markup } from './Markup';
|
|
256
258
|
export { default as Modal } from './Modal';
|
|
257
259
|
export { default as Money } from './Money';
|
|
258
260
|
export { default as MoneyInput } from './MoneyInput';
|
package/src/main.css
CHANGED
|
@@ -30917,6 +30917,41 @@ button.np-link {
|
|
|
30917
30917
|
height: var(--wds-logo-height);
|
|
30918
30918
|
}
|
|
30919
30919
|
|
|
30920
|
+
.wds-markup-emphasis {
|
|
30921
|
+
font-weight: 600;
|
|
30922
|
+
font-weight: var(--font-weight-semi-bold);
|
|
30923
|
+
font-style: normal;
|
|
30924
|
+
}
|
|
30925
|
+
|
|
30926
|
+
.wds-markup-emphasis--important {
|
|
30927
|
+
color: #37517e;
|
|
30928
|
+
color: var(--color-content-primary);
|
|
30929
|
+
}
|
|
30930
|
+
|
|
30931
|
+
.wds-markup-emphasis--positive {
|
|
30932
|
+
color: #008026;
|
|
30933
|
+
color: var(--color-content-positive);
|
|
30934
|
+
}
|
|
30935
|
+
|
|
30936
|
+
.wds-markup-emphasis--negative {
|
|
30937
|
+
color: #cf2929;
|
|
30938
|
+
color: var(--color-content-negative);
|
|
30939
|
+
}
|
|
30940
|
+
|
|
30941
|
+
.wds-markup-strong {
|
|
30942
|
+
font-weight: 600;
|
|
30943
|
+
font-weight: var(--font-weight-semi-bold);
|
|
30944
|
+
}
|
|
30945
|
+
|
|
30946
|
+
.wds-markup-strikethrough {
|
|
30947
|
+
-webkit-text-decoration: line-through;
|
|
30948
|
+
text-decoration: line-through;
|
|
30949
|
+
}
|
|
30950
|
+
|
|
30951
|
+
.wds-markup-paragraph {
|
|
30952
|
+
margin: 0;
|
|
30953
|
+
}
|
|
30954
|
+
|
|
30920
30955
|
.tw-modal--scrollable {
|
|
30921
30956
|
max-height: 100%;
|
|
30922
30957
|
/* mobile viewport bug fix */
|