@transferwise/components 0.0.0-experimental-1d81581 → 0.0.0-experimental-a06eb0d

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.
@@ -12,69 +12,69 @@ const ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;
12
12
  const IPV6_BRACKETS = /^\[|\]$/g;
13
13
  const NUMERIC_IP = /^[\d.]+$/;
14
14
  const HEX_IP = /^0x[0-9a-f]+$/i;
15
- function isAllowedHref(href) {
15
+ function sanitiseHref(href) {
16
16
  const cleaned = href.replace(INVISIBLE_CHARS, '');
17
17
  const trimmed = cleaned.trim();
18
18
  if (!trimmed) {
19
- return false;
19
+ return undefined;
20
20
  }
21
21
  if (CONTROL_CHARS.test(trimmed)) {
22
- return false;
22
+ return undefined;
23
23
  }
24
24
  if (DANGEROUS_HTML_CHARS.test(trimmed)) {
25
- return false;
25
+ return undefined;
26
26
  }
27
27
  if (DANGEROUS_SCHEME.test(trimmed)) {
28
- return false;
28
+ return undefined;
29
29
  }
30
30
  if (ENCODED_COLON_SCHEME.test(trimmed)) {
31
- return false;
31
+ return undefined;
32
32
  }
33
33
  if (trimmed.startsWith('//')) {
34
- return false;
34
+ return undefined;
35
35
  }
36
36
  if (trimmed.startsWith('\\')) {
37
- return false;
37
+ return undefined;
38
38
  }
39
39
  if (trimmed.startsWith('/')) {
40
- return trimmed[1] !== '\\';
40
+ return trimmed[1] !== '\\' ? trimmed : undefined;
41
41
  }
42
42
  if (trimmed.startsWith('#')) {
43
- return true;
43
+ return trimmed;
44
44
  }
45
45
  if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
46
- return false;
46
+ return undefined;
47
47
  }
48
48
  try {
49
49
  const url = new URL(trimmed);
50
50
  if (!constants.ALLOWED_PROTOCOLS.includes(url.protocol)) {
51
- return false;
51
+ return undefined;
52
52
  }
53
53
  if (url.protocol === 'https:') {
54
54
  if (!trimmed.startsWith('https://')) {
55
- return false;
55
+ return undefined;
56
56
  }
57
57
  if (!url.hostname) {
58
- return false;
58
+ return undefined;
59
59
  }
60
60
  if (url.username || url.password) {
61
- return false;
61
+ return undefined;
62
62
  }
63
63
  if (isInternalHost(url.hostname)) {
64
- return false;
64
+ return undefined;
65
65
  }
66
66
  }
67
67
  if (url.protocol === 'mailto:') {
68
68
  if (url.search) {
69
- return false;
69
+ return undefined;
70
70
  }
71
71
  if (!url.pathname) {
72
- return false;
72
+ return undefined;
73
73
  }
74
74
  }
75
- return true;
75
+ return trimmed;
76
76
  } catch {
77
- return false;
77
+ return undefined;
78
78
  }
79
79
  }
80
80
  // Synthetic origin used to resolve relative paths via the WHATWG URL parser.
