Skip to content

Commit 7ceb995

Browse files
aavanianclaude
andcommitted
fix(route/wikipedia): escape link targets in current events
Link targets come from the wikitext, so any editor controls them, and they went straight into an href. sanitizeHtml runs before the tags exist and does not escape double quotes, so a target could close the attribute and add its own, or supply a javascript: link. Build the URL through encodeURI and linkify external URLs only when http(s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 821523b commit 7ceb995

1 file changed

Lines changed: 31 additions & 5 deletions

File tree

lib/routes/wikipedia/current-events.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,43 @@ function stripTemplates(wikitext: string): string {
9595
return wikitext.replaceAll(/\{\{([^}]+)\}\}/g, '$1');
9696
}
9797

98+
// sanitizeHtml runs on the wikitext before any tag is built, so & and < reach this point already
99+
// encoded. Decode them before assembling a URL, or the entity text ends up inside the link. &amp;
100+
// must go last so that &amp;lt; does not decode all the way to <.
101+
function decodeSanitizedEntities(text: string): string {
102+
return text.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&quot;', '"').replaceAll('&amp;', '&');
103+
}
104+
105+
// Link targets come from the wikitext, so they are attacker-controlled and land in an href
106+
// attribute. encodeURI percent-encodes the " that would otherwise close the attribute and let the
107+
// rest of a crafted target through as markup, and it fixes the spaces that made the URLs invalid.
108+
function wikiUrl(target: string): string {
109+
return `https://en.wikipedia.org/wiki/${encodeURI(decodeSanitizedEntities(target).trim().replaceAll(' ', '_'))}`;
110+
}
111+
112+
// Anything that is not http(s) — javascript:, data: — is left as plain text rather than linked.
113+
function externalUrl(url: string): string | null {
114+
const decoded = decodeSanitizedEntities(url).trim();
115+
return /^https?:\/\//i.test(decoded) ? encodeURI(decoded) : null;
116+
}
117+
98118
function convertWikiLinks(html: string): string {
99119
// Convert wiki links [[Link|Text]] or [[Link]]
100-
html = html.replaceAll(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, '<a href="https://en.wikipedia.org/wiki/$1">$2</a>');
101-
html = html.replaceAll(/\[\[([^\]]+)\]\]/g, '<a href="https://en.wikipedia.org/wiki/$1">$1</a>');
120+
html = html.replaceAll(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (_match, target: string, text: string) => `<a href="${wikiUrl(target)}">${text}</a>`);
121+
html = html.replaceAll(/\[\[([^\]]+)\]\]/g, (_match, target: string) => `<a href="${wikiUrl(target)}">${target}</a>`);
102122
return html;
103123
}
104124

105125
function convertExternalLinks(html: string): string {
106-
// Convert external links [URL Text] or [URL]
107-
html = html.replaceAll(/\[([^\s\]]+)\s+([^\s\]][^\]]*|\s)\]/g, '<a href="$1">$2</a>');
108-
html = html.replaceAll(/\[([^\s\]]+)\]/g, '<a href="$1">$1</a>');
126+
// Convert external links [URL Text] or [URL], leaving non-http(s) ones as the original text
127+
html = html.replaceAll(/\[([^\s\]]+)\s+([^\s\]][^\]]*|\s)\]/g, (match, url: string, text: string) => {
128+
const href = externalUrl(url);
129+
return href ? `<a href="${href}">${text}</a>` : match;
130+
});
131+
html = html.replaceAll(/\[([^\s\]]+)\]/g, (match, url: string) => {
132+
const href = externalUrl(url);
133+
return href ? `<a href="${href}">${url}</a>` : match;
134+
});
109135
return html;
110136
}
111137

0 commit comments

Comments
 (0)