fix(docs): block javascript: URLs in renderMarkdown href (XSS guard) (#131)

Per audit batch #127 P2 security minor (issue #130).

## Risk

`renderMarkdown` substituted `[text](url)` → `<a href="...url...">` verbatim
без URL validation. Edge case: `[click](javascript:alert(1))` →
`<a href="javascript:alert(1)">click</a>` execute'нется при click.

Currently low risk (markdown source = build-time fs.readFileSync из
public/docs/, checked into repo), но защита-в-глубину: если когда-либо
markdown source станет user-supplied, vuln materialize.

## Fix

`frontend/src/app/docs/b2b-channels/renderMarkdown.ts`:

1. New `safeUrl(url)` function — allowlist `https://`, `http://`, `/`, `#`,
   `mailto:`. Everything else → `"#"`.
2. Link regex replacement switched from backreference string to callback
   so URL passes через `safeUrl` ДО injection в href.
3. URL unescape → safeUrl → re-escape (HTML escape applied per-line).

## Test cases verified

- `[ok](https://example.com)` → href=`https://example.com` 
- `[ok](/local)` → href=`/local` 
- `[ok](#anchor)` → href=`#anchor` 
- `[ok](mailto:foo@bar.com)` → href=`mailto:foo@bar.com` 
- `[bad](javascript:alert(1))` → href=`#` 
- `[bad](data:text/html,...)` → href=`#` 
- `[bad](vbscript:msgbox(1))` → href=`#` 

## Checks

- tsc: 0 errors
- lint: 0 warnings
- No existing `__tests__/renderMarkdown` to break

## Vault

`fixes/Bug_RenderMarkdown_JavascriptUrl_May14.md` — created.

Closes #130

Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
lekss361 2026-05-14 23:39:47 +03:00 committed by GitHub
parent 8a266502a1
commit 8647114b22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -13,6 +13,24 @@ function escapeHtml(s: string): string {
.replace(/'/g, "&#39;");
}
/**
* Allow only safe URL schemes in rendered links.
* Blocks javascript:, data:, vbscript:, file:, etc.
*/
function safeUrl(url: string): string {
const trimmed = url.trim();
if (
trimmed.startsWith("https://") ||
trimmed.startsWith("http://") ||
trimmed.startsWith("/") ||
trimmed.startsWith("#") ||
trimmed.startsWith("mailto:")
) {
return trimmed;
}
return "#";
}
function inlineMd(line: string): string {
// escape first, then re-introduce safe markup
let html = escapeHtml(line);
@ -20,10 +38,21 @@ function inlineMd(line: string): string {
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
// bold **...**
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// links [text](url)
// links [text](url) — URL is validated before being placed in href.
// Note: at this point `html` is already HTML-escaped, so we must unescape
// the captured URL before passing through safeUrl, then re-escape for href.
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
(_, text: string, escapedUrl: string) => {
const rawUrl = escapedUrl
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
const href = escapeHtml(safeUrl(rawUrl));
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
},
);
// checkboxes
html = html.replace(