@@ -87,15 +87,16 @@ function isHrefAllowedByMode(href, mode) {
87
87
  case 'all':
88
88
  return true;
89
89
  case 'internal':
90
- if (href.startsWith('https://wise.com/')) return true;
91
- if (href.startsWith('/')) {
92
- try {
93
- return new URL(href, SSR_SENTINEL_ORIGIN).origin === SSR_SENTINEL_ORIGIN;
94
- } catch {
95
- return false;
90
+ try {
91
+ if (!href.startsWith('/')) {
92
+ const parsed = new URL(href);
93
+ return parsed.protocol === 'https:' && parsed.hostname === 'wise.com';
96
94
  }
95
+ const parsed = new URL(href, SSR_SENTINEL_ORIGIN);
96
+ return parsed.origin === SSR_SENTINEL_ORIGIN;
97
+ } catch {
98
+ return false;
97
99
  }
98
- return false;
99
100
  }
100
101
  }
101
102
  function isInternalHost(hostname) {
@@ -109,6 +110,6 @@ function isInternalHost(hostname) {
109
110
  return false;
110
111
  }
111
112
 
112
- exports.isAllowedHref = isAllowedHref;
113
113
  exports.isHrefAllowedByMode = isHrefAllowedByMode;
114
+ exports.sanitiseHref = sanitiseHref;
114
115
  //# sourceMappingURL=sanitise.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sanitise.js","sources":["../../../src/Markup/utils/sanitise.ts"],"sourcesContent":["import { ALLOWED_PROTOCOLS } from '../constants';\n\nconst INVISIBLE_CHARS = /[\\u200b\\u200c\\u200d\\ufeff\\u00ad\\u2060\\u180e]/g;\n// eslint-disable-next-line regexp/no-control-character\nconst CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f]/;\nconst DANGEROUS_HTML_CHARS = /[<>\"']/;\nconst DANGEROUS_SCHEME = /^(?:javascript|vbscript|data|jscript)\\s*:/i;\n// eslint-disable-next-line regexp/no-useless-non-capturing-group\nconst ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;\nconst IPV6_BRACKETS = /^\\[|\\]$/g;\nconst NUMERIC_IP = /^[\\d.]+$/;\nconst HEX_IP = /^0x[0-9a-f]+$/i;\n\nexport function isAllowedHref(href: string): boolean {\n const cleaned = href.replace(INVISIBLE_CHARS, '');\n const trimmed = cleaned.trim();\n\n if (!trimmed) {\n return false;\n }\n\n if (CONTROL_CHARS.test(trimmed)) {\n return false;\n }\n\n if (DANGEROUS_HTML_CHARS.test(trimmed)) {\n return false;\n }\n\n if (DANGEROUS_SCHEME.test(trimmed)) {\n return false;\n }\n\n if (ENCODED_COLON_SCHEME.test(trimmed)) {\n return false;\n }\n\n if (trimmed.startsWith('//')) {\n return false;\n }\n\n if (trimmed.startsWith('\\\\')) {\n return false;\n }\n\n if (trimmed.startsWith('/')) {\n return trimmed[1] !== '\\\\';\n }\n\n if (trimmed.startsWith('#')) {\n return true;\n }\n\n if (trimmed.startsWith('./') || trimmed.startsWith('../')) {\n return false;\n }\n\n try {\n const url = new URL(trimmed);\n\n if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {\n return false;\n }\n\n if (url.protocol === 'https:') {\n if (!trimmed.startsWith('https://')) {\n return false;\n }\n\n if (!url.hostname) {\n return false;\n }\n\n if (url.username || url.password) {\n return false;\n }\n\n if (isInternalHost(url.hostname)) {\n return false;\n }\n }\n\n if (url.protocol === 'mailto:') {\n if (url.search) {\n return false;\n }\n\n if (!url.pathname) {\n return false;\n }\n }\n\n return true;\n } catch {\n return false;\n }\n}\n\nexport type AllowLinksMode = 'internal' | 'all' | 'none';\n\n// Synthetic origin used to resolve relative paths via the WHATWG URL parser.\n// If the resolved origin still equals this sentinel, the path stays on-origin.\nconst SSR_SENTINEL_ORIGIN = 'https://internal.invalid';\n\nexport function isHrefAllowedByMode(href: string, mode: AllowLinksMode): boolean {\n switch (mode) {\n case 'none':\n return false;\n case 'all':\n return true;\n case 'internal':\n if (href.startsWith('https://wise.com/')) return true;\n if (href.startsWith('/')) {\n try {\n return new URL(href, SSR_SENTINEL_ORIGIN).origin === SSR_SENTINEL_ORIGIN;\n } catch {\n return false;\n }\n }\n return false;\n }\n}\n\nfunction isInternalHost(hostname: string): boolean {\n const stripped = hostname.replace(IPV6_BRACKETS, '');\n const host = stripped.endsWith('.') ? stripped.slice(0, -1) : stripped;\n if (NUMERIC_IP.test(host)) return true;\n if (HEX_IP.test(host)) return true;\n if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;\n if (host === 'localhost') return true;\n if (host.endsWith('.internal')) return true;\n return false;\n}\n"],"names":["INVISIBLE_CHARS","CONTROL_CHARS","DANGEROUS_HTML_CHARS","DANGEROUS_SCHEME","ENCODED_COLON_SCHEME","IPV6_BRACKETS","NUMERIC_IP","HEX_IP","isAllowedHref","href","cleaned","replace","trimmed","trim","test","startsWith","url","URL","ALLOWED_PROTOCOLS","includes","protocol","hostname","username","password","isInternalHost","search","pathname","SSR_SENTINEL_ORIGIN","isHrefAllowedByMode","mode","origin","stripped","host","endsWith","slice"],"mappings":"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"}
1
+ {"version":3,"file":"sanitise.js","sources":["../../../src/Markup/utils/sanitise.ts"],"sourcesContent":["import { ALLOWED_PROTOCOLS } from '../constants';\n\nconst INVISIBLE_CHARS = /[\\u200b\\u200c\\u200d\\ufeff\\u00ad\\u2060\\u180e]/g;\n// eslint-disable-next-line regexp/no-control-character\nconst CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f]/;\nconst DANGEROUS_HTML_CHARS = /[<>\"']/;\nconst DANGEROUS_SCHEME = /^(?:javascript|vbscript|data|jscript)\\s*:/i;\n// eslint-disable-next-line regexp/no-useless-non-capturing-group\nconst ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;\nconst IPV6_BRACKETS = /^\\[|\\]$/g;\nconst NUMERIC_IP = /^[\\d.]+$/;\nconst HEX_IP = /^0x[0-9a-f]+$/i;\n\nexport function sanitiseHref(href: string): string | undefined {\n const cleaned = href.replace(INVISIBLE_CHARS, '');\n const trimmed = cleaned.trim();\n\n if (!trimmed) {\n return undefined;\n }\n\n if (CONTROL_CHARS.test(trimmed)) {\n return undefined;\n }\n\n if (DANGEROUS_HTML_CHARS.test(trimmed)) {\n return undefined;\n }\n\n if (DANGEROUS_SCHEME.test(trimmed)) {\n return undefined;\n }\n\n if (ENCODED_COLON_SCHEME.test(trimmed)) {\n return undefined;\n }\n\n if (trimmed.startsWith('//')) {\n return undefined;\n }\n\n if (trimmed.startsWith('\\\\')) {\n return undefined;\n }\n\n if (trimmed.startsWith('/')) {\n return trimmed[1] !== '\\\\' ? trimmed : undefined;\n }\n\n if (trimmed.startsWith('#')) {\n return trimmed;\n }\n\n if (trimmed.startsWith('./') || trimmed.startsWith('../')) {\n return undefined;\n }\n\n try {\n const url = new URL(trimmed);\n\n if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {\n return undefined;\n }\n\n if (url.protocol === 'https:') {\n if (!trimmed.startsWith('https://')) {\n return undefined;\n }\n\n if (!url.hostname) {\n return undefined;\n }\n\n if (url.username || url.password) {\n return undefined;\n }\n\n if (isInternalHost(url.hostname)) {\n return undefined;\n }\n }\n\n if (url.protocol === 'mailto:') {\n if (url.search) {\n return undefined;\n }\n\n if (!url.pathname) {\n return undefined;\n }\n }\n\n return trimmed;\n } catch {\n return undefined;\n }\n}\n\nexport type AllowLinksMode = 'internal' | 'all' | 'none';\n\n// Synthetic origin used to resolve relative paths via the WHATWG URL parser.\n// If the resolved origin still equals this sentinel, the path stays on-origin.\nconst SSR_SENTINEL_ORIGIN = 'https://internal.invalid';\n\nexport function isHrefAllowedByMode(href: string, mode: AllowLinksMode): boolean {\n switch (mode) {\n case 'none':\n return false;\n case 'all':\n return true;\n case 'internal':\n try {\n if (!href.startsWith('/')) {\n const parsed = new URL(href);\n return parsed.protocol === 'https:' && parsed.hostname === 'wise.com';\n }\n const parsed = new URL(href, SSR_SENTINEL_ORIGIN);\n return parsed.origin === SSR_SENTINEL_ORIGIN;\n } catch {\n return false;\n }\n }\n}\n\nfunction isInternalHost(hostname: string): boolean {\n const stripped = hostname.replace(IPV6_BRACKETS, '');\n const host = stripped.endsWith('.') ? stripped.slice(0, -1) : stripped;\n if (NUMERIC_IP.test(host)) return true;\n if (HEX_IP.test(host)) return true;\n if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;\n if (host === 'localhost') return true;\n if (host.endsWith('.internal')) return true;\n return false;\n}\n"],"names":["INVISIBLE_CHARS","CONTROL_CHARS","DANGEROUS_HTML_CHARS","DANGEROUS_SCHEME","ENCODED_COLON_SCHEME","IPV6_BRACKETS","NUMERIC_IP","HEX_IP","sanitiseHref","href","cleaned","replace","trimmed","trim","undefined","test","startsWith","url","URL","ALLOWED_PROTOCOLS","includes","protocol","hostname","username","password","isInternalHost","search","pathname","SSR_SENTINEL_ORIGIN","isHrefAllowedByMode","mode","parsed","origin","stripped","host","endsWith","slice"],"mappings":"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"}
@@ -10,69 +10,69 @@ const ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;
10
10
  const IPV6_BRACKETS = /^\[|\]$/g;
11
11
  const NUMERIC_IP = /^[\d.]+$/;
12
12
  const HEX_IP = /^0x[0-9a-f]+$/i;
13
- function isAllowedHref(href) {
13
+ function sanitiseHref(href) {
14
14
  const cleaned = href.replace(INVISIBLE_CHARS, '');
15
15
  const trimmed = cleaned.trim();
16
16
  if (!trimmed) {
17
- return false;
17
+ return undefined;
18
18
  }
19
19
  if (CONTROL_CHARS.test(trimmed)) {
20
- return false;
20
+ return undefined;
21
21
  }
22
22
  if (DANGEROUS_HTML_CHARS.test(trimmed)) {
23
- return false;
23
+ return undefined;
24
24
  }
25
25
  if (DANGEROUS_SCHEME.test(trimmed)) {
26
- return false;
26
+ return undefined;
27
27
  }
28
28
  if (ENCODED_COLON_SCHEME.test(trimmed)) {
29
- return false;
29
+ return undefined;
30
30
  }
31
31
  if (trimmed.startsWith('//')) {
32
- return false;
32
+ return undefined;
33
33
  }
34
34
  if (trimmed.startsWith('\\')) {
35
- return false;
35
+ return undefined;
36
36
  }
37
37
  if (trimmed.startsWith('/')) {
38
- return trimmed[1] !== '\\';
38
+ return trimmed[1] !== '\\' ? trimmed : undefined;
39
39
  }
40
40
  if (trimmed.startsWith('#')) {
41
- return true;
41
+ return trimmed;
42
42
  }
43
43
  if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
44
- return false;
44
+ return undefined;
45
45
  }
46
46
  try {
47
47
  const url = new URL(trimmed);
48
48
  if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
49
- return false;
49
+ return undefined;
50
50
  }
51
51
  if (url.protocol === 'https:') {
52
52
  if (!trimmed.startsWith('https://')) {
53
- return false;
53
+ return undefined;
54
54
  }
55
55
  if (!url.hostname) {
56
- return false;
56
+ return undefined;
57
57
  }
58
58
  if (url.username || url.password) {
59
- return false;
59
+ return undefined;
60
60
  }
61
61
  if (isInternalHost(url.hostname)) {
62
- return false;
62
+ return undefined;
63
63
  }
64
64
  }
65
65
  if (url.protocol === 'mailto:') {
66
66
  if (url.search) {
67
- return false;
67
+ return undefined;
68
68
  }
69
69
  if (!url.pathname) {
70
- return false;
70
+ return undefined;
71
71
  }
72
72
  }
73
- return true;
73
+ return trimmed;
74
74
  } catch {
75
- return false;
75
+ return undefined;
76
76
  }
77
77
  }
78
78
  // Synthetic origin used to resolve relative paths via the WHATWG URL parser.
@@ -85,15 +85,16 @@ function isHrefAllowedByMode(href, mode) {
85
85
  case 'all':
86
86
  return true;
87
87
  case 'internal':
88
- if (href.startsWith('https://wise.com/')) return true;
89
- if (href.startsWith('/')) {
90
- try {
91
- return new URL(href, SSR_SENTINEL_ORIGIN).origin === SSR_SENTINEL_ORIGIN;
92
- } catch {
93
- return false;
88
+ try {
89
+ if (!href.startsWith('/')) {
90
+ const parsed = new URL(href);
91
+ return parsed.protocol === 'https:' && parsed.hostname === 'wise.com';
94
92
  }
93
+ const parsed = new URL(href, SSR_SENTINEL_ORIGIN);
94
+ return parsed.origin === SSR_SENTINEL_ORIGIN;
95
+ } catch {
96
+ return false;
95
97
  }
96
- return false;
97
98
  }
98
99
  }
99
100
  function isInternalHost(hostname) {
@@ -107,5 +108,5 @@ function isInternalHost(hostname) {
107
108
  return false;
108
109
  }
109
110
 
110
- export { isAllowedHref, isHrefAllowedByMode };
111
+ export { isHrefAllowedByMode, sanitiseHref };
111
112
  //# sourceMappingURL=sanitise.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"sanitise.mjs","sources":["../../../src/Markup/utils/sanitise.ts"],"sourcesContent":["import { ALLOWED_PROTOCOLS } from '../constants';\n\nconst INVISIBLE_CHARS = /[\\u200b\\u200c\\u200d\\ufeff\\u00ad\\u2060\\u180e]/g;\n// eslint-disable-next-line regexp/no-control-character\nconst CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f]/;\nconst DANGEROUS_HTML_CHARS = /[<>\"']/;\nconst DANGEROUS_SCHEME = /^(?:javascript|vbscript|data|jscript)\\s*:/i;\n// eslint-disable-next-line regexp/no-useless-non-capturing-group\nconst ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;\nconst IPV6_BRACKETS = /^\\[|\\]$/g;\nconst NUMERIC_IP = /^[\\d.]+$/;\nconst HEX_IP = /^0x[0-9a-f]+$/i;\n\nexport function isAllowedHref(href: string): boolean {\n const cleaned = href.replace(INVISIBLE_CHARS, '');\n const trimmed = cleaned.trim();\n\n if (!trimmed) {\n return false;\n }\n\n if (CONTROL_CHARS.test(trimmed)) {\n return false;\n }\n\n if (DANGEROUS_HTML_CHARS.test(trimmed)) {\n return false;\n }\n\n if (DANGEROUS_SCHEME.test(trimmed)) {\n return false;\n }\n\n if (ENCODED_COLON_SCHEME.test(trimmed)) {\n return false;\n }\n\n if (trimmed.startsWith('//')) {\n return false;\n }\n\n if (trimmed.startsWith('\\\\')) {\n return false;\n }\n\n if (trimmed.startsWith('/')) {\n return trimmed[1] !== '\\\\';\n }\n\n if (trimmed.startsWith('#')) {\n return true;\n }\n\n if (trimmed.startsWith('./') || trimmed.startsWith('../')) {\n return false;\n }\n\n try {\n const url = new URL(trimmed);\n\n if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {\n return false;\n }\n\n if (url.protocol === 'https:') {\n if (!trimmed.startsWith('https://')) {\n return false;\n }\n\n if (!url.hostname) {\n return false;\n }\n\n if (url.username || url.password) {\n return false;\n }\n\n if (isInternalHost(url.hostname)) {\n return false;\n }\n }\n\n if (url.protocol === 'mailto:') {\n if (url.search) {\n return false;\n }\n\n if (!url.pathname) {\n return false;\n }\n }\n\n return true;\n } catch {\n return false;\n }\n}\n\nexport type AllowLinksMode = 'internal' | 'all' | 'none';\n\n// Synthetic origin used to resolve relative paths via the WHATWG URL parser.\n// If the resolved origin still equals this sentinel, the path stays on-origin.\nconst SSR_SENTINEL_ORIGIN = 'https://internal.invalid';\n\nexport function isHrefAllowedByMode(href: string, mode: AllowLinksMode): boolean {\n switch (mode) {\n case 'none':\n return false;\n case 'all':\n return true;\n case 'internal':\n if (href.startsWith('https://wise.com/')) return true;\n if (href.startsWith('/')) {\n try {\n return new URL(href, SSR_SENTINEL_ORIGIN).origin === SSR_SENTINEL_ORIGIN;\n } catch {\n return false;\n }\n }\n return false;\n }\n}\n\nfunction isInternalHost(hostname: string): boolean {\n const stripped = hostname.replace(IPV6_BRACKETS, '');\n const host = stripped.endsWith('.') ? stripped.slice(0, -1) : stripped;\n if (NUMERIC_IP.test(host)) return true;\n if (HEX_IP.test(host)) return true;\n if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;\n if (host === 'localhost') return true;\n if (host.endsWith('.internal')) return true;\n return false;\n}\n"],"names":["INVISIBLE_CHARS","CONTROL_CHARS","DANGEROUS_HTML_CHARS","DANGEROUS_SCHEME","ENCODED_COLON_SCHEME","IPV6_BRACKETS","NUMERIC_IP","HEX_IP","isAllowedHref","href","cleaned","replace","trimmed","trim","test","startsWith","url","URL","ALLOWED_PROTOCOLS","includes","protocol","hostname","username","password","isInternalHost","search","pathname","SSR_SENTINEL_ORIGIN","isHrefAllowedByMode","mode","origin","stripped","host","endsWith","slice"],"mappings":"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"}
1
+ {"version":3,"file":"sanitise.mjs","sources":["../../../src/Markup/utils/sanitise.ts"],"sourcesContent":["import { ALLOWED_PROTOCOLS } from '../constants';\n\nconst INVISIBLE_CHARS = /[\\u200b\\u200c\\u200d\\ufeff\\u00ad\\u2060\\u180e]/g;\n// eslint-disable-next-line regexp/no-control-character\nconst CONTROL_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f]/;\nconst DANGEROUS_HTML_CHARS = /[<>\"']/;\nconst DANGEROUS_SCHEME = /^(?:javascript|vbscript|data|jscript)\\s*:/i;\n// eslint-disable-next-line regexp/no-useless-non-capturing-group\nconst ENCODED_COLON_SCHEME = /^(?:javascript|vbscript|data|jscript)(?:%3a)/i;\nconst IPV6_BRACKETS = /^\\[|\\]$/g;\nconst NUMERIC_IP = /^[\\d.]+$/;\nconst HEX_IP = /^0x[0-9a-f]+$/i;\n\nexport function sanitiseHref(href: string): string | undefined {\n const cleaned = href.replace(INVISIBLE_CHARS, '');\n const trimmed = cleaned.trim();\n\n if (!trimmed) {\n return undefined;\n }\n\n if (CONTROL_CHARS.test(trimmed)) {\n return undefined;\n }\n\n if (DANGEROUS_HTML_CHARS.test(trimmed)) {\n return undefined;\n }\n\n if (DANGEROUS_SCHEME.test(trimmed)) {\n return undefined;\n }\n\n if (ENCODED_COLON_SCHEME.test(trimmed)) {\n return undefined;\n }\n\n if (trimmed.startsWith('//')) {\n return undefined;\n }\n\n if (trimmed.startsWith('\\\\')) {\n return undefined;\n }\n\n if (trimmed.startsWith('/')) {\n return trimmed[1] !== '\\\\' ? trimmed : undefined;\n }\n\n if (trimmed.startsWith('#')) {\n return trimmed;\n }\n\n if (trimmed.startsWith('./') || trimmed.startsWith('../')) {\n return undefined;\n }\n\n try {\n const url = new URL(trimmed);\n\n if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {\n return undefined;\n }\n\n if (url.protocol === 'https:') {\n if (!trimmed.startsWith('https://')) {\n return undefined;\n }\n\n if (!url.hostname) {\n return undefined;\n }\n\n if (url.username || url.password) {\n return undefined;\n }\n\n if (isInternalHost(url.hostname)) {\n return undefined;\n }\n }\n\n if (url.protocol === 'mailto:') {\n if (url.search) {\n return undefined;\n }\n\n if (!url.pathname) {\n return undefined;\n }\n }\n\n return trimmed;\n } catch {\n return undefined;\n }\n}\n\nexport type AllowLinksMode = 'internal' | 'all' | 'none';\n\n// Synthetic origin used to resolve relative paths via the WHATWG URL parser.\n// If the resolved origin still equals this sentinel, the path stays on-origin.\nconst SSR_SENTINEL_ORIGIN = 'https://internal.invalid';\n\nexport function isHrefAllowedByMode(href: string, mode: AllowLinksMode): boolean {\n switch (mode) {\n case 'none':\n return false;\n case 'all':\n return true;\n case 'internal':\n try {\n if (!href.startsWith('/')) {\n const parsed = new URL(href);\n return parsed.protocol === 'https:' && parsed.hostname === 'wise.com';\n }\n const parsed = new URL(href, SSR_SENTINEL_ORIGIN);\n return parsed.origin === SSR_SENTINEL_ORIGIN;\n } catch {\n return false;\n }\n }\n}\n\nfunction isInternalHost(hostname: string): boolean {\n const stripped = hostname.replace(IPV6_BRACKETS, '');\n const host = stripped.endsWith('.') ? stripped.slice(0, -1) : stripped;\n if (NUMERIC_IP.test(host)) return true;\n if (HEX_IP.test(host)) return true;\n if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true;\n if (host === 'localhost') return true;\n if (host.endsWith('.internal')) return true;\n return false;\n}\n"],"names":["INVISIBLE_CHARS","CONTROL_CHARS","DANGEROUS_HTML_CHARS","DANGEROUS_SCHEME","ENCODED_COLON_SCHEME","IPV6_BRACKETS","NUMERIC_IP","HEX_IP","sanitiseHref","href","cleaned","replace","trimmed","trim","undefined","test","startsWith","url","URL","ALLOWED_PROTOCOLS","includes","protocol","hostname","username","password","isInternalHost","search","pathname","SSR_SENTINEL_ORIGIN","isHrefAllowedByMode","mode","parsed","origin","stripped","host","endsWith","slice"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"Markup.d.ts","sourceRoot":"","sources":["../../../src/Markup/Markup.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4B,MAAM,OAAO,CAAC;AAMjD,OAAO,KAAK,EAA6B,WAAW,EAAE,MAAM,gBAAgB,CAAC;AA+G7E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CAoCxC,CAAC;AAEF,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"Markup.d.ts","sourceRoot":"","sources":["../../../src/Markup/Markup.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4B,MAAM,OAAO,CAAC;AAMjD,OAAO,KAAK,EAA6B,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAqH7E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CAoCxC,CAAC;AAEF,eAAe,MAAM,CAAC"}
@@ -1,18 +1,4 @@
1
1
  export declare const ALLOWED_TAGS: readonly ["important", "positive", "negative", "strong", "strikethrough", "paragraph", "p", "link", "a"];
2
- /**
3
- * Matches opening and closing tags from the ALLOWED_TAGS allow-list.
4
- *
5
- * Capture groups:
6
- * 1. `/` if this is a closing tag, undefined otherwise
7
- * 2. Tag name
8
- * 3. Raw attribute string between the tag name and `>` (empty for closing tags)
9
- *
10
- * The lookahead `(?=[\s>/])` after the tag name ensures we don't match partial
11
- * words — e.g. `<stronger>` won't be matched as `<strong>`.
12
- *
13
- * Global flag (`g`) allows iterative matching via `exec()`.
14
- */
15
- export declare const TAG_REGEX: RegExp;
16
2
  export declare const TAG_ALIASES: Record<string, string>;
17
3
  export declare const MAX_NESTING_DEPTH = 32;
18
4
  export declare const ALLOWED_TARGETS: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/Markup/constants.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,YAAY,0GAUf,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,SAAS,QAGrB,CAAC;AAEF,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAiC,CAAC;AAGjF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAGpC,eAAO,MAAM,eAAe,UAAsB,CAAC;AAGnD,eAAO,MAAM,iBAAiB,UAAwB,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/Markup/constants.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,YAAY,0GAUf,CAAC;AAEX,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAiC,CAAC;AAGjF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAGpC,eAAO,MAAM,eAAe,UAAsB,CAAC;AAGnD,eAAO,MAAM,iBAAiB,UAAwB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"parseMarkup.d.ts","sourceRoot":"","sources":["../../../../src/Markup/utils/parseMarkup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,iBAAiB,EAA0B,MAAM,cAAc,CAAC;AAC1F,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,eAAO,MAAM,gBAAgB,QAAS,CAAC;AACvC,eAAO,MAAM,8BAA8B,MAAM,CAAC;AAuNlD,oGAAoG;AACpG,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,EAAE,CAYvD"}
1
+ {"version":3,"file":"parseMarkup.d.ts","sourceRoot":"","sources":["../../../../src/Markup/utils/parseMarkup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiC,iBAAiB,EAAe,MAAM,cAAc,CAAC;AAC7F,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,eAAO,MAAM,gBAAgB,QAAS,CAAC;AACvC,eAAO,MAAM,8BAA8B,MAAM,CAAC;AAsPlD,oGAAoG;AACpG,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,EAAE,CAYvD"}
@@ -1,4 +1,4 @@
1
- export declare function isAllowedHref(href: string): boolean;
1
+ export declare function sanitiseHref(href: string): string | undefined;
2
2
  export type AllowLinksMode = 'internal' | 'all' | 'none';
3
3
  export declare function isHrefAllowedByMode(href: string, mode: AllowLinksMode): boolean;
4
4
  //# sourceMappingURL=sanitise.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sanitise.d.ts","sourceRoot":"","sources":["../../../../src/Markup/utils/sanitise.ts"],"names":[],"mappings":"AAaA,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAmFnD;AAED,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,KAAK,GAAG,MAAM,CAAC;AAMzD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,GAAG,OAAO,CAiB/E"}
1
+ {"version":3,"file":"sanitise.d.ts","sourceRoot":"","sources":["../../../../src/Markup/utils/sanitise.ts"],"names":[],"mappings":"AAaA,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAmF7D;AAED,MAAM,MAAM,cAAc,GAAG,UAAU,GAAG,KAAK,GAAG,MAAM,CAAC;AAMzD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,GAAG,OAAO,CAkB/E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transferwise/components",
3
- "version": "0.0.0-experimental-1d81581",
3
+ "version": "0.0.0-experimental-a06eb0d",
4
4
  "description": "Neptune React components",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -3,7 +3,7 @@ import React, { Fragment, useMemo } from 'react';
3
3
  import Link from '../Link';
4
4
 
5
5
  import { parseMarkup } from './utils/parseMarkup';
6
- import { type AllowLinksMode, isAllowedHref, isHrefAllowedByMode } from './utils/sanitise';
6
+ import { type AllowLinksMode, isHrefAllowedByMode, sanitiseHref } from './utils/sanitise';
7
7
  import type { MarkupActions, MarkupNode, MarkupProps } from './Markup.types';
8
8
 
9
9
  const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
@@ -43,9 +43,15 @@ function renderLinkNode(
43
43
  );
44
44
  }
45
45
 
46
- if (node.href && isAllowedHref(node.href) && isHrefAllowedByMode(node.href, allowLinks)) {
46
+ const sanitisedHref = node.href ? sanitiseHref(node.href) : undefined;
47
+ if (sanitisedHref && isHrefAllowedByMode(sanitisedHref, allowLinks)) {
47
48
  return (
48
- <Link key={key} href={node.href} target={node.target} aria-label={node.accessibilityLabel}>
49
+ <Link
50
+ key={key}
51
+ href={sanitisedHref}
52
+ target={node.target}
53
+ aria-label={node.accessibilityLabel}
54
+ >
49
55
  {children}
50
56
  </Link>
51
57
  );
@@ -11,24 +11,6 @@ export const ALLOWED_TAGS = [
11
11
  'a',
12
12
  ] as const;
13
13
 
14
- /**
15
- * Matches opening and closing tags from the ALLOWED_TAGS allow-list.
16
- *
17
- * Capture groups:
18
- * 1. `/` if this is a closing tag, undefined otherwise
19
- * 2. Tag name
20
- * 3. Raw attribute string between the tag name and `>` (empty for closing tags)
21
- *
22
- * The lookahead `(?=[\s>/])` after the tag name ensures we don't match partial
23
- * words — e.g. `<stronger>` won't be matched as `<strong>`.
24
- *
25
- * Global flag (`g`) allows iterative matching via `exec()`.
26
- */
27
- export const TAG_REGEX = new RegExp(
28
- `<(\\/)?(${ALLOWED_TAGS.join('|')})(?=[\\s>/])((?:[^>"']*(?:"[^"]*"|'[^']*'))*[^>"']*)>`,
29
- 'g',
30
- );
31
-
32
14
  export const TAG_ALIASES: Record<string, string> = { p: 'paragraph', a: 'link' };
33
15
 
34
16
  // Maximum recursive nesting before the parser emits tags as literal text (DoS protection)
@@ -495,7 +495,7 @@ describe('parseMarkup', () => {
495
495
  it('captures full href even when it contains > inside quotes', () => {
496
496
  const result = parseMarkup('<link href="</link>">remaining text</link>');
497
497
  // TAG_REGEX sees through > inside quotes — full href is captured
498
- // isAllowedHref (at render time) blocks it because it contains <
498
+ // sanitiseHref (at render time) blocks it because it contains <
499
499
  expect(result).toEqual([
500
500
  {
501
501
  type: 'link',
@@ -636,10 +636,10 @@ describe('parseMarkup', () => {
636
636
  });
637
637
 
638
638
  describe('SVG/IMG in attribute values', () => {
639
- it('extracts href containing img tag (blocked by isAllowedHref at render)', () => {
639
+ it('extracts href containing img tag (blocked by sanitiseHref at render)', () => {
640
640
  const result = parseMarkup('<link href="<img src=x onerror=alert(1)>">text</link>');
641
641
  // TAG_REGEX now captures > inside quotes — full href is extracted
642
- // isAllowedHref blocks it at render time (contains < and >)
642
+ // sanitiseHref blocks it at render time (contains < and >)
643
643
  expect(result).toEqual([
644
644
  {
645
645
  type: 'link',
@@ -668,13 +668,13 @@ describe('parseMarkup', () => {
668
668
  });
669
669
 
670
670
  it('parses normally at exactly MAX_INPUT_LENGTH', () => {
671
- const input = `<important>${ 'x'.repeat(MAX_INPUT_LENGTH - 24) }</important>`;
671
+ const input = `<important>${'x'.repeat(MAX_INPUT_LENGTH - 24)}</important>`;
672
672
  const result = parseMarkup(input);
673
673
  expect(result[0]).toHaveProperty('type', 'important');
674
674
  });
675
675
 
676
676
  it('does not parse markup tags in oversized input', () => {
677
- const input = `<important>text</important>${ 'x'.repeat(MAX_INPUT_LENGTH)}`;
677
+ const input = `<important>text</important>${'x'.repeat(MAX_INPUT_LENGTH)}`;
678
678
  const result = parseMarkup(input);
679
679
  expect(result).toEqual([{ type: 'text', content: input }]);
680
680
  });
@@ -1,4 +1,4 @@
1
- import { ALLOWED_TARGETS, MAX_NESTING_DEPTH, TAG_ALIASES, TAG_REGEX } from '../constants';
1
+ import { ALLOWED_TAGS, ALLOWED_TARGETS, MAX_NESTING_DEPTH, TAG_ALIASES } from '../constants';
2
2
  import { MarkupNode } from '../Markup.types';
3
3
 
4
4
  export { MAX_NESTING_DEPTH };
@@ -11,6 +11,24 @@ const OBJECT_PROTOTYPE_KEYS = new Set(Object.getOwnPropertyNames(Object.prototyp
11
11
  const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g;
12
12
  const HTML_METACHAR = /[<>"']/g;
13
13
 
14
+ /**
15
+ * Matches opening and closing tags from the ALLOWED_TAGS allow-list.
16
+ *
17
+ * Capture groups:
18
+ * 1. `/` if this is a closing tag, undefined otherwise
19
+ * 2. Tag name
20
+ * 3. Raw attribute string between the tag name and `>` (empty for closing tags)
21
+ *
22
+ * The lookahead `(?=[\s>/])` after the tag name ensures we don't match partial
23
+ * words — e.g. `<stronger>` won't be matched as `<strong>`.
24
+ *
25
+ * Global flag (`g`) allows iterative matching via `exec()`.
26
+ */
27
+ const TAG_REGEX = new RegExp(
28
+ `<(\\/)?(${ALLOWED_TAGS.join('|')})(?=[\\s>/])((?:[^>"']*(?:"[^"]*"|'[^']*'))*[^>"']*)>`,
29
+ 'g',
30
+ );
31
+
14
32
  type Token =
15
33
  | { type: 'text'; content: string }
16
34
  | { type: 'newline' }
@@ -157,6 +175,19 @@ function buildNode(rawTag: string, attrs: string, children: MarkupNode[]): Marku
157
175
  } as MarkupNode;
158
176
  }
159
177
 
178
+ /**
179
+ * Matches allow-listed attributes from the raw attribute string captured by TAG_REGEX.
180
+ * Requires whitespace before each attribute name to prevent matching inside quoted values.
181
+ *
182
+ * Example: for input ` href="https://wise.com" target="_blank"`
183
+ * - Match 1: groups = ['href', 'https://wise.com', undefined]
184
+ * - Match 2: groups = ['target', '_blank', undefined]
185
+ *
186
+ * Capture groups:
187
+ * 1. Attribute name (href, action, target, or accessibilityLabel)
188
+ * 2. Double-quoted value (if present)
189
+ * 3. Single-quoted value (if present)
190
+ */
160
191
  const ATTR_REGEX = /(?<=\s)(href|action|target|accessibilityLabel)=(?:"([^"]*)"|'([^']*)')/g;
161
192
 
162
193
  type ParsedAttributes = {
@@ -214,7 +245,7 @@ function sanitizeAttributeValue(
214
245
  case 'accessibilityLabel':
215
246
  return value.replace(HTML_METACHAR, '').slice(0, MAX_ACCESSIBILITY_LABEL_LENGTH);
216
247
  default:
217
- return value;
248
+ return undefined;
218
249
  }
219
250
  }
220
251