@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,62 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { LinkProps } from '../Link';
|
|
4
|
+
|
|
5
|
+
export type MarkupNode =
|
|
6
|
+
| { type: 'text'; content: string }
|
|
7
|
+
| { type: 'newline' }
|
|
8
|
+
| { type: 'important'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
9
|
+
| { type: 'positive'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
10
|
+
| { type: 'negative'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
11
|
+
| { type: 'strong'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
12
|
+
| { type: 'strikethrough'; accessibilityLabel?: string; children: MarkupNode[] }
|
|
13
|
+
| { type: 'paragraph'; children: MarkupNode[] }
|
|
14
|
+
| {
|
|
15
|
+
type: 'link';
|
|
16
|
+
href?: string;
|
|
17
|
+
action?: string;
|
|
18
|
+
target?: string;
|
|
19
|
+
accessibilityLabel?: string;
|
|
20
|
+
children: MarkupNode[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type MarkupActionHandler = NonNullable<LinkProps['onClick']>;
|
|
24
|
+
|
|
25
|
+
export type MarkupActions = Record<string, MarkupActionHandler>;
|
|
26
|
+
|
|
27
|
+
export interface MarkupProps {
|
|
28
|
+
/**
|
|
29
|
+
* Markup string to parse and render, or arbitrary JSX to pass through unchanged.
|
|
30
|
+
* Parsing only occurs when children is a string.
|
|
31
|
+
* */
|
|
32
|
+
children?: React.ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Map of placeholder keys to plain-text values. Values are substituted into
|
|
35
|
+
* `{{key}}` placeholders in text nodes and are never parsed as markup.
|
|
36
|
+
* */
|
|
37
|
+
data?: Record<string, string | number>;
|
|
38
|
+
/**
|
|
39
|
+
* Controls which link destinations are allowed to render as clickable links.
|
|
40
|
+
* Non-matching hrefs degrade to plain text. This is an additional restriction
|
|
41
|
+
* on top of built-in protocol/host sanitisation — it never bypasses it.
|
|
42
|
+
* - `'internal'` (default) — root-relative paths (`/*`) and `https://wise.com` URLs
|
|
43
|
+
* - `'all'` — any href that passes base sanitisation (including external HTTPS domains)
|
|
44
|
+
* - `'none'` — all href-based links render as plain text
|
|
45
|
+
* */
|
|
46
|
+
allowLinks?: 'internal' | 'all' | 'none';
|
|
47
|
+
/**
|
|
48
|
+
* Map of action names to click handlers for `<link action="name">` button-mode links.
|
|
49
|
+
* */
|
|
50
|
+
actions?: MarkupActions;
|
|
51
|
+
/**
|
|
52
|
+
* Wrapper element. Defaults to `div` when the parsed tree contains `<paragraph>` nodes,
|
|
53
|
+
* `span` otherwise. Set explicitly to override auto-detection.
|
|
54
|
+
* When passing a custom React component, it must accept a `className` prop.
|
|
55
|
+
* */
|
|
56
|
+
as?: React.ElementType;
|
|
57
|
+
className?: string;
|
|
58
|
+
/**
|
|
59
|
+
* When using a custom React component via `as`, it must also accept a `data-testid` prop.
|
|
60
|
+
* */
|
|
61
|
+
'data-testid'?: string;
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Meta, Source } from '@storybook/addon-docs/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Typography/Markup/Developer notes" />
|
|
4
|
+
|
|
5
|
+
# Developer notes
|
|
6
|
+
|
|
7
|
+
## How parsing works
|
|
8
|
+
|
|
9
|
+
The parser runs in two phases: **tokenise** then **parse**.
|
|
10
|
+
|
|
11
|
+
### Phase 1: Tokenise
|
|
12
|
+
|
|
13
|
+
The input string is scanned left-to-right with a regex that only recognises allowed tag names. Everything between matches becomes text. Newlines become their own tokens.
|
|
14
|
+
|
|
15
|
+
<Source dark language="text" code={`
|
|
16
|
+
Input: "Hello <important>world</important>!\\nBye"
|
|
17
|
+
|
|
18
|
+
Tokens: [ TEXT:"Hello " ]
|
|
19
|
+
[ OPEN:important ]
|
|
20
|
+
[ TEXT:"world" ]
|
|
21
|
+
[ CLOSE:important ]
|
|
22
|
+
[ TEXT:"!" ]
|
|
23
|
+
[ NEWLINE ]
|
|
24
|
+
[ TEXT:"Bye" ]
|
|
25
|
+
`} />
|
|
26
|
+
|
|
27
|
+
Unrecognised tags like `<script>` or `<div>` are never tokenised — they stay as part of the text content. This is the first security layer.
|
|
28
|
+
|
|
29
|
+
### Phase 2: Recursive descent parse
|
|
30
|
+
|
|
31
|
+
The token stream is consumed by a recursive parser that builds a tree of nodes.
|
|
32
|
+
|
|
33
|
+
<Source
|
|
34
|
+
dark
|
|
35
|
+
language="text"
|
|
36
|
+
code={`
|
|
37
|
+
parse(tokens, until=none)
|
|
38
|
+
│
|
|
39
|
+
├─ TEXT:"Hello " ────────────────────► { type: 'text', content: 'Hello ' }
|
|
40
|
+
├─ OPEN:important
|
|
41
|
+
│ │
|
|
42
|
+
│ ├─ parse(tokens, until='important') // recurse
|
|
43
|
+
│ │ ├─ TEXT:"world" ───────────────► { type: 'text', content: 'world' }
|
|
44
|
+
│ │ └─ CLOSE:important ────────────► return { closed: true, nodes: [...] }
|
|
45
|
+
│ │
|
|
46
|
+
│ └─ result.closed? YES ────────────► { type: 'important', children: [...] }
|
|
47
|
+
│
|
|
48
|
+
├─ TEXT:"!" ─────────────────────────► { type: 'text', content: '!' }
|
|
49
|
+
├─ NEWLINE ──────────────────────────► { type: 'newline' }
|
|
50
|
+
└─ TEXT:"Bye" ───────────────────────► { type: 'text', content: 'Bye' }
|
|
51
|
+
`}
|
|
52
|
+
/>
|
|
53
|
+
|
|
54
|
+
The key insight is the `closed` flag. When we see an open tag, we recurse to collect its children. Two outcomes:
|
|
55
|
+
|
|
56
|
+
1. **Matching close tag found:** build a proper node with children.
|
|
57
|
+
2. **No matching close tag found:** treat the open tag as literal text, and return the already-parsed children.
|
|
58
|
+
|
|
59
|
+
This gives graceful degradation for malformed input — no crashes, no swallowed content.
|
|
60
|
+
|
|
61
|
+
### Nesting
|
|
62
|
+
|
|
63
|
+
Because each open tag recurses, nesting is handled naturally:
|
|
64
|
+
|
|
65
|
+
<Source dark language="text" code={`
|
|
66
|
+
Input: "<p><important>Read the <a href="/terms">terms</a></important></p>"
|
|
67
|
+
|
|
68
|
+
Tree: paragraph
|
|
69
|
+
└─ important
|
|
70
|
+
├─ text: "Read the "
|
|
71
|
+
└─ link (href="/terms")
|
|
72
|
+
└─ text: "terms"
|
|
73
|
+
`} />
|
|
74
|
+
|
|
75
|
+
The parser enforces a `MAX_NESTING_DEPTH` limit (32). Beyond that depth, tags are emitted as literal text instead of recursing — this prevents stack overflow from deeply-nested input.
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { Meta } from '@storybook/addon-docs/blocks';
|
|
2
|
+
import {
|
|
3
|
+
MAX_NESTING_DEPTH,
|
|
4
|
+
MAX_INPUT_LENGTH,
|
|
5
|
+
MAX_ACCESSIBILITY_LABEL_LENGTH,
|
|
6
|
+
} from '../utils/parseMarkup';
|
|
7
|
+
import { Vulnerability, Badge } from './Vulnerability/Vulnerability';
|
|
8
|
+
import { InfoPrompt } from '../../Prompt/InfoPrompt/';
|
|
9
|
+
import Link from '../../Link/';
|
|
10
|
+
|
|
11
|
+
<Meta title="Typography/Markup/Parser security" />
|
|
12
|
+
|
|
13
|
+
# Parser security
|
|
14
|
+
|
|
15
|
+
## Security model
|
|
16
|
+
|
|
17
|
+
Five layers, each independent:
|
|
18
|
+
|
|
19
|
+
1. Tag allow-list (tokeniser). Only known tags are tokenised. `<script>`, `<img>`, `<iframe>`, etc. stay as
|
|
20
|
+
literal text.
|
|
21
|
+
2. Attribute allow-list (parseAttributes). Only `href`, `action`, `target`, `accessibilityLabel` are extracted. `onclick`, `onerror`, etc. are silently ignored.
|
|
22
|
+
3. Protocol allow-list (sanitise). Only `https:`, `mailto:`, `/path`, and `#fragment` are allowed. Relative paths (`./`, `../`), `javascript:`, `data:`, `//`, and all other schemes are blocked.
|
|
23
|
+
4. Link destination mode (`allowLinks`). The `internal` mode (default) restricts links to root-relative paths and `https://wise.com/*`. Root-relative paths are validated through the WHATWG URL parser to ensure they don't escape same-origin after browser normalization.
|
|
24
|
+
5. Input limits. Input exceeding {MAX_INPUT_LENGTH} characters is returned as plain text (DoS prevention). `accessibilityLabel` is capped at {MAX_ACCESSIBILITY_LABEL_LENGTH} characters (social engineering prevention).
|
|
25
|
+
|
|
26
|
+
No `dangerouslySetInnerHTML` is used anywhere. The parser produces a typed AST, and the renderer maps it to React elements — React's own escaping handles the rest.
|
|
27
|
+
|
|
28
|
+
<InfoPrompt
|
|
29
|
+
description={
|
|
30
|
+
<>
|
|
31
|
+
{'The parser has been tested against the attack vectors documented in '}
|
|
32
|
+
<Link href="https://github.com/cure53/DOMPurify">DOMPurify 3.4.11</Link>
|
|
33
|
+
{' and '}
|
|
34
|
+
<Link href="https://github.com/apostrophecms/sanitize-html">sanitize-html 2.17.5</Link>
|
|
35
|
+
{
|
|
36
|
+
'. It aims to match the security grading of those industry-standard libraries within the scope of its supported markup subset.'
|
|
37
|
+
}
|
|
38
|
+
</>
|
|
39
|
+
}
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<br />
|
|
43
|
+
<br />
|
|
44
|
+
|
|
45
|
+
## Attack vector
|
|
46
|
+
|
|
47
|
+
This page documents all attack vectors tested against the Markup component. Each section shows the raw malicious input and how the component neutralises it.
|
|
48
|
+
|
|
49
|
+
### XSS: Dangerous protocols
|
|
50
|
+
|
|
51
|
+
#### javascript: protocol <Badge severity="High" />
|
|
52
|
+
|
|
53
|
+
The `javascript:` protocol is blocked by the href sanitiser. The link degrades to plain text.
|
|
54
|
+
|
|
55
|
+
<Vulnerability markup='<link href="javascript:alert(1)">click me</link>' />
|
|
56
|
+
|
|
57
|
+
#### javascript: mixed case <Badge severity="High" />
|
|
58
|
+
|
|
59
|
+
`new URL()` normalises the protocol to lowercase before checking the allowlist.
|
|
60
|
+
|
|
61
|
+
<Vulnerability markup='<link href="JavaScript:alert(1)">mixed case</link>' />
|
|
62
|
+
|
|
63
|
+
#### data: URI with script <Badge severity="High" />
|
|
64
|
+
|
|
65
|
+
The `data:` protocol is blocked regardless of MIME type or encoding.
|
|
66
|
+
|
|
67
|
+
<Vulnerability markup='<link href="data:text/html,<script>alert(1)</script>">data URI</link>' />
|
|
68
|
+
|
|
69
|
+
#### data: base64-encoded <Badge severity="High" />
|
|
70
|
+
|
|
71
|
+
Base64-encoded payloads are equally blocked.
|
|
72
|
+
|
|
73
|
+
<Vulnerability markup='<link href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">base64</link>' />
|
|
74
|
+
|
|
75
|
+
#### vbscript: protocol <Badge severity="High" />
|
|
76
|
+
|
|
77
|
+
Legacy protocol explicitly blocked.
|
|
78
|
+
|
|
79
|
+
<Vulnerability markup='<link href="vbscript:MsgBox(1)">vbscript</link>' />
|
|
80
|
+
|
|
81
|
+
#### SVG data URI in href <Badge severity="High" />
|
|
82
|
+
|
|
83
|
+
SVG data URIs can contain executable JavaScript. Blocked by the `data:` protocol check.
|
|
84
|
+
|
|
85
|
+
<Vulnerability markup='<link href="data:image/svg+xml,<svg onload=alert(1)>">svg data</link>' />
|
|
86
|
+
|
|
87
|
+
### XSS: Control character bypass
|
|
88
|
+
|
|
89
|
+
#### Null byte in scheme <Badge severity="High" />
|
|
90
|
+
|
|
91
|
+
Null bytes inserted mid-scheme cause URL parsing to fail. The sanitiser rejects any href containing control characters.
|
|
92
|
+
|
|
93
|
+
<Vulnerability markup={'<link href="java\x00script:alert(1)">null byte</link>'} />
|
|
94
|
+
|
|
95
|
+
#### Zero-width space prefix <Badge severity="High" />
|
|
96
|
+
|
|
97
|
+
Invisible characters (ZWSP, BOM, ZWNJ) are stripped before validation so they cannot bypass `.trim()`.
|
|
98
|
+
|
|
99
|
+
<Vulnerability markup={'<link href="javascript:alert(1)">invisible prefix</link>'} />
|
|
100
|
+
|
|
101
|
+
#### URL-encoded colon (%3A) <Badge severity="Medium" />
|
|
102
|
+
|
|
103
|
+
The sanitiser checks for `javascript%3a` patterns that evade URL constructor parsing.
|
|
104
|
+
|
|
105
|
+
<Vulnerability markup='<link href="javascript%3Aalert(1)">encoded colon</link>' />
|
|
106
|
+
|
|
107
|
+
### XSS: HTML tag injection
|
|
108
|
+
|
|
109
|
+
#### <script> tag <Badge severity="High" />
|
|
110
|
+
|
|
111
|
+
Not in the allowed tag list. Never tokenised — remains as escaped text.
|
|
112
|
+
|
|
113
|
+
<Vulnerability markup='<script>alert("xss")</script>' />
|
|
114
|
+
|
|
115
|
+
#### Deeply malformed nesting with <script> <Badge severity="High" />
|
|
116
|
+
|
|
117
|
+
Mismatched closing tags, interleaved valid and invalid elements, and duplicated `<script>` blocks
|
|
118
|
+
cannot confuse the parser into executing code. Unrecognised tags stay as literal text, and
|
|
119
|
+
mismatched close tags degrade to visible text — no combination of malformed nesting can promote
|
|
120
|
+
a `<script>` to an executable element.
|
|
121
|
+
|
|
122
|
+
<Vulnerability markup="start <strong>hello<important></strong><script>console.log(1)</strong><script>console.log(1)</script></important>" />
|
|
123
|
+
|
|
124
|
+
#### <img onerror> <Badge severity="High" />
|
|
125
|
+
|
|
126
|
+
Not in the allowed tag list. Passes through as literal text rendered via `textContent`.
|
|
127
|
+
|
|
128
|
+
<Vulnerability markup="<img src=x onerror=alert(1)>" />
|
|
129
|
+
|
|
130
|
+
#### <svg onload> <Badge severity="High" />
|
|
131
|
+
|
|
132
|
+
Not in the allowed tag list. Rendered as harmless text content.
|
|
133
|
+
|
|
134
|
+
<Vulnerability markup="<svg onload=alert(1)>" />
|
|
135
|
+
|
|
136
|
+
#### <svg> inside allowed element <Badge severity="High" />
|
|
137
|
+
|
|
138
|
+
Even inside a valid element, unrecognised tags are text nodes. React escapes them.
|
|
139
|
+
|
|
140
|
+
<Vulnerability markup="<important><svg onload=alert(1)></important>" />
|
|
141
|
+
|
|
142
|
+
#### <iframe> injection <Badge severity="High" />
|
|
143
|
+
|
|
144
|
+
Not in the allowed tag list. The entire string renders as escaped text.
|
|
145
|
+
|
|
146
|
+
<Vulnerability markup='<iframe src="javascript:alert(1)"></iframe>' />
|
|
147
|
+
|
|
148
|
+
#### <img> inside link <Badge severity="High" />
|
|
149
|
+
|
|
150
|
+
Unrecognised tags within link content are safe text children.
|
|
151
|
+
|
|
152
|
+
<Vulnerability markup='<link href="/safe"><img src=x onerror=alert(1)></link>' />
|
|
153
|
+
|
|
154
|
+
#### <input> with autofocus <Badge severity="High" />
|
|
155
|
+
|
|
156
|
+
Not in the allowed tag list. Cannot trigger focus-based XSS.
|
|
157
|
+
|
|
158
|
+
<Vulnerability markup="<input onfocus=alert(1) autofocus>" />
|
|
159
|
+
|
|
160
|
+
#### <details ontoggle> <Badge severity="High" />
|
|
161
|
+
|
|
162
|
+
Not in the allowed tag list. Cannot trigger toggle-based XSS.
|
|
163
|
+
|
|
164
|
+
<Vulnerability markup="<details open ontoggle=alert(1)>" />
|
|
165
|
+
|
|
166
|
+
### XSS: Attribute injection
|
|
167
|
+
|
|
168
|
+
#### onclick attribute on link <Badge severity="High" />
|
|
169
|
+
|
|
170
|
+
Only `href`, `action`, `target`, and `accessibilityLabel` are extracted. All event handlers are discarded.
|
|
171
|
+
|
|
172
|
+
<Vulnerability markup='<link href="/safe" onclick="alert(1)">click</link>' />
|
|
173
|
+
|
|
174
|
+
#### HTML in accessibilityLabel <Badge severity="Medium" />
|
|
175
|
+
|
|
176
|
+
HTML metacharacters are stripped from `accessibilityLabel` to prevent injection if the value is ever used in an unsafe context.
|
|
177
|
+
|
|
178
|
+
<Vulnerability markup='<important accessibilityLabel="<img onerror=alert(1)>">safe text</important>' />
|
|
179
|
+
|
|
180
|
+
#### Slash as attribute separator <Badge severity="Low" />
|
|
181
|
+
|
|
182
|
+
The attribute regex now requires whitespace before attribute names. `<link/href="x">` no longer parses the href.
|
|
183
|
+
|
|
184
|
+
<Vulnerability markup='<link/href="/evil">slash separator</link>' />
|
|
185
|
+
|
|
186
|
+
### Open redirect
|
|
187
|
+
|
|
188
|
+
#### Protocol-relative URL (//) <Badge severity="High" />
|
|
189
|
+
|
|
190
|
+
Protocol-relative URLs navigate to external domains. Blocked by `startsWith("//")` check.
|
|
191
|
+
|
|
192
|
+
<Vulnerability markup='<link href="//evil.com/phishing">protocol relative</link>' />
|
|
193
|
+
|
|
194
|
+
#### Backslash after slash (/\) <Badge severity="High" />
|
|
195
|
+
|
|
196
|
+
Browsers normalise `\` to `/`, making `/\evil.com` equivalent to `//evil.com`. The WHATWG URL parser
|
|
197
|
+
is used to verify that root-relative paths stay on the same origin after normalization — the same
|
|
198
|
+
parser the browser uses to resolve the `href`.
|
|
199
|
+
|
|
200
|
+
<Vulnerability markup='<link href="/\evil.com">backslash redirect</link>' />
|
|
201
|
+
|
|
202
|
+
#### Backslash with @ (/\@evil.com) <Badge severity="High" />
|
|
203
|
+
|
|
204
|
+
Combining backslash with userinfo syntax could trick naive string checks. The URL-parser origin
|
|
205
|
+
check catches this because the resolved URL no longer matches the sentinel origin.
|
|
206
|
+
|
|
207
|
+
<Vulnerability markup='<link href="/\@evil.com">backslash userinfo</link>' />
|
|
208
|
+
|
|
209
|
+
#### Relative path traversal <Badge severity="Medium" />
|
|
210
|
+
|
|
211
|
+
Relative paths (`./`, `../`) could be exploited to navigate outside the expected domain or bypass path-based security checks. Only absolute `https:` URLs, `mailto:`, root-relative `/path`, and `#fragment` are allowed.
|
|
212
|
+
|
|
213
|
+
<Vulnerability markup='<link href="../admin/secret">parent traversal</link>' />
|
|
214
|
+
|
|
215
|
+
<Vulnerability markup='<link href="./evil-page">dot relative</link>' />
|
|
216
|
+
|
|
217
|
+
#### Credential confusion (@) <Badge severity="Medium" />
|
|
218
|
+
|
|
219
|
+
In `https://good.com@evil.com`, the actual host is `evil.com`. URLs with username/password components are rejected.
|
|
220
|
+
|
|
221
|
+
<Vulnerability markup='<link href="https://good.com@evil.com">phishing link</link>' />
|
|
222
|
+
|
|
223
|
+
### SSRF: Internal network
|
|
224
|
+
|
|
225
|
+
#### Localhost IP <Badge severity="Medium" />
|
|
226
|
+
|
|
227
|
+
Numeric IPs, hex IPs, localhost, and `*.internal` hosts are blocked.
|
|
228
|
+
|
|
229
|
+
<Vulnerability markup='<link href="https://127.0.0.1/admin">localhost</link>' />
|
|
230
|
+
|
|
231
|
+
#### Cloud metadata endpoint <Badge severity="Medium" />
|
|
232
|
+
|
|
233
|
+
Prevents access to cloud instance metadata services.
|
|
234
|
+
|
|
235
|
+
<Vulnerability markup='<link href="https://169.254.169.254/latest/meta-data">AWS metadata</link>' />
|
|
236
|
+
|
|
237
|
+
#### IPv6 localhost <Badge severity="Medium" />
|
|
238
|
+
|
|
239
|
+
IPv6 loopback address blocked.
|
|
240
|
+
|
|
241
|
+
<Vulnerability markup='<link href="https://[::1]/">ipv6 localhost</link>' />
|
|
242
|
+
|
|
243
|
+
### Open redirect: WHATWG URL-parser normalization
|
|
244
|
+
|
|
245
|
+
The browser's WHATWG URL parser applies normalization (stripping tabs, newlines, resolving `\` to `/`)
|
|
246
|
+
that can turn an apparently-safe root-relative path into a cross-origin navigation. Rather than
|
|
247
|
+
maintaining an ever-growing list of regex patterns, the `internal` mode validates root-relative paths
|
|
248
|
+
by resolving them through the same URL parser the browser uses, then comparing the resolved origin
|
|
249
|
+
against a synthetic sentinel. If they differ, the path escapes same-origin and is blocked.
|
|
250
|
+
|
|
251
|
+
#### Tab in path (/\t/evil.com) <Badge severity="High" />
|
|
252
|
+
|
|
253
|
+
A literal tab character causes the WHATWG parser to strip it, turning `/[tab]/evil.com` into
|
|
254
|
+
`//evil.com` — a protocol-relative URL navigating to `evil.com`. Caught by both the control-character
|
|
255
|
+
check in base sanitisation and the URL-parser origin check.
|
|
256
|
+
|
|
257
|
+
<Vulnerability markup={'<link href="/\t/evil.com">tab bypass</link>'} />
|
|
258
|
+
|
|
259
|
+
#### Newline in path (/\n/evil.com) <Badge severity="High" />
|
|
260
|
+
|
|
261
|
+
Same class as tab — the parser strips the newline, producing `//evil.com`.
|
|
262
|
+
|
|
263
|
+
<Vulnerability markup={'<link href="/\n/evil.com">newline bypass</link>'} />
|
|
264
|
+
|
|
265
|
+
### DoS: Stack overflow
|
|
266
|
+
|
|
267
|
+
#### Deep nesting <Badge severity="High" />
|
|
268
|
+
|
|
269
|
+
The recursive parser enforces a `MAX_NESTING_DEPTH` limit (currently {MAX_NESTING_DEPTH}). Beyond that, tags are emitted as literal text.
|
|
270
|
+
|
|
271
|
+
<Vulnerability
|
|
272
|
+
markup={
|
|
273
|
+
'<important>'.repeat(MAX_NESTING_DEPTH + 20) +
|
|
274
|
+
'deep' +
|
|
275
|
+
'</important>'.repeat(MAX_NESTING_DEPTH + 20)
|
|
276
|
+
}
|
|
277
|
+
/>
|
|
278
|
+
|
|
279
|
+
### DoS: Input length
|
|
280
|
+
|
|
281
|
+
#### Oversized input <Badge severity="Medium" />
|
|
282
|
+
|
|
283
|
+
Inputs exceeding `MAX_INPUT_LENGTH` ({MAX_INPUT_LENGTH} characters) are returned as a single
|
|
284
|
+
unprocessed text node. This prevents regex-based DoS on pathological inputs without silently
|
|
285
|
+
truncating content.
|
|
286
|
+
|
|
287
|
+
<Vulnerability markup={'<important>attack</important>' + 'x'.repeat(MAX_INPUT_LENGTH)} />
|
|
288
|
+
|
|
289
|
+
### accessibilityLabel abuse
|
|
290
|
+
|
|
291
|
+
#### Oversized aria-label (social engineering) <Badge severity="Low" />
|
|
292
|
+
|
|
293
|
+
The `accessibilityLabel` attribute is truncated to {MAX_ACCESSIBILITY_LABEL_LENGTH} characters.
|
|
294
|
+
This prevents attacker-controlled input from injecting arbitrarily long fake security messages
|
|
295
|
+
into the accessibility tree.
|
|
296
|
+
|
|
297
|
+
<Vulnerability
|
|
298
|
+
markup={'<important accessibilityLabel="' + 'A'.repeat(300) + '">text</important>'}
|
|
299
|
+
/>
|
|
300
|
+
|
|
301
|
+
### Prototype pollution
|
|
302
|
+
|
|
303
|
+
#### action="\_\_proto\_\_" <Badge severity="Medium" />
|
|
304
|
+
|
|
305
|
+
Action values that collide with `Object.prototype` properties are rejected by the parser.
|
|
306
|
+
Without this, `actions?.['__proto__']` resolves to a truthy inherited value, rendering an unintended clickable link.
|
|
307
|
+
|
|
308
|
+
<Vulnerability markup='<link action="__proto__">click</link>' />
|
|
309
|
+
|
|
310
|
+
#### action="constructor" <Badge severity="Medium" />
|
|
311
|
+
|
|
312
|
+
`Object.prototype.constructor` is the `Object` function — truthy and callable. Blocked.
|
|
313
|
+
|
|
314
|
+
<Vulnerability markup='<link action="constructor">click</link>' />
|
|
315
|
+
|
|
316
|
+
### Attribute injection
|
|
317
|
+
|
|
318
|
+
#### Duplicate href (first wins) <Badge severity="Medium" />
|
|
319
|
+
|
|
320
|
+
When the same attribute appears twice, the first value is used. Previously the last won,
|
|
321
|
+
which could bypass content filters that only inspect the first occurrence.
|
|
322
|
+
|
|
323
|
+
<Vulnerability markup='<link href="/safe" href="javascript:alert(1)">duplicate href</link>' />
|
|
324
|
+
|
|
325
|
+
#### target="\_top" (iframe breakout) <Badge severity="Low" />
|
|
326
|
+
|
|
327
|
+
The `target` attribute is restricted to `_blank` and `_self`. Values like `_top` and `_parent` are stripped.
|
|
328
|
+
|
|
329
|
+
<Vulnerability markup='<link href="https://example.com" target="_top">escape iframe</link>' />
|
|
330
|
+
|
|
331
|
+
### Mailto abuse
|
|
332
|
+
|
|
333
|
+
#### Header injection (cc/bcc) <Badge severity="Medium" />
|
|
334
|
+
|
|
335
|
+
Mailto query parameters can inject additional recipients. Mailto URLs with query strings are blocked.
|
|
336
|
+
|
|
337
|
+
<Vulnerability markup='<link href="mailto:evil@evil.com?cc=victim@good.com&bcc=spy@evil.com">contact</link>' />
|
|
338
|
+
|
|
339
|
+
#### No recipient (social engineering) <Badge severity="Medium" />
|
|
340
|
+
|
|
341
|
+
Mailto URLs must have an actual address.
|
|
342
|
+
|
|
343
|
+
<Vulnerability markup='<link href="mailto:?subject=urgent&body=click%20here">empty mailto</link>' />
|