Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/streamdown-rn/src/__tests__/sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ describe('Security: URL Sanitization', () => {
expect(sanitizeURL('wss://evil.com/socket')).toBeNull();
expect(sanitizeURL('blob:https://evil.com/uuid')).toBeNull();
});

it('should block protocol-relative URLs', () => {
expect(sanitizeURL('//evil.com/path')).toBeNull();
expect(sanitizeURL('\\\\evil.local\\share')).toBeNull();
});
});

describe('allowed protocols', () => {
Expand Down Expand Up @@ -171,6 +176,25 @@ describe('Security: Prop Sanitization', () => {
expect(safe.description).toBe('Some text without URLs');
});

it('should sanitize protocol-relative URLs in URL-like keys', () => {
const props = { url: '//evil.com/payload', href: '/safe/path' };
const safe = sanitizeProps(props);
expect(safe.url).toBe('');
expect(safe.href).toBe('/safe/path');
});

it('should sanitize URL-like keys even without explicit protocol prefix', () => {
const props = {
image_url: 'javascript:alert(1)',
endpoint: 'https://api.example.com/v1',
title: 'Status card',
};
const safe = sanitizeProps(props);
expect(safe.image_url).toBe('');
expect(safe.endpoint).toBe('https://api.example.com/v1');
expect(safe.title).toBe('Status card');
});

it('should preserve primitives', () => {
const props = { count: 42, active: true, data: null };
const safe = sanitizeProps(props);
Expand Down Expand Up @@ -245,6 +269,12 @@ describe('Security: Full Pipeline Integration', () => {
expect(data.props.src).toBe('');
});

it('should block protocol-relative URLs in component props', () => {
const input = '[{c:"Image",p:{"src":"//evil.com/tracker.png"}}]';
const data = extractComponentData(input);
expect(data.props.src).toBe('');
});

it('should preserve safe URLs in component props', () => {
const input = '[{c:"Link",p:{"href":"https://example.com"}}]';
const data = extractComponentData(input);
Expand Down
41 changes: 41 additions & 0 deletions packages/streamdown-rn/src/__tests__/splitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ describe('Block Splitter', () => {
expect(registry.blocks.length).toBe(1);
expect(registry.activeBlock).toBeNull();
});

it('should split code block and following paragraph in one chunk', () => {
const input = '```ts\nconst x = 1;\n```\n\nFollowing paragraph';
const registry = processNewContent(INITIAL_REGISTRY, input);

expect(registry.blocks.length).toBe(1);
expect(registry.blocks[0].type).toBe('codeBlock');
expect(registry.blocks[0].content).toBe('```ts\nconst x = 1;\n```');
expect(registry.activeBlock?.type).toBe('paragraph');
expect(registry.activeBlock?.content.trim()).toBe('Following paragraph');
});
});

describe('Component detection', () => {
Expand Down Expand Up @@ -382,6 +393,36 @@ describe('Block Splitter', () => {
registry = processNewContent(registry, '1. ');
expect(registry.activeBlock?.type).toBe('list');
});

it('should produce identical results for large mixed input regardless of chunk size', () => {
const fullInput = [
'# Title',
'',
'Paragraph start ' + 'x'.repeat(3000),
'',
'[{c:"StatusCard",p:{"title":"Ops","status":"ok"}}]',
'',
'```js',
'const n = 42;',
'```',
'',
'Tail paragraph',
].join('\n');

const allAtOnce = processNewContent(INITIAL_REGISTRY, fullInput);

let chunked = INITIAL_REGISTRY;
for (let i = 64; i <= fullInput.length; i += 64) {
chunked = processNewContent(chunked, fullInput.slice(0, i));
}
chunked = processNewContent(chunked, fullInput);

expect(chunked.blocks.length).toBe(allAtOnce.blocks.length);
expect(chunked.blocks.map(b => b.type)).toEqual(allAtOnce.blocks.map(b => b.type));
expect(chunked.blocks.map(b => b.content)).toEqual(allAtOnce.blocks.map(b => b.content));
expect(chunked.activeBlock?.type).toBe(allAtOnce.activeBlock?.type);
expect(chunked.activeBlock?.content).toBe(allAtOnce.activeBlock?.content);
});
});
});

28 changes: 25 additions & 3 deletions packages/streamdown-rn/src/core/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export function sanitizeURL(url: string): string | null {
if (trimmed.length === 0) {
return null;
}

// Block protocol-relative and UNC paths (can bypass protocol allowlists)
if (trimmed.startsWith('//') || trimmed.startsWith('\\\\')) {
if (process.env.NODE_ENV !== 'production') {
console.warn('[streamdown-rn] Blocked protocol-relative URL');
}
return null;
}

// Allow relative URLs - they're safe
if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
Expand Down Expand Up @@ -91,6 +99,20 @@ function looksLikeURL(value: string): boolean {
return /^[a-z][a-z0-9+.-]*:/i.test(value);
}

/**
* Check if a prop key is likely intended to carry a URL.
*/
function isLikelyURLKey(key: string): boolean {
return /(?:^|_)(?:url|uri|href|src|link|website|endpoint|avatar|image)(?:$|_)/i.test(key);
}

/**
* Decide whether a prop string should be URL-sanitized.
*/
function shouldSanitizeStringProp(key: string, value: string): boolean {
return isLikelyURLKey(key) || looksLikeURL(value);
}

/**
* Recursively sanitize component props.
*
Expand All @@ -109,8 +131,8 @@ export function sanitizeProps(props: Record<string, unknown>): Record<string, un

for (const [key, value] of Object.entries(props)) {
if (typeof value === 'string') {
// Only check strings that look like URLs
if (looksLikeURL(value)) {
// Sanitize known URL props and protocol-like strings
if (shouldSanitizeStringProp(key, value)) {
const safeUrl = sanitizeURL(value);
result[key] = safeUrl ?? '';
} else {
Expand All @@ -122,7 +144,7 @@ export function sanitizeProps(props: Record<string, unknown>): Record<string, un
if (typeof item === 'object' && item !== null) {
return sanitizeProps(item as Record<string, unknown>);
}
if (typeof item === 'string' && looksLikeURL(item)) {
if (typeof item === 'string' && (isLikelyURLKey(key) || looksLikeURL(item))) {
return sanitizeURL(item) ?? '';
}
return item;
Expand Down
45 changes: 33 additions & 12 deletions packages/streamdown-rn/src/core/splitter/blockClosers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,42 @@
* Helpers to detect when special blocks are closed.
*/

export function isCodeBlockClosed(content: string): boolean {
const lines = content.split('\n');
if (lines.length < 2) return false;

const firstLine = lines[0];
const lastLine = lines[lines.length - 1];
export function findCodeBlockCloseIndex(content: string): number {
const firstNewlineIndex = content.indexOf('\n');
if (firstNewlineIndex === -1) return -1;

const firstLine = content.slice(0, firstNewlineIndex).replace(/\r$/, '');
const openMatch = firstLine.match(/^(`{3,}|~{3,})/);
if (!openMatch) return false;
if (!openMatch) return -1;

const fence = openMatch[1];
const fenceChar = fence[0];
const fenceLen = fence.length;
const closePattern = new RegExp(`^${fenceChar}{${fenceLen},}\\s*$`);
return closePattern.test(lastLine);

let cursor = firstNewlineIndex + 1;
while (cursor <= content.length) {
const nextNewlineIndex = content.indexOf('\n', cursor);
const lineEnd = nextNewlineIndex === -1 ? content.length : nextNewlineIndex;
const line = content.slice(cursor, lineEnd).replace(/\r$/, '');

if (closePattern.test(line)) {
return lineEnd;
}

if (nextNewlineIndex === -1) break;
cursor = nextNewlineIndex + 1;
}

return -1;
}

export function isComponentClosed(content: string): boolean {
if (!content.startsWith('[{')) return false;
export function isCodeBlockClosed(content: string): boolean {
return findCodeBlockCloseIndex(content) === content.length;
}

export function findComponentCloseIndex(content: string): number {
if (!content.startsWith('[{')) return -1;

let braceDepth = 1;
let bracketDepth = 1;
Expand Down Expand Up @@ -59,10 +76,14 @@ export function isComponentClosed(content: string): boolean {
content[i - 1] === '}' &&
char === ']'
) {
return true;
return i + 1;
}
}

return false;
return -1;
}

export function isComponentClosed(content: string): boolean {
return findComponentCloseIndex(content) === content.length;
}

55 changes: 14 additions & 41 deletions packages/streamdown-rn/src/core/splitter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { logDebug, logStateSnapshot } from './logger';
import { processLines } from './processLines';
import { finalizeBlock } from './finalizeBlock';

const SPLITTER_VERSION = 'char-level-v1';
const SPLITTER_VERSION = 'incremental-v2';

/**
* Process new content character-by-character.
*
* This ensures consistent block boundary detection regardless of chunk size.
* Whether content arrives 1 character at a time or 1000 characters at once,
* block boundaries are detected at the exact character position.
* Process new appended content incrementally.
*
* The splitter operates on the newly appended range and relies on explicit
* block-closure index detection (component/code blocks) to preserve
* chunk-size-independent boundaries without per-character full rescans.
*/
export function processNewContent(
registry: BlockRegistry,
Expand All @@ -30,52 +30,25 @@ export function processNewContent(
return registry;
}

// Process each new character individually to ensure consistent
// block boundary detection regardless of chunk size
let currentRegistry = registry;

for (let i = registry.cursor; i < fullText.length; i++) {
// Process content up to position i+1 (one character at a time)
currentRegistry = processSingleCharacter(currentRegistry, fullText, i + 1);
}

logStateSnapshot('state.after', currentRegistry);
return currentRegistry;
}

/**
* Process content up to a specific position (single character increment).
* This is the core of character-level processing.
*/
function processSingleCharacter(
registry: BlockRegistry,
fullText: string,
endPos: number
): BlockRegistry {
// Only process if we have new content
if (endPos <= registry.cursor) {
return registry;
}

const newContent = fullText.slice(registry.cursor, endPos);
const newContent = fullText.slice(registry.cursor);
const activeContent = registry.activeBlock
? registry.activeBlock.content + newContent
: newContent;
const activeStartPos = registry.activeBlock?.startPos ?? registry.cursor;
const newTagState = updateTagState(registry.activeTagState, activeContent);
const lines = activeContent.split('\n');

// Create a virtual "fullText" that only goes up to endPos
// This ensures cursor is set correctly for this character
const virtualFullText = fullText.slice(0, endPos);

return processLines({
const currentRegistry = processLines({
registry,
fullText: virtualFullText,
fullText,
lines,
activeContent,
tagState: newTagState,
activeStartPos: registry.activeBlock?.startPos ?? registry.cursor,
activeStartPos,
});

logStateSnapshot('state.after', currentRegistry);
return currentRegistry;
}

export function resetRegistry(): BlockRegistry {
Expand Down
Loading