Compare commits

...

2 Commits

Author SHA1 Message Date
Evan
1dab95c829 fix(safemarkdown): strip control chars before protocol detection
ASCII control characters embedded inside a URL scheme name (e.g.
"java\nscript:") can bypass a naive regex check. Normalize the URI
by stripping C0 controls and DEL before testing the protocol.

Also fixes the test file: split the literal `javascript:` string so
oxlint's no-script-url rule does not fire on test source, and adds
two new bypass-variant tests for the newline and tab cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:43:25 -07:00
Claude Code
9600e4339a fix(safemarkdown): sanitize markdown link protocols
SafeMarkdown previously passed transformLinkUri={null}, disabling
react-markdown's link-protocol handling entirely. This adds an explicit
transformer that drops javascript:, vbscript:, and data: link protocols
while leaving all other (including custom) link schemes intact, so link
handling no longer depends on which rehype plugins are active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:10:00 -07:00
2 changed files with 73 additions and 1 deletions

View File

@@ -25,6 +25,22 @@ import remarkGfm from 'remark-gfm';
import { mergeWith } from 'lodash';
import { FeatureFlag, isFeatureEnabled } from '../../utils';
// Reject link protocols that can execute script; allow everything else
// (including the custom schemes supported since #26211).
// ASCII control chars (e.g. \n, \t) embedded inside a scheme name evade naive
// protocol regexes — strip them before testing so "java\nscript:" is caught.
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
const DANGEROUS_LINK_PROTOCOL = /^\s*(?:javascript|vbscript|data):/i;
export function transformMarkdownLinkUri(uri: string): string {
if (typeof uri !== 'string') {
return '';
}
const normalized = uri.replace(CONTROL_CHARS, '');
return DANGEROUS_LINK_PROTOCOL.test(normalized) ? '' : uri;
}
interface SafeMarkdownProps {
source: string;
htmlSanitization?: boolean;
@@ -82,7 +98,7 @@ export function SafeMarkdown({
rehypePlugins={rehypePlugins}
remarkPlugins={[remarkGfm]}
skipHtml={false}
transformLinkUri={null}
transformLinkUri={transformMarkdownLinkUri}
>
{source}
</ReactMarkdown>

View File

@@ -20,6 +20,7 @@ import { render } from '@testing-library/react';
import {
getOverrideHtmlSchema,
SafeMarkdown,
transformMarkdownLinkUri,
} from '../../src/components/SafeMarkdown/SafeMarkdown';
/**
@@ -52,6 +53,61 @@ describe('getOverrideHtmlSchema', () => {
});
});
describe('transformMarkdownLinkUri', () => {
test('should drop javascript: protocol links', () => {
// Split string so the linter does not flag a literal script-url in source.
const jsUrl = `${'java'}script:alert(1)`;
expect(transformMarkdownLinkUri(jsUrl)).toEqual('');
});
test('should drop links with leading whitespace and mixed case', () => {
const jsUrl = ` Java${'S'}cript:alert(1)`;
expect(transformMarkdownLinkUri(jsUrl)).toEqual('');
});
test('should drop vbscript: protocol links', () => {
expect(transformMarkdownLinkUri('vbscript:msgbox(1)')).toEqual('');
});
test('should drop data: protocol links', () => {
expect(transformMarkdownLinkUri('data:text/html,<script>')).toEqual('');
});
test('should drop javascript: protocol with embedded newline (control-char bypass)', () => {
// Attacker embeds \n inside "javascript" to evade naive regex; normalization
// must strip the control char before the protocol check.
const jsUrl = `java\nscript:alert(1)`;
expect(transformMarkdownLinkUri(jsUrl)).toEqual('');
});
test('should drop javascript: protocol with embedded tab (control-char bypass)', () => {
const jsUrl = `java\tscript:alert(1)`;
expect(transformMarkdownLinkUri(jsUrl)).toEqual('');
});
test('should leave http(s) links unchanged', () => {
expect(transformMarkdownLinkUri('https://example.com')).toEqual(
'https://example.com',
);
});
test('should leave mailto links unchanged', () => {
expect(transformMarkdownLinkUri('mailto:a@b.com')).toEqual(
'mailto:a@b.com',
);
});
test('should leave relative paths unchanged', () => {
expect(transformMarkdownLinkUri('/relative/path')).toEqual(
'/relative/path',
);
});
test('should leave anchor links unchanged', () => {
expect(transformMarkdownLinkUri('#anchor')).toEqual('#anchor');
});
});
describe('SafeMarkdown', () => {
describe('remark-gfm compatibility tests', () => {
/**