Skip to content

Commit a712b3d

Browse files
committed
feat(ui): WebUI modernization PR6 — streaming-safe markdown tokenizer, golden tests, strict CSP
- Replace the regex-pipeline markdownToHtml with a hand-written line-based block parser + inline tokenizer. Unterminated fenced code blocks render their collected lines as a code block (EOF = fence close); unterminated inline constructs (**, backtick, ~~) render literally, so partial markdown no longer flashes broken markup while streaming. DOM contract preserved (h1-h4, hr, code-block/cb-header/ cb-lang/cb-copy, ul/ol, p/br); .cb-copy is now a <button> (delegated clicks in render.js) with a UA-style reset in style.css. - Extract escapeHtml/escapeAttr into leaf js/escape.js (re-exported by utils.js) so markdown.js is testable under Node without a DOM. - Golden tests in js/markdown.test.js (node:test, 26 tests) covering every block/span type, streaming edge cases, link scheme allowlist, and HTML escaping; new ui-js CI job. - Strict Content-Security-Policy on static responses (script-src 'self', no inline scripts); frame-ancestors folded into the CSP.
1 parent 2f2968f commit a712b3d

8 files changed

Lines changed: 440 additions & 82 deletions

File tree

.github/workflows/test.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ jobs:
4848
version: v2.12.2
4949
args: --timeout=10m
5050

51+
ui-js:
52+
runs-on: ubuntu-latest
53+
steps:
54+
- uses: actions/checkout@v4
55+
56+
- uses: actions/setup-node@v4
57+
with:
58+
node-version: "22"
59+
60+
- name: test
61+
run: node --test "cmd/odek/ui/js/**/*.test.js"
62+
5163
vuln:
5264
runs-on: ubuntu-latest
5365
steps:

cmd/odek/serve.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1820,7 +1820,10 @@ func handleStatic(wsToken string) http.HandlerFunc {
18201820
w.Header().Set("X-Content-Type-Options", "nosniff")
18211821
w.Header().Set("Referrer-Policy", "no-referrer")
18221822
w.Header().Set("X-Frame-Options", "DENY")
1823-
w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
1823+
// Strict CSP: no inline scripts (all handlers are addEventListener /
1824+
// delegation), styles only from self + the few style="" attributes in
1825+
// index.html. frame-ancestors replaces the old standalone CSP line.
1826+
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
18241827
w.Write(data)
18251828
}
18261829
}

cmd/odek/ui/js/escape.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// HTML escaping helpers. Leaf module with no imports so it can be used from
2+
// markdown.js without pulling in state.js/dom.js (which require a browser).
3+
4+
export function escapeHtml(s) {
5+
if (!s) return '';
6+
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
7+
}
8+
9+
export function escapeAttr(s) {
10+
if (!s) return '';
11+
// & must be replaced first — doing it last double-escapes the entities
12+
// introduced by the quote replacements (&quot; → &amp;quot;).
13+
return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')
14+
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
15+
}

cmd/odek/ui/js/markdown.js

Lines changed: 201 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,206 @@
1-
// Markdown → HTML (safe, no DOMPurify needed since we control input).
2-
// Imports only from utils.js. The copy button on code blocks has no inline
3-
// handler — clicks are delegated on #messages in render.js.
4-
import { escapeHtml } from './utils.js';
1+
// Markdown → HTML via a small hand-written tokenizer (zero deps).
2+
//
3+
// Streaming-safe: an unterminated fenced code block renders its collected
4+
// lines as a code block anyway (EOF acts as the fence close), while
5+
// unterminated INLINE constructs (`**bold`, `` `code ``, `~~strike~~`)
6+
// render as literal text — never as broken markup. All input is
7+
// HTML-escaped by default; content inside code spans and fences is escaped
8+
// but never re-processed. The copy button carries no inline handler —
9+
// clicks are delegated on #messages in render.js.
10+
import { escapeHtml } from './escape.js';
11+
12+
// Link scheme allowlist: http(s), mailto, and relative paths only —
13+
// javascript:/data:/vbscript: render as plain text.
14+
const SAFE_URL = /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i;
15+
const WORD_CHAR = /[A-Za-z0-9]/;
516

