@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.
- package/build/Markup/Markup.js +3 -2
- package/build/Markup/Markup.js.map +1 -1
- package/build/Markup/Markup.mjs +4 -3
- package/build/Markup/Markup.mjs.map +1 -1
- package/build/Markup/constants.js +0 -15
- package/build/Markup/constants.js.map +1 -1
- package/build/Markup/constants.mjs +1 -15
- package/build/Markup/constants.mjs.map +1 -1
- package/build/Markup/utils/parseMarkup.js +32 -5
- package/build/Markup/utils/parseMarkup.js.map +1 -1
- package/build/Markup/utils/parseMarkup.mjs +29 -2
- package/build/Markup/utils/parseMarkup.mjs.map +1 -1
- package/build/Markup/utils/sanitise.js +29 -28
- package/build/Markup/utils/sanitise.js.map +1 -1
- package/build/Markup/utils/sanitise.mjs +29 -28
- package/build/Markup/utils/sanitise.mjs.map +1 -1
- package/build/types/Markup/Markup.d.ts.map +1 -1
- package/build/types/Markup/constants.d.ts +0 -14
- package/build/types/Markup/constants.d.ts.map +1 -1
- package/build/types/Markup/utils/parseMarkup.d.ts.map +1 -1
- package/build/types/Markup/utils/sanitise.d.ts +1 -1
- package/build/types/Markup/utils/sanitise.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Markup/Markup.tsx +9 -3
- package/src/Markup/constants.ts +0 -18
- package/src/Markup/utils/parseMarkup.test.ts +5 -5
- package/src/Markup/utils/parseMarkup.ts +33 -2
- package/src/Markup/utils/sanitise.test.ts +91 -89
- package/src/Markup/utils/sanitise.ts +28 -27
|
@@ -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
|
|
15
|
+
function sanitiseHref(href) {
|
|
16
16
|
const cleaned = href.replace(INVISIBLE_CHARS, '');
|
|
17
17
|
const trimmed = cleaned.trim();
|
|
18
18
|
if (!trimmed) {
|
|
19
|
-
return
|
|
19
|
+
return undefined;
|
|
20
20
|
}
|
|
21
21
|
if (CONTROL_CHARS.test(trimmed)) {
|
|
22
|
-
return
|
|
22
|
+
return undefined;
|
|
23
23
|
}
|
|
24
24
|
if (DANGEROUS_HTML_CHARS.test(trimmed)) {
|
|
25
|
-
return
|
|
25
|
+
return undefined;
|
|
26
26
|
}
|
|
27
27
|
if (DANGEROUS_SCHEME.test(trimmed)) {
|
|
28
|
-
return
|
|
28
|
+
return undefined;
|
|
29
29
|
}
|
|
30
30
|
if (ENCODED_COLON_SCHEME.test(trimmed)) {
|
|
31
|
-
return
|
|
31
|
+
return undefined;
|
|
32
32
|
}
|
|
33
33
|
if (trimmed.startsWith('//')) {
|
|
34
|
-
return
|
|
34
|
+
return undefined;
|
|
35
35
|
}
|
|
36
36
|
if (trimmed.startsWith('\\')) {
|
|
37
|
-
return
|
|
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
|
|
43
|
+
return trimmed;
|
|
44
44
|
}
|
|
45
45
|
if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
|
46
|
-
return
|
|
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
|
|
51
|
+
return undefined;
|
|
52
52
|
}
|
|
53
53
|
if (url.protocol === 'https:') {
|
|
54
54
|
if (!trimmed.startsWith('https://')) {
|
|
55
|
-
return
|
|
55
|
+
return undefined;
|
|
56
56
|
}
|
|
57
57
|
if (!url.hostname) {
|
|
58
|
-
return
|
|
58
|
+
return undefined;
|
|
59
59
|
}
|
|
60
60
|
if (url.username || url.password) {
|
|
61
|
-
return
|
|
61
|
+
return undefined;
|
|
62
62
|
}
|
|
63
63
|
if (isInternalHost(url.hostname)) {
|
|
64
|
-
return
|
|
64
|
+
return undefined;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
if (url.protocol === 'mailto:') {
|
|
68
68
|
if (url.search) {
|
|
69
|
-
return
|
|
69
|
+
return undefined;
|
|
70
70
|
}
|
|
71
71
|
if (!url.pathname) {
|
|
72
|
-
return
|
|
72
|
+
return undefined;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
return
|
|
75
|
+
return trimmed;
|
|
76
76
|
} catch {
|
|
77
|
-
return
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return
|
|
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
|
|
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
|
|
13
|
+
function sanitiseHref(href) {
|
|
14
14
|
const cleaned = href.replace(INVISIBLE_CHARS, '');
|
|
15
15
|
const trimmed = cleaned.trim();
|
|
16
16
|
if (!trimmed) {
|
|
17
|
-
return
|
|
17
|
+
return undefined;
|
|
18
18
|
}
|
|
19
19
|
if (CONTROL_CHARS.test(trimmed)) {
|
|
20
|
-
return
|
|
20
|
+
return undefined;
|
|
21
21
|
}
|
|
22
22
|
if (DANGEROUS_HTML_CHARS.test(trimmed)) {
|
|
23
|
-
return
|
|
23
|
+
return undefined;
|
|
24
24
|
}
|
|
25
25
|
if (DANGEROUS_SCHEME.test(trimmed)) {
|
|
26
|
-
return
|
|
26
|
+
return undefined;
|
|
27
27
|
}
|
|
28
28
|
if (ENCODED_COLON_SCHEME.test(trimmed)) {
|
|
29
|
-
return
|
|
29
|
+
return undefined;
|
|
30
30
|
}
|
|
31
31
|
if (trimmed.startsWith('//')) {
|
|
32
|
-
return
|
|
32
|
+
return undefined;
|
|
33
33
|
}
|
|
34
34
|
if (trimmed.startsWith('\\')) {
|
|
35
|
-
return
|
|
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
|
|
41
|
+
return trimmed;
|
|
42
42
|
}
|
|
43
43
|
if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
|
44
|
-
return
|
|
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
|
|
49
|
+
return undefined;
|
|
50
50
|
}
|
|
51
51
|
if (url.protocol === 'https:') {
|
|
52
52
|
if (!trimmed.startsWith('https://')) {
|
|
53
|
-
return
|
|
53
|
+
return undefined;
|
|
54
54
|
}
|
|
55
55
|
if (!url.hostname) {
|
|
56
|
-
return
|
|
56
|
+
return undefined;
|
|
57
57
|
}
|
|
58
58
|
if (url.username || url.password) {
|
|
59
|
-
return
|
|
59
|
+
return undefined;
|
|
60
60
|
}
|
|
61
61
|
if (isInternalHost(url.hostname)) {
|
|
62
|
-
return
|
|
62
|
+
return undefined;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
if (url.protocol === 'mailto:') {
|
|
66
66
|
if (url.search) {
|
|
67
|
-
return
|
|
67
|
+
return undefined;
|
|
68
68
|
}
|
|
69
69
|
if (!url.pathname) {
|
|
70
|
-
return
|
|
70
|
+
return undefined;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
return
|
|
73
|
+
return trimmed;
|
|
74
74
|
} catch {
|
|
75
|
-
return
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return
|
|
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 {
|
|
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
|
|
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;
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
package/src/Markup/Markup.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
46
|
+
const sanitisedHref = node.href ? sanitiseHref(node.href) : undefined;
|
|
47
|
+
if (sanitisedHref && isHrefAllowedByMode(sanitisedHref, allowLinks)) {
|
|
47
48
|
return (
|
|
48
|
-
<Link
|
|
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
|
);
|
package/src/Markup/constants.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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>${
|
|
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>${
|
|
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
|
|
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
|
|
248
|
+
return undefined;
|
|
218
249
|
}
|
|
219
250
|
}
|
|
220
251
|
|