diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ade483b..5bc50b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,6 +48,18 @@ jobs: version: v2.12.2 args: --timeout=10m + ui-js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: test + run: node --test "cmd/odek/ui/js/**/*.test.js" + vuln: runs-on: ubuntu-latest steps: diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 8642c5a..41086c2 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -1820,7 +1820,10 @@ func handleStatic(wsToken string) http.HandlerFunc { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") + // Strict CSP: no inline scripts (all handlers are addEventListener / + // delegation), styles only from self + the few style="" attributes in + // index.html. frame-ancestors replaces the old standalone CSP line. + 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'") w.Write(data) } } diff --git a/cmd/odek/ui/js/escape.js b/cmd/odek/ui/js/escape.js new file mode 100644 index 0000000..a2ab97e --- /dev/null +++ b/cmd/odek/ui/js/escape.js @@ -0,0 +1,15 @@ +// HTML escaping helpers. Leaf module with no imports so it can be used from +// markdown.js without pulling in state.js/dom.js (which require a browser). + +export function escapeHtml(s) { + if (!s) return ''; + return s.replace(/&/g,'&').replace(//g,'>'); +} + +export function escapeAttr(s) { + if (!s) return ''; + // & must be replaced first — doing it last double-escapes the entities + // introduced by the quote replacements (" → "). + return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''') + .replace(//g,'>'); +} diff --git a/cmd/odek/ui/js/markdown.js b/cmd/odek/ui/js/markdown.js index 9ce7162..498a033 100644 --- a/cmd/odek/ui/js/markdown.js +++ b/cmd/odek/ui/js/markdown.js @@ -1,70 +1,206 @@ -// Markdown → HTML (safe, no DOMPurify needed since we control input). -// Imports only from utils.js. The copy button on code blocks has no inline -// handler — clicks are delegated on #messages in render.js. -import { escapeHtml } from './utils.js'; +// Markdown → HTML via a small hand-written tokenizer (zero deps). +// +// Streaming-safe: an unterminated fenced code block renders its collected +// lines as a code block anyway (EOF acts as the fence close), while +// unterminated INLINE constructs (`**bold`, `` `code ``, `~~strike~~`) +// render as literal text — never as broken markup. All input is +// HTML-escaped by default; content inside code spans and fences is escaped +// but never re-processed. The copy button carries no inline handler — +// clicks are delegated on #messages in render.js. +import { escapeHtml } from './escape.js'; + +// Link scheme allowlist: http(s), mailto, and relative paths only — +// javascript:/data:/vbscript: render as plain text. +const SAFE_URL = /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i; +const WORD_CHAR = /[A-Za-z0-9]/; export function markdownToHtml(text) { if (!text) return ''; - let html = escapeHtml(text); - - // Headers (must be at start of line) - html = html.replace(/^#### (.+)$/gm, '
' + escapeHtml(code) + '' +
- '$1');
-
- // Bold
- html = html.replace(/\*\*(.+?)\*\*/g, '$1');
-
- // Italic
- html = html.replace(/\*(.+?)\*/g, '$1');
-
- // Strikethrough
- html = html.replace(/~~(.+?)~~/g, '
- // Split by double newlines (paragraph breaks)
- const parts = html.split(/\n\n+/);
- html = parts.map(part => {
- part = part.trim();
- if (!part) return '';
- // Don't wrap if it starts with a block-level tag
- if (/^<(h[1-4]|ul|ol|li|pre|div|hr|table)/.test(part)) return part;
- // Don't wrap single
- if (/^
$/.test(part)) return part;
- return '
' + part.replace(/\n/g, '
') + '
' + para.map(inlineHtml).join('
') + '
' + escapeHtml(code) + '' +
+ '' + escapeHtml(text.slice(i + 1, end)) + '';
+ i = end + 1;
+ continue;
+ }
+ lit += ch; i++; continue;
+ }
+
+ // Bold.
+ if (ch === '*' && text[i + 1] === '*') {
+ const end = text.indexOf('**', i + 2);
+ if (end > i + 2) {
+ flush();
+ out += '' + inlineHtml(text.slice(i + 2, end)) + '';
+ i = end + 2;
+ continue;
+ }
+ lit += '**'; i += 2; continue;
+ }
+
+ // Strikethrough.
+ if (ch === '~' && text[i + 1] === '~') {
+ const end = text.indexOf('~~', i + 2);
+ if (end > i + 2) {
+ flush();
+ out += '##### T
'); +}); + +test('horizontal rules', () => { + assert.equal(markdownToHtml('---'), 'fmt.Println("hi")\nx = 1\n'));
+});
+
+test('unterminated fence renders collected lines as a code block', () => {
+ const html = markdownToHtml('```js\nconsole.log(1)\nconsole.log(2)');
+ assert.equal(
+ html,
+ 'console.log(1)\nconsole.log(2)\n**not bold** <b>x</b>\n'));
+ assert.ok(!html.includes(''));
+});
+
+test('unordered list', () => {
+ assert.equal(
+ markdownToHtml('- one\n- two'),
+ 'a b c
'); +}); + +test('italic', () => { + assert.equal(markdownToHtml('a *b* c'), 'a b c
'); +}); + +test('italic not inside words', () => { + assert.equal(markdownToHtml('a*b*c'), 'a*b*c
'); +}); + +test('strikethrough', () => { + assert.equal(markdownToHtml('a ~~b~~ c'), 'a b c
use npm test now
**x**
**bold
'); +}); + +test('unterminated inline code renders literally', () => { + assert.equal(markdownToHtml('some `code'), 'some `code
'); +}); + +test('unterminated strikethrough renders literally', () => { + assert.equal(markdownToHtml('~~strike'), '~~strike
'); +}); + +// ── Links ── + +test('http link renders anchor with safe attributes', () => { + assert.equal( + markdownToHtml('[site](https://example.com)'), + '' + ); +}); + +test('relative and anchor links are allowed', () => { + for (const url of ['/abs/path', './rel', '../up', '#frag', 'mailto:a@b.c']) { + const html = markdownToHtml('[x](' + url + ')'); + assert.ok(html.includes(' { + const html = markdownToHtml('[click](javascript:alert)'); + assert.ok(!html.includes('click'); + // URL with parens: parsing stops at the first ')' (same as the old + // regex pipeline), leaving the trailing ')' as literal text. + const html2 = markdownToHtml('[click](javascript:alert(1))'); + assert.ok(!html2.includes('click)'); +}); + +test('data: link renders as plain text', () => { + const html = markdownToHtml('[click](data:text/html;base64,xxxx)'); + assert.ok(!html.includes('click'); +}); + +// ── Escaping & paragraphs ── + +test('raw HTML in input is escaped', () => { + assert.equal( + markdownToHtml(''), + '<script>alert(1)</script>
' + ); +}); + +test('paragraphs split on blank lines, single newline becomesone
two
three
' + ); +}); + +test('empty input', () => { + assert.equal(markdownToHtml(''), ''); +}); + +// ── Golden multi-feature document ── + +test('golden document', () => { + const doc = [ + '# Title', + '', + 'Some **bold** and *italic* text with `code`.', + '', + '- one', + '- two', + '', + '```js', + 'console.log("hi");', + '```', + '', + 'Check [link](https://example.com) out.', + ].join('\n'); + + const expected = [ + 'Some bold and italic text with code.
console.log("hi");\nCheck link out.
', + ].join('\n'); + + assert.equal(markdownToHtml(doc), expected); +}); diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js index ac99f2f..b31f5fd 100644 --- a/cmd/odek/ui/js/utils.js +++ b/cmd/odek/ui/js/utils.js @@ -3,19 +3,9 @@ import { S } from './state.js'; import { messagesEl, scrollBottomBtn, cancelBtn } from './dom.js'; -// ── Escape helpers ── -export function escapeHtml(s) { - if (!s) return ''; - return s.replace(/&/g,'&').replace(//g,'>'); -} - -export function escapeAttr(s) { - if (!s) return ''; - // & must be replaced first — doing it last double-escapes the entities - // introduced by the quote replacements (" → "). - return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''') - .replace(//g,'>'); -} +// ── Escape helpers (implemented in escape.js so markdown.js can use them +// without importing the browser-dependent modules) ── +export { escapeHtml, escapeAttr } from './escape.js'; // ── Number formatting ── export function formatNum(n) { diff --git a/cmd/odek/ui/style.css b/cmd/odek/ui/style.css index 8243035..91ab7c9 100644 --- a/cmd/odek/ui/style.css +++ b/cmd/odek/ui/style.css @@ -863,7 +863,7 @@ body.light { text-transform: uppercase; letter-spacing: .1em; } -.cb-copy { cursor: pointer; opacity: .5; transition: opacity .12s; padding: 2px 6px; border-radius: 2px; } +.cb-copy { cursor: pointer; opacity: .5; transition: opacity .12s; padding: 2px 6px; border-radius: 2px; background: none; border: 0; font: inherit; color: inherit; } .cb-copy:hover { opacity: 1; background: rgba(255,255,255,.05); } .cb-copy.copied { opacity: 1; color: var(--green); } .code-block pre { diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d8fad07..ddb424a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -119,8 +119,10 @@ cmd/odek/ ui/ index.html Single-page web UI (vanilla JS + CSS) app.js ES-module entry point (imports ./js/main.js) - js/ Native ES modules (state, dom, utils, markdown, render, - approvals, sessions, input, ws, main, net) — no build step + js/ Native ES modules (state, dom, utils, escape, markdown, + render, approvals, sessions, input, ws, main, net) — no build step. + UI unit tests (node:test golden tests for markdown.js, etc.): + node --test "cmd/odek/ui/js/**/*.test.js" style.css Stylesheet docs/ Documentation CLI.md CLI reference