@transferwise/components 0.0.0-experimental-c388243 → 0.0.0-experimental-18063d6

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.
Files changed (75) hide show
  1. package/build/chips/Chip.js.map +1 -1
  2. package/build/chips/Chip.mjs.map +1 -1
  3. package/build/chips/Chips.js.map +1 -1
  4. package/build/chips/Chips.mjs.map +1 -1
  5. package/build/index.js +0 -2
  6. package/build/index.js.map +1 -1
  7. package/build/index.mjs +0 -1
  8. package/build/index.mjs.map +1 -1
  9. package/build/main.css +0 -35
  10. package/build/styles/main.css +0 -35
  11. package/build/types/body/Body.d.ts +2 -2
  12. package/build/types/chips/Chip.d.ts +3 -3
  13. package/build/types/chips/Chip.d.ts.map +1 -1
  14. package/build/types/chips/Chips.d.ts +10 -9
  15. package/build/types/chips/Chips.d.ts.map +1 -1
  16. package/build/types/iconButton/IconButton.d.ts +1 -1
  17. package/build/types/index.d.ts +0 -2
  18. package/build/types/index.d.ts.map +1 -1
  19. package/build/types/title/Title.d.ts +2 -2
  20. package/build/types/upload/steps/uploadImageStep/uploadImageStep.d.ts +1 -1
  21. package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts +1 -1
  22. package/package.json +3 -3
  23. package/src/chips/Chip.tsx +4 -4
  24. package/src/chips/Chips.tsx +13 -18
  25. package/src/index.ts +0 -2
  26. package/src/main.css +0 -35
  27. package/src/main.less +0 -1
  28. package/build/markup/Markup.js +0 -128
  29. package/build/markup/Markup.js.map +0 -1
  30. package/build/markup/Markup.mjs +0 -126
  31. package/build/markup/Markup.mjs.map +0 -1
  32. package/build/markup/constants.js +0 -36
  33. package/build/markup/constants.js.map +0 -1
  34. package/build/markup/constants.mjs +0 -29
  35. package/build/markup/constants.mjs.map +0 -1
  36. package/build/markup/utils/parseMarkup.js +0 -226
  37. package/build/markup/utils/parseMarkup.js.map +0 -1
  38. package/build/markup/utils/parseMarkup.mjs +0 -223
  39. package/build/markup/utils/parseMarkup.mjs.map +0 -1
  40. package/build/markup/utils/sanitise.js +0 -91
  41. package/build/markup/utils/sanitise.js.map +0 -1
  42. package/build/markup/utils/sanitise.mjs +0 -89
  43. package/build/markup/utils/sanitise.mjs.map +0 -1
  44. package/build/styles/markup/Markup.css +0 -28
  45. package/build/styles/markup/_stories/Vulnerability/Vulnerability.css +0 -70
  46. package/build/types/markup/Markup.d.ts +0 -4
  47. package/build/types/markup/Markup.d.ts.map +0 -1
  48. package/build/types/markup/Markup.types.d.ts +0 -63
  49. package/build/types/markup/Markup.types.d.ts.map +0 -1
  50. package/build/types/markup/_stories/Vulnerability/Vulnerability.d.ts +0 -12
  51. package/build/types/markup/_stories/Vulnerability/Vulnerability.d.ts.map +0 -1
  52. package/build/types/markup/constants.d.ts +0 -20
  53. package/build/types/markup/constants.d.ts.map +0 -1
  54. package/build/types/markup/index.d.ts +0 -3
  55. package/build/types/markup/index.d.ts.map +0 -1
  56. package/build/types/markup/utils/parseMarkup.d.ts +0 -6
  57. package/build/types/markup/utils/parseMarkup.d.ts.map +0 -1
  58. package/build/types/markup/utils/sanitise.d.ts +0 -2
  59. package/build/types/markup/utils/sanitise.d.ts.map +0 -1
  60. package/src/markup/Markup.css +0 -28
  61. package/src/markup/Markup.less +0 -29
  62. package/src/markup/Markup.test.tsx +0 -330
  63. package/src/markup/Markup.tsx +0 -144
  64. package/src/markup/Markup.types.ts +0 -48
  65. package/src/markup/_stories/Markup.docs.mdx +0 -75
  66. package/src/markup/_stories/Markup.security.docs.mdx +0 -256
  67. package/src/markup/_stories/Markup.story.tsx +0 -198
  68. package/src/markup/_stories/Vulnerability/Vulnerability.css +0 -70
  69. package/src/markup/_stories/Vulnerability/Vulnerability.tsx +0 -30
  70. package/src/markup/constants.ts +0 -41
  71. package/src/markup/index.ts +0 -2
  72. package/src/markup/utils/parseMarkup.test.ts +0 -658
  73. package/src/markup/utils/parseMarkup.ts +0 -228
  74. package/src/markup/utils/sanitise.test.ts +0 -375
  75. package/src/markup/utils/sanitise.ts +0 -107
@@ -1,228 +0,0 @@
1
- import { ALLOWED_TARGETS, MAX_NESTING_DEPTH, TAG_ALIASES, TAG_REGEX } from '../constants';
2
- import { MarkupNode } from '../Markup.types';
3
-
4
- export { MAX_NESTING_DEPTH };
5
-
6
- const OBJECT_PROTOTYPE_KEYS = new Set(Object.getOwnPropertyNames(Object.prototype));
7
-
8
- // eslint-disable-next-line regexp/no-control-character
9
- const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g;
10
- const HTML_METACHAR = /[<>"']/g;
11
-
12
- type Token =
13
- | { type: 'text'; content: string }
14
- | { type: 'newline' }
15
- | { type: 'open'; tag: string; attrs: string }
16
- | { type: 'close'; tag: string };
17
-
18
- /**
19
- * Splits the input string into a flat list of tokens by scanning for known tags.
20
- * Text between tags is emitted as text/newline tokens. Unrecognised tags are never
21
- * tokenised — they remain part of the surrounding text content.
22
- */
23
- function tokenise(input: string): Token[] {
24
- const tokens: Token[] = [];
25
- let lastIndex = 0;
26
-
27
- TAG_REGEX.lastIndex = 0;
28
- let match = TAG_REGEX.exec(input);
29
-
30
- while (match !== null) {
31
- if (match.index > lastIndex) {
32
- pushTextWithNewlines(tokens, input.slice(lastIndex, match.index));
33
- }
34
-
35
- const isClose = match[1] === '/';
36
- const tag = match[2];
37
- const attrs = match[3] || '';
38
-
39
- if (isClose) {
40
- tokens.push({ type: 'close', tag });
41
- } else {
42
- tokens.push({ type: 'open', tag, attrs });
43
- }
44
-
45
- lastIndex = TAG_REGEX.lastIndex;
46
- match = TAG_REGEX.exec(input);
47
- }
48
-
49
- if (lastIndex < input.length) {
50
- pushTextWithNewlines(tokens, input.slice(lastIndex));
51
- }
52
-
53
- return tokens;
54
- }
55
-
56
- /** Splits a text fragment on newline characters, interleaving newline tokens between parts. */
57
- function pushTextWithNewlines(tokens: Token[], text: string): void {
58
- const parts = text.split('\n');
59
- for (let index = 0; index < parts.length; index += 1) {
60
- if (index > 0) {
61
- tokens.push({ type: 'newline' });
62
- }
63
- if (parts[index]) {
64
- tokens.push({ type: 'text', content: parts[index] });
65
- }
66
- }
67
- }
68
-
69
- type ParseResult = { closed: true; nodes: MarkupNode[] } | { closed: false; nodes: MarkupNode[] };
70
-
71
- /**
72
- * Recursive descent parser that consumes tokens and builds a MarkupNode tree.
73
- *
74
- * When `untilTag` is provided, the function collects nodes until it finds a matching
75
- * close token — returning `{ closed: true }`. If it exhausts all tokens without finding
76
- * the close, it returns `{ closed: false }` so the caller can fall back to plain text
77
- * (graceful degradation for malformed input).
78
- *
79
- * The shared `position` object acts as a cursor across recursive calls, avoiding the
80
- * need to slice or copy the token array.
81
- */
82
- function parse(
83
- tokens: Token[],
84
- position: { index: number },
85
- untilTag?: string,
86
- depth = 0,
87
- ): ParseResult {
88
- const nodes: MarkupNode[] = [];
89
-
90
- while (position.index < tokens.length) {
91
- const token = tokens[position.index];
92
-
93
- if (token.type === 'text') {
94
- nodes.push({ type: 'text', content: token.content });
95
- position.index += 1;
96
- } else if (token.type === 'newline') {
97
- nodes.push({ type: 'newline' });
98
- position.index += 1;
99
- } else if (token.type === 'close') {
100
- if (token.tag === untilTag) {
101
- position.index += 1;
102
- return { closed: true, nodes };
103
- }
104
- // Mismatched close tag — render as literal text
105
- nodes.push({ type: 'text', content: `</${token.tag}>` });
106
- position.index += 1;
107
- } else if (token.type === 'open') {
108
- if (depth >= MAX_NESTING_DEPTH) {
109
- nodes.push({ type: 'text', content: `<${token.tag}${token.attrs}>` });
110
- position.index += 1;
111
- } else {
112
- position.index += 1;
113
- const result = parse(tokens, position, token.tag, depth + 1);
114
-
115
- if (result.closed) {
116
- const node = buildNode(token.tag, token.attrs, result.nodes);
117
- nodes.push(node);
118
- } else {
119
- nodes.push({ type: 'text', content: `<${token.tag}${token.attrs}>` });
120
- nodes.push(...result.nodes);
121
- }
122
- }
123
- }
124
- }
125
-
126
- if (untilTag) {
127
- return { closed: false, nodes };
128
- }
129
-
130
- return { closed: true, nodes };
131
- }
132
-
133
- /**
134
- * Maps a raw tag name to a typed MarkupNode. Resolves aliases first (p→paragraph, a→link),
135
- * then builds the appropriate node shape. Link nodes carry full attributes; other element
136
- * nodes extract only accessibilityLabel.
137
- */
138
- function buildNode(rawTag: string, attrs: string, children: MarkupNode[]): MarkupNode {
139
- const tag = TAG_ALIASES[rawTag] || rawTag;
140
-
141
- if (tag === 'link') {
142
- return { type: 'link', children, ...parseAttributes(attrs) };
143
- }
144
-
145
- if (tag === 'paragraph') {
146
- return { type: 'paragraph', children };
147
- }
148
-
149
- const { accessibilityLabel } = parseAttributes(attrs);
150
-
151
- return {
152
- type: tag as MarkupNode['type'],
153
- children,
154
- ...(accessibilityLabel && { accessibilityLabel }),
155
- } as MarkupNode;
156
- }
157
-
158
- const ATTR_REGEX = /(?<=\s)(href|action|target|accessibilityLabel)=(?:"([^"]*)"|'([^']*)')/g;
159
-
160
- type ParsedAttributes = {
161
- href?: string;
162
- action?: string;
163
- target?: string;
164
- accessibilityLabel?: string;
165
- };
166
-
167
- /**
168
- * Extracts allowed attributes from a raw attribute string.
169
- * Supports both double- and single-quoted values. Any attribute not in the
170
- * allow-list (href, action, target, accessibilityLabel) is silently discarded.
171
- * First occurrence wins — duplicates are ignored.
172
- */
173
- function parseAttributes(attrs: string): ParsedAttributes {
174
- const result: ParsedAttributes = {};
175
- ATTR_REGEX.lastIndex = 0;
176
- let match = ATTR_REGEX.exec(attrs);
177
-
178
- while (match !== null) {
179
- const key = match[1] as 'href' | 'action' | 'target' | 'accessibilityLabel';
180
- const value = match[2] ?? match[3];
181
-
182
- if (!(key in result)) {
183
- const sanitized = sanitizeAttributeValue(key, value);
184
- if (sanitized !== undefined) {
185
- result[key] = sanitized;
186
- }
187
- }
188
-
189
- match = ATTR_REGEX.exec(attrs);
190
- }
191
-
192
- return result;
193
- }
194
-
195
- function sanitizeAttributeValue(
196
- key: 'href' | 'action' | 'target' | 'accessibilityLabel',
197
- value: string,
198
- ): string | undefined {
199
- switch (key) {
200
- case 'href':
201
- return value.replace(CONTROL_CHARS, '');
202
- case 'action':
203
- if (OBJECT_PROTOTYPE_KEYS.has(value)) {
204
- return undefined;
205
- }
206
- return value;
207
- case 'target':
208
- if (!ALLOWED_TARGETS.includes(value)) {
209
- return undefined;
210
- }
211
- return value;
212
- case 'accessibilityLabel':
213
- return value.replace(HTML_METACHAR, '');
214
- default:
215
- return value;
216
- }
217
- }
218
-
219
- /** Entry point: tokenises the input string, then parses the token stream into a MarkupNode tree. */
220
- export function parseMarkup(input: string): MarkupNode[] {
221
- if (!input) {
222
- return [];
223
- }
224
-
225
- const tokens = tokenise(input);
226
- const position = { index: 0 };
227
- return parse(tokens, position).nodes;
228
- }
@@ -1,375 +0,0 @@
1
- import { isAllowedHref } from './sanitise';
2
-
3
- describe('isAllowedHref', () => {
4
- describe('allowed protocols', () => {
5
- it('allows https URLs', () => {
6
- expect(isAllowedHref('https://example.com')).toBe(true);
7
- });
8
-
9
- it('allows mailto URLs', () => {
10
- expect(isAllowedHref('mailto:user@example.com')).toBe(true);
11
- });
12
-
13
- it('blocks http URLs', () => {
14
- expect(isAllowedHref('http://example.com')).toBe(false);
15
- });
16
-
17
- it('blocks tel URLs', () => {
18
- expect(isAllowedHref('tel:+1234567890')).toBe(false);
19
- });
20
- });
21
-
22
- describe('allowed relative paths', () => {
23
- it('allows root-relative paths', () => {
24
- expect(isAllowedHref('/settings')).toBe(true);
25
- });
26
-
27
- it('allows hash fragments', () => {
28
- expect(isAllowedHref('#section')).toBe(true);
29
- });
30
- });
31
-
32
- describe('blocked relative paths', () => {
33
- it('blocks dot-relative paths', () => {
34
- expect(isAllowedHref('./page')).toBe(false);
35
- });
36
-
37
- it('blocks parent-relative paths', () => {
38
- expect(isAllowedHref('../page')).toBe(false);
39
- });
40
-
41
- it('blocks dot-relative paths with query parameters', () => {
42
- expect(isAllowedHref('./page?time=10:30')).toBe(false);
43
- });
44
-
45
- it('blocks bare relative paths with colons in segments', () => {
46
- expect(isAllowedHref('page?key=value:other')).toBe(false);
47
- });
48
- });
49
-
50
- describe('blocked protocols', () => {
51
- it('blocks javascript: protocol', () => {
52
- // eslint-disable-next-line no-script-url
53
- expect(isAllowedHref('javascript:alert(1)')).toBe(false);
54
- });
55
-
56
- it('blocks javascript: with mixed case', () => {
57
- // eslint-disable-next-line no-script-url
58
- expect(isAllowedHref('JavaScript:alert(1)')).toBe(false);
59
- });
60
-
61
- it('blocks data: protocol', () => {
62
- expect(isAllowedHref('data:text/html,<script>alert(1)</script>')).toBe(false);
63
- });
64
-
65
- it('blocks vbscript: protocol', () => {
66
- expect(isAllowedHref('vbscript:MsgBox("xss")')).toBe(false);
67
- });
68
-
69
- it('blocks unknown protocols', () => {
70
- expect(isAllowedHref('ftp://evil.com')).toBe(false);
71
- });
72
- });
73
-
74
- describe('protocol-relative URLs', () => {
75
- it('blocks protocol-relative URLs', () => {
76
- expect(isAllowedHref('//evil.com/phishing')).toBe(false);
77
- });
78
-
79
- it('blocks protocol-relative URLs with whitespace', () => {
80
- expect(isAllowedHref(' //evil.com ')).toBe(false);
81
- });
82
- });
83
-
84
- describe('edge cases', () => {
85
- it('handles empty string', () => {
86
- expect(isAllowedHref('')).toBe(false);
87
- });
88
-
89
- it('handles whitespace-padded URLs', () => {
90
- expect(isAllowedHref(' https://example.com ')).toBe(true);
91
- });
92
-
93
- it('handles whitespace-padded dangerous URLs', () => {
94
- expect(isAllowedHref(' javascript:alert(1) ')).toBe(false);
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(isAllowedHref('java\x00script:alert(1)')).toBe(false);
101
- });
102
-
103
- it('blocks null byte between s and c (javas\\x00cript:)', () => {
104
- expect(isAllowedHref('javas\x00cript:alert(1)')).toBe(false);
105
- });
106
-
107
- it('blocks SOH prefix before javascript:', () => {
108
- expect(isAllowedHref('\x01javascript:alert(1)')).toBe(false);
109
- });
110
-
111
- it('blocks vertical tab prefix before javascript:', () => {
112
- expect(isAllowedHref('\x0Bjavascript:alert(1)')).toBe(false);
113
- });
114
-
115
- it('blocks form feed prefix before javascript:', () => {
116
- expect(isAllowedHref('\x0Cjavascript:alert(1)')).toBe(false);
117
- });
118
-
119
- it('blocks tab mid-scheme (java\\tscript:)', () => {
120
- expect(isAllowedHref('java\tscript:alert(1)')).toBe(false);
121
- });
122
-
123
- it('blocks CRLF mid-scheme (java\\r\\nscript:)', () => {
124
- expect(isAllowedHref('java\r\nscript:alert(1)')).toBe(false);
125
- });
126
-
127
- it('blocks LF mid-scheme (j\\navascript:)', () => {
128
- expect(isAllowedHref('j\navascript:alert(1)')).toBe(false);
129
- });
130
- });
131
-
132
- describe('zero-width and invisible character bypass', () => {
133
- it('blocks zero-width space prefix before javascript:', () => {
134
- expect(isAllowedHref('​javascript:alert(1)')).toBe(false);
135
- });
136
-
137
- it('blocks zero-width space only (invisible link)', () => {
138
- expect(isAllowedHref('​')).toBe(false);
139
- });
140
-
141
- it('blocks BOM-only string (invisible link)', () => {
142
- expect(isAllowedHref('')).toBe(false);
143
- });
144
-
145
- it('blocks BOM prefix before javascript:', () => {
146
- expect(isAllowedHref('javascript:alert(1)')).toBe(false);
147
- });
148
-
149
- it('blocks zero-width non-joiner prefix before javascript:', () => {
150
- expect(isAllowedHref('‌javascript:alert(1)')).toBe(false);
151
- });
152
-
153
- it('blocks zero-width joiner prefix before javascript:', () => {
154
- expect(isAllowedHref('‍javascript:alert(1)')).toBe(false);
155
- });
156
- });
157
-
158
- describe('non-ASCII whitespace prefix bypass', () => {
159
- it('blocks NBSP prefix before javascript:', () => {
160
- expect(isAllowedHref(' javascript:alert(1)')).toBe(false);
161
- });
162
-
163
- it('blocks line separator prefix before javascript:', () => {
164
- expect(isAllowedHref('\u2028javascript:alert(1)')).toBe(false);
165
- });
166
-
167
- it('blocks paragraph separator prefix before javascript:', () => {
168
- expect(isAllowedHref('\u2029javascript:alert(1)')).toBe(false);
169
- });
170
-
171
- it('blocks NBSP before protocol-relative URL', () => {
172
- expect(isAllowedHref(' //evil.com')).toBe(false);
173
- });
174
-
175
- it('blocks NBSP-only string', () => {
176
- expect(isAllowedHref(' ')).toBe(false);
177
- });
178
- });
179
-
180
- describe('backslash-based open redirect', () => {
181
- it('blocks /\\evil.com (browsers normalize \\ to /)', () => {
182
- expect(isAllowedHref('/\\evil.com')).toBe(false);
183
- });
184
-
185
- it('blocks /\\\\evil.com (double backslash)', () => {
186
- expect(isAllowedHref('/\\\\evil.com')).toBe(false);
187
- });
188
-
189
- it('allows /path\\with\\backslash (not at position 1)', () => {
190
- expect(isAllowedHref('/path\\with\\backslash')).toBe(true);
191
- });
192
- });
193
-
194
- describe('URL-encoded dangerous schemes', () => {
195
- it('blocks javascript with URL-encoded colon (%3A)', () => {
196
- expect(isAllowedHref('javascript%3Aalert(1)')).toBe(false);
197
- });
198
-
199
- it('blocks javascript with lowercase URL-encoded colon (%3a)', () => {
200
- expect(isAllowedHref('javascript%3aalert(1)')).toBe(false);
201
- });
202
- });
203
-
204
- describe('credential confusion / phishing via userinfo', () => {
205
- it('blocks https URLs with misleading userinfo (good.com@evil.com)', () => {
206
- expect(isAllowedHref('https://good.com@evil.com')).toBe(false);
207
- });
208
-
209
- it('blocks https URLs with port-like credentials', () => {
210
- expect(isAllowedHref('https://evil.com:443@good.com')).toBe(false);
211
- });
212
-
213
- it('allows https URLs without userinfo', () => {
214
- expect(isAllowedHref('https://example.com/path')).toBe(true);
215
- });
216
-
217
- it('allows mailto with @ (expected in email addresses)', () => {
218
- expect(isAllowedHref('mailto:user@example.com')).toBe(true);
219
- });
220
- });
221
-
222
- describe('degenerate HTTPS URLs', () => {
223
- it('blocks https:evil.com (no slashes — ambiguous host)', () => {
224
- expect(isAllowedHref('https:evil.com')).toBe(false);
225
- });
226
-
227
- it('blocks https:/evil.com (single slash — ambiguous)', () => {
228
- expect(isAllowedHref('https:/evil.com')).toBe(false);
229
- });
230
-
231
- it('blocks https:// (no host)', () => {
232
- expect(isAllowedHref('https://')).toBe(false);
233
- });
234
-
235
- it('blocks https:/// (empty host)', () => {
236
- expect(isAllowedHref('https:///')).toBe(false);
237
- });
238
- });
239
-
240
- describe('internal network / SSRF via allowed HTTPS', () => {
241
- it('blocks https://127.0.0.1 (localhost)', () => {
242
- expect(isAllowedHref('https://127.0.0.1/')).toBe(false);
243
- });
244
-
245
- it('blocks https://[::1] (IPv6 localhost)', () => {
246
- expect(isAllowedHref('https://[::1]/')).toBe(false);
247
- });
248
-
249
- it('blocks hex-encoded IP (0x7f000001)', () => {
250
- expect(isAllowedHref('https://0x7f000001/')).toBe(false);
251
- });
252
-
253
- it('blocks decimal IP (2130706433)', () => {
254
- expect(isAllowedHref('https://2130706433/')).toBe(false);
255
- });
256
-
257
- it('blocks octal IP (0177.0.0.1)', () => {
258
- expect(isAllowedHref('https://0177.0.0.1/')).toBe(false);
259
- });
260
-
261
- it('blocks AWS metadata endpoint', () => {
262
- expect(isAllowedHref('https://169.254.169.254/')).toBe(false);
263
- });
264
-
265
- it('blocks GCP metadata endpoint', () => {
266
- expect(isAllowedHref('https://metadata.google.internal/')).toBe(false);
267
- });
268
- });
269
-
270
- describe('XSS payloads in fragment and path', () => {
271
- it('blocks XSS payload in hash fragment', () => {
272
- expect(isAllowedHref('#<img onerror=alert(1)>')).toBe(false);
273
- });
274
-
275
- it('blocks XSS payload in path', () => {
276
- expect(isAllowedHref('/<img onerror=alert(1)>')).toBe(false);
277
- });
278
-
279
- it('blocks attribute injection in hash fragment', () => {
280
- expect(isAllowedHref('#" onclick="alert(1)')).toBe(false);
281
- });
282
-
283
- it('allows normal hash fragments', () => {
284
- expect(isAllowedHref('#section-1')).toBe(true);
285
- });
286
-
287
- it('allows normal root-relative paths', () => {
288
- expect(isAllowedHref('/normal/path?query=value')).toBe(true);
289
- });
290
- });
291
-
292
- describe('UNC paths (Windows network shares)', () => {
293
- it('blocks UNC path to external server', () => {
294
- expect(isAllowedHref('\\\\evil.com\\share')).toBe(false);
295
- });
296
- });
297
-
298
- describe('space before colon in scheme', () => {
299
- it('blocks "javascript :alert(1)" (space before colon)', () => {
300
- expect(isAllowedHref('javascript :alert(1)')).toBe(false);
301
- });
302
- });
303
-
304
- describe('mailto abuse', () => {
305
- it('blocks mailto with XSS in body parameter', () => {
306
- expect(isAllowedHref('mailto:evil@evil.com?body=<script>alert(1)</script>')).toBe(false);
307
- });
308
-
309
- it('blocks mailto with header injection (cc/bcc)', () => {
310
- expect(isAllowedHref('mailto:evil@evil.com?cc=victim@good.com&bcc=spy@evil.com')).toBe(false);
311
- });
312
-
313
- it('blocks mailto with no recipient (social engineering)', () => {
314
- expect(isAllowedHref('mailto:?subject=urgent&body=click%20here')).toBe(false);
315
- });
316
-
317
- it('allows simple mailto with just an address', () => {
318
- expect(isAllowedHref('mailto:support@example.com')).toBe(true);
319
- });
320
- });
321
-
322
- describe('additional blocked protocols (regression)', () => {
323
- it('blocks file: protocol', () => {
324
- expect(isAllowedHref('file:///etc/passwd')).toBe(false);
325
- });
326
-
327
- it('blocks ws: protocol', () => {
328
- expect(isAllowedHref('ws://evil.com')).toBe(false);
329
- });
330
-
331
- it('blocks wss: protocol', () => {
332
- expect(isAllowedHref('wss://evil.com')).toBe(false);
333
- });
334
-
335
- it('blocks blob: protocol', () => {
336
- expect(isAllowedHref('blob:https://evil.com/uuid')).toBe(false);
337
- });
338
-
339
- it('blocks data: with SVG XSS', () => {
340
- expect(isAllowedHref('data:image/svg+xml,<svg onload=alert(1)>')).toBe(false);
341
- });
342
-
343
- it('blocks data: with base64-encoded script', () => {
344
- expect(isAllowedHref('data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==')).toBe(
345
- false,
346
- );
347
- });
348
-
349
- it('blocks view-source: protocol', () => {
350
- expect(isAllowedHref('view-source:https://evil.com')).toBe(false);
351
- });
352
-
353
- it('blocks custom protocol (foo:bar)', () => {
354
- expect(isAllowedHref('foo:bar')).toBe(false);
355
- });
356
-
357
- it('blocks minimal protocol pattern (a:b)', () => {
358
- expect(isAllowedHref('a:b')).toBe(false);
359
- });
360
-
361
- it('blocks customprotocol:payload', () => {
362
- expect(isAllowedHref('customprotocol:payload')).toBe(false);
363
- });
364
- });
365
-
366
- describe('whitespace-only inputs', () => {
367
- it('blocks spaces only', () => {
368
- expect(isAllowedHref(' ')).toBe(false);
369
- });
370
-
371
- it('blocks tab/newline/CR only', () => {
372
- expect(isAllowedHref('\t\n\r')).toBe(false);
373
- });
374
- });
375
- });
@@ -1,107 +0,0 @@
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_HTML_CHARS = /[<>"']/;
7
- const DANGEROUS_SCHEME = /^(?:javascript|vbscript|data|jscript)\s*:/i;
8
- // eslint-disable-next-line regexp/no-useless-non-capturing-group
9
- const ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;
10
- const IPV6_BRACKETS = /^\[|\]$/g;
11
- const NUMERIC_IP = /^[\d.]+$/;
12
- const HEX_IP = /^0x[0-9a-f]+$/i;
13
-
14
- export function isAllowedHref(href: string): boolean {
15
- const cleaned = href.replace(INVISIBLE_CHARS, '');
16
- const trimmed = cleaned.trim();
17
-
18
- if (!trimmed) {
19
- return false;
20
- }
21
-
22
- if (CONTROL_CHARS.test(trimmed)) {
23
- return false;
24
- }
25
-
26
- if (DANGEROUS_HTML_CHARS.test(trimmed)) {
27
- return false;
28
- }
29
-
30
- if (DANGEROUS_SCHEME.test(trimmed)) {
31
- return false;
32
- }
33
-
34
- if (ENCODED_COLON_SCHEME.test(trimmed)) {
35
- return false;
36
- }
37
-
38
- if (trimmed.startsWith('//')) {
39
- return false;
40
- }
41
-
42
- if (trimmed.startsWith('\\')) {
43
- return false;
44
- }
45
-
46
- if (trimmed.startsWith('/')) {
47
- return trimmed[1] !== '\\';
48
- }
49
-
50
- if (trimmed.startsWith('#')) {
51
- return true;
52
- }
53
-
54
- if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
55
- return false;
56
- }
57
-
58
- try {
59
- const url = new URL(trimmed);
60
-
61
- if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
62
- return false;
63
- }
64
-
65
- if (url.protocol === 'https:') {
66
- if (!trimmed.startsWith('https://')) {
67
- return false;
68
- }
69
-
70
- if (!url.hostname) {
71
- return false;
72
- }
73
-
74
- if (url.username || url.password) {
75
- return false;
76
- }
77
-
78
- if (isInternalHost(url.hostname)) {
79
- return false;
80
- }
81
- }
82
-
83
- if (url.protocol === 'mailto:') {
84
- if (url.search) {
85
- return false;
86
- }
87
-
88
- if (!url.pathname) {
89
- return false;
90
- }
91
- }
92
-
93
- return true;
94
- } catch {
95
- return false;
96
- }
97
- }
98
-
99
- function isInternalHost(hostname: string): boolean {
100
- const host = hostname.replace(IPV6_BRACKETS, '');
101
- if (NUMERIC_IP.test(host)) return true;
102
- if (HEX_IP.test(host)) return true;
103
- if (host === '::1') return true;
104
- if (host === 'localhost') return true;
105
- if (host.endsWith('.internal')) return true;
106
- return false;
107
- }