617
export function markdownToHtml(text) {
718
if (!text) return '';
819

9-
let html = escapeHtml(text);
10-
11-
// Headers (must be at start of line)
12-
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
13-
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
14-
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
15-
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
16-
17-
// Horizontal rules
18-
html = html.replace(/^(---|\*\*\*|___)$/gm, '<hr>');
19-
20-
// Code blocks (```lang ... ```) — need to handle BEFORE inline code
21-
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
22-
const langLabel = lang || 'code';
23-
return '<div class="code-block">' +
24-
'<div class="cb-header">' +
25-
'<span class="cb-lang">' + escapeHtml(langLabel) + '</span>' +
26-
'<span class="cb-copy">📋 copy</span>' +
27-
'</div>' +
28-
'<pre><code>' + escapeHtml(code) + '</code></pre>' +
29-
'</div>';
30-
});
31-
32-
// Inline code
33-
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
34-
35-
// Bold
36-
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
37-
38-
// Italic
39-
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
40-
41-
// Strikethrough
42-
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>');
43-
44-
// Links — allowlist safe URL schemes to prevent javascript:/data: XSS.
45-
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
46-
const trimmed = url.trim();
47-
const safe = /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(trimmed);
48-
if (!safe) return label;
49-
return '<a href="' + trimmed.replace(/"/g, '&quot;') + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
50-
});
51-
52-
// Unordered lists (simple: lines starting with - or *)
53-
html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li>$1</li>');
54-
html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
55-
56-
// Paragraphs — wrap remaining non-tag text in <p>
57-
// Split by double newlines (paragraph breaks)
58-
const parts = html.split(/\n\n+/);
59-
html = parts.map(part => {
60-
part = part.trim();
61-
if (!part) return '';
62-
// Don't wrap if it starts with a block-level tag
63-
if (/^<(h[1-4]|ul|ol|li|pre|div|hr|table)/.test(part)) return part;
64-
// Don't wrap single <br>
65-
if (/^<br\s*\/?>$/.test(part)) return part;
66-
return '<p>' + part.replace(/\n/g, '<br>') + '</p>';
67-
}).join('\n');
68-
69-
return html;
20+
const lines = text.split('\n');
21+
const out = [];
22+
let i = 0;
23+
24+
const fenceOpen = (l) => /^```(\w*)\s*$/.exec(l);
25+
const isFenceClose = (l) => /^```\s*$/.test(l);
26+
const headerMatch = (l) => /^(#{1,4})\s+(.+)$/.exec(l);
27+
const isHr = (l) => /^(---|\*\*\*|___)$/.test(l.trim());
28+
const ulItem = (l) => /^\s*[-*]\s+(.+)$/.exec(l);
29+
const olItem = (l) => /^\s*\d+\.\s+(.+)$/.exec(l);
30+
const isBlockStart = (l) =>
31+
!!fenceOpen(l) || !!headerMatch(l) || isHr(l) || !!ulItem(l) || !!olItem(l);
32+
33+
while (i < lines.length) {
34+
const line = lines[i];
35+
36+
if (line.trim() === '') { i++; continue; }
37+
38+
// Fenced code block. An unterminated fence is closed by EOF so partial
39+
// code still renders as a block while streaming.
40+
const fence = fenceOpen(line);
41+
if (fence) {
42+
const lang = fence[1] || 'code';
43+
const buf = [];
44+
i++;
45+
while (i < lines.length && !isFenceClose(lines[i])) { buf.push(lines[i]); i++; }
46+
if (i < lines.length) i++; // consume the closing fence
47+
out.push(codeBlockHtml(lang, buf.length ? buf.join('\n') + '\n' : ''));
48+
continue;
49+
}
50+
51+
const header = headerMatch(line);
52+
if (header) {
53+
const level = header[1].length;
54+
out.push('<h' + level + '>' + inlineHtml(header[2]) + '</h' + level + '>');
55+
i++;
56+
continue;
57+
}
58+
59+
if (isHr(line)) { out.push('<hr>'); i++; continue; }
60+
61+
if (ulItem(line)) {
62+
const items = [];
63+
while (i < lines.length) {
64+
const m = ulItem(lines[i]);
65+
if (!m) break;
66+
items.push('<li>' + inlineHtml(m[1]) + '</li>');
67+
i++;
68+
}
69+
out.push('<ul>' + items.join('') + '</ul>');
70+
continue;
71+
}
72+
73+
if (olItem(line)) {
74+
const items = [];
75+
while (i < lines.length) {
76+
const m = olItem(lines[i]);
77+
if (!m) break;
78+
items.push('<li>' + inlineHtml(m[1]) + '</li>');
79+
i++;
80+
}
81+
out.push('<ol>' + items.join('') + '</ol>');
82+
continue;
83+
}
84+
85+
// Paragraph — consecutive lines up to a blank line or the next block.
86+
const para = [];
87+
while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) {
88+
para.push(lines[i]);
89+
i++;
90+
}
91+
out.push('<p>' + para.map(inlineHtml).join('<br>') + '</p>');
92+
}
93+
94+
return out.join('\n');
95+
}
96+
97+
function codeBlockHtml(lang, code) {
98+
return '<div class="code-block">' +
99+
'<div class="cb-header">' +
100+
'<span class="cb-lang">' + escapeHtml(lang) + '</span>' +
101+
'<button class="cb-copy">📋 copy</button>' +
102+
'</div>' +
103+
'<pre><code>' + escapeHtml(code) + '</code></pre>' +
104+
'</div>';
105+
}
106+
107+
// Inline tokenizer: scans left to right, emitting literal runs through
108+
// escapeHtml. Every construct requires a closing delimiter; otherwise the
109+
// opener is emitted literally and scanning continues after it.
110+
function inlineHtml(text) {
111+
let out = '';
112+
let lit = '';
113+
const flush = () => { if (lit) { out += escapeHtml(lit); lit = ''; } };
114+
let i = 0;
115+
116+
while (i < text.length) {
117+
const ch = text[i];
118+
119+
// Inline code — content is escaped, never re-processed.
120+
if (ch === '`') {
121+
const end = text.indexOf('`', i + 1);
122+
if (end > i + 1) {
123+
flush();
124+
out += '<code>' + escapeHtml(text.slice(i + 1, end)) + '</code>';
125+
i = end + 1;
126+
continue;
127+
}
128+
lit += ch; i++; continue;
129+
}
130+
131+
// Bold.
132+
if (ch === '*' && text[i + 1] === '*') {
133+
const end = text.indexOf('**', i + 2);
134+
if (end > i + 2) {
135+
flush();
136+
out += '<strong>' + inlineHtml(text.slice(i + 2, end)) + '</strong>';
137+
i = end + 2;
138+
continue;
139+
}
140+
lit += '**'; i += 2; continue;
141+
}
142+
143+
// Strikethrough.
144+
if (ch === '~' && text[i + 1] === '~') {
145+
const end = text.indexOf('~~', i + 2);
146+
if (end > i + 2) {
147+
flush();
148+
out += '<s>' + inlineHtml(text.slice(i + 2, end)) + '</s>';
149+
i = end + 2;
150+
continue;
151+
}
152+
lit += '~~'; i += 2; continue;
153+
}
154+
155+
// Italic — not inside words (a*b*c stays literal): the opener must not
156+
// follow a word char, the closer must not precede one.
157+
if (ch === '*') {
158+
const prev = i > 0 ? text[i - 1] : '';
159+
if (!WORD_CHAR.test(prev)) {
160+
let end = -1;
161+
for (let j = i + 1; j < text.length; j++) {
162+
if (text[j] !== '*' || text[j + 1] === '*') continue;
163+
if (j === i + 1) continue; // empty span
164+
const after = j + 1 < text.length ? text[j + 1] : '';
165+
if (WORD_CHAR.test(after)) continue;
166+
end = j;
167+
break;
168+
}
169+
if (end > 0) {
170+
flush();
171+
out += '<em>' + inlineHtml(text.slice(i + 1, end)) + '</em>';
172+
i = end + 1;
173+
continue;
174+
}
175+
}
176+
lit += ch; i++; continue;
177+
}
178+
179+
// Links — [text](url) with a scheme allowlist.
180+
if (ch === '[') {
181+
const close = text.indexOf('](', i + 1);
182+
if (close > i + 1) {
183+
const end = text.indexOf(')', close + 2);
184+
if (end > close + 2) {
185+
const label = text.slice(i + 1, close);
186+
const url = text.slice(close + 2, end).trim();
187+
flush();
188+
if (SAFE_URL.test(url)) {
189+
out += '<a href="' + url.replace(/"/g, '&quot;') +
190+
'" target="_blank" rel="noopener noreferrer">' + inlineHtml(label) + '</a>';
191+
} else {
192+
out += escapeHtml(label);
193+
}
194+
i = end + 1;
195+
continue;
196+
}
197+
}
198+
lit += ch; i++; continue;
199+
}
200+
201+
lit += ch; i++;
202+
}
203+
204+
flush();
205+
return out;
70206
}

0 commit comments

Comments
 (0)