fix: Escape markdown preview HTML #53
|
|
@ -159,6 +159,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
|
|
||||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||||
|
|
||||||
|
<script src="/static/markdown.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const qs = (s, el=document) => el.querySelector(s);
|
const qs = (s, el=document) => el.querySelector(s);
|
||||||
|
|
@ -327,14 +328,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#md-input').addEventListener('input', renderMD);
|
qs('#md-input').addEventListener('input', renderMD);
|
||||||
function renderMD() {
|
function renderMD() {
|
||||||
const raw = qs('#md-input').value || '';
|
const raw = qs('#md-input').value || '';
|
||||||
const html = raw.replace(/^#{1,6}\s.*$/gm, (m)=>{ const lvl=m.match(/^(#{1,6})/)[1].length; return '<h'+lvl+'>'+m.replace(/^#{1,6}\s/,'')+'</h'+lvl+'>'; })
|
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
||||||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
|
||||||
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
|
||||||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
|
||||||
.replace(/^\-\s?(.+)$/gm,'<li>$1</li>')
|
|
||||||
.replace(/(<li>.*<\/li>)/s,'<ul>$1</ul>')
|
|
||||||
.replace(/\n/g,'<br>');
|
|
||||||
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + html + '</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Commands */
|
/* Commands */
|
||||||
|
|
|
||||||
29
frontend/markdown.js
Normal file
29
frontend/markdown.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const renderMarkdown = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = renderMarkdown;
|
||||||
|
root.renderMarkdown = renderMarkdown;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '').replace(/[&<>"']/g, (character) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
})[character]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return function renderMarkdown(raw) {
|
||||||
|
return escapeHtml(raw)
|
||||||
|
.replace(/^#{1,6}\s.*$/gm, (line) => {
|
||||||
|
const level = line.match(/^(#{1,6})/)[1].length;
|
||||||
|
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
|
||||||
|
})
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
|
||||||
|
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -13,6 +14,7 @@ from src.views import router as frontend_router
|
||||||
|
|
||||||
app = FastAPI(title="Stackchain Dashboard")
|
app = FastAPI(title="Stackchain Dashboard")
|
||||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||||
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -22,6 +24,7 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||||
app.include_router(frontend_router)
|
app.include_router(frontend_router)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
25
tests/test_markdown_renderer.py
Normal file
25
tests/test_markdown_renderer.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
RENDERER = Path(__file__).parent.parent / "frontend" / "markdown.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_markdown_renderer_escapes_raw_html_before_rendering_heading():
|
||||||
|
payload = "# <img src=x onerror=alert(document.domain)>"
|
||||||
|
script = (
|
||||||
|
f"const render = require({json.dumps(str(RENDERER))});"
|
||||||
|
f"process.stdout.write(render({json.dumps(payload)}));"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.stdout == (
|
||||||
|
"<h1><img src=x onerror=alert(document.domain)></h1>"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user