Skip to content

Commit e6b1e14

Browse files
committed
gracefully handle adversarial input attacks
1 parent 36d3d15 commit e6b1e14

3 files changed

Lines changed: 73 additions & 28 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'markdown-to-jsx': patch
3+
---
4+
5+
Fix renderer crash on extremely deeply nested markdown content
6+
7+
Previously, rendering markdown with extremely deeply nested content (e.g., thousands of nested bold markers like `****************...text...****************`) would cause a stack overflow crash. The renderer now gracefully handles such edge cases by falling back to plain text rendering instead of crashing.
8+
9+
**Technical details:**
10+
11+
- Added render depth tracking to prevent stack overflow
12+
- Graceful fallback at 2500 levels of nesting (way beyond normal usage)
13+
- Try/catch safety net as additional protection for unexpected errors
14+
- Zero performance impact during normal operation
15+
- Prevents crashes while maintaining O(n) parsing complexity
16+
17+
This fix ensures stability even with adversarial or malformed inputs while having no impact on normal markdown documents.

fuzz.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,17 @@ describe('Fuzzing: Exponential Backtracking Protections', () => {
587587
}
588588
})
589589

590+
it('should have try/catch safety net for unexpected stack overflows', () => {
591+
// Create extremely deeply nested structure that could bypass depth check
592+
const extremeNesting = '*'.repeat(10000) + 'text' + '*'.repeat(10000)
593+
594+
// Should not throw - try/catch should catch and fallback to plain text
595+
expect(() => {
596+
const result = compiler(extremeNesting)
597+
expect(result).toBeDefined()
598+
}).not.toThrow()
599+
})
600+
590601
it('should handle mixed delimiter characters that look similar', () => {
591602
const repetitions = [100, 500]
592603

index.tsx

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,14 +1303,8 @@ function createRenderer(
13031303
const MAX_RENDER_DEPTH = 2500
13041304

13051305
if (currentDepth > MAX_RENDER_DEPTH) {
1306-
if (process.env.NODE_ENV !== 'production') {
1307-
console.error(
1308-
'markdown-to-jsx: Rendering depth exceeded maximum (2500 levels). ' +
1309-
'This usually indicates extremely nested delimiter sequences (e.g., 5,000+ consecutive asterisks). ' +
1310-
'Consider breaking up the nested formatting or reducing delimiter depth.'
1311-
)
1312-
}
13131306
// Return plain text as fallback to prevent stack overflow
1307+
// Note: Don't log error here as console.error can trigger stack overflow
13141308
if (Array.isArray(ast)) {
13151309
return ast.map(node => ('text' in node ? node.text : ''))
13161310
}
@@ -1319,39 +1313,62 @@ function createRenderer(
13191313

13201314
state.renderDepth = currentDepth
13211315

1322-
if (Array.isArray(ast)) {
1323-
const oldKey = state.key
1324-
const result = []
1316+
try {
1317+
if (Array.isArray(ast)) {
1318+
const oldKey = state.key
1319+
const result = []
13251320

1326-
// map nestedOutput over the ast, except group any text
1327-
// nodes together into a single string output.
1328-
let lastWasString = false
1321+
// map nestedOutput over the ast, except group any text
1322+
// nodes together into a single string output.
1323+
let lastWasString = false
13291324

1330-
for (let i = 0; i < ast.length; i++) {
1331-
state.key = i
1325+
for (let i = 0; i < ast.length; i++) {
1326+
state.key = i
13321327

1333-
const nodeOut = patchedRender(ast[i], state)
1334-
const _isString = isString(nodeOut)
1328+
const nodeOut = patchedRender(ast[i], state)
1329+
const _isString = isString(nodeOut)
13351330

1336-
if (_isString && lastWasString) {
1337-
result[result.length - 1] += nodeOut
1338-
} else if (nodeOut !== null) {
1339-
result.push(nodeOut)
1331+
if (_isString && lastWasString) {
1332+
result[result.length - 1] += nodeOut
1333+
} else if (nodeOut !== null) {
1334+
result.push(nodeOut)
1335+
}
1336+
1337+
lastWasString = _isString
13401338
}
13411339

1342-
lastWasString = _isString
1340+
state.key = oldKey
1341+
state.renderDepth = currentDepth - 1
1342+
1343+
return result
13431344
}
13441345

1345-
state.key = oldKey
1346+
const result = renderRule(ast, patchedRender, state)
13461347
state.renderDepth = currentDepth - 1
13471348

13481349
return result
1350+
} catch (error) {
1351+
// Catch stack overflow or other unexpected errors
1352+
if (
1353+
error instanceof RangeError &&
1354+
error.message.includes('Maximum call stack')
1355+
) {
1356+
if (process.env.NODE_ENV !== 'production') {
1357+
console.error(
1358+
'markdown-to-jsx: Stack overflow during rendering. ' +
1359+
'This usually indicates extremely nested content. ' +
1360+
'Consider breaking up the nested structure.'
1361+
)
1362+
}
1363+
// Fallback to plain text rendering
1364+
if (Array.isArray(ast)) {
1365+
return ast.map(node => ('text' in node ? node.text : ''))
1366+
}
1367+
return 'text' in ast ? ast.text : ''
1368+
}
1369+
// Re-throw other errors
1370+
throw error
13491371
}
1350-
1351-
const result = renderRule(ast, patchedRender, state)
1352-
state.renderDepth = currentDepth - 1
1353-
1354-
return result
13551372
}
13561373
}
13571374

0 commit comments

Comments
 (0)