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, '

$1

'); - html = html.replace(/^### (.+)$/gm, '

$1

'); - html = html.replace(/^## (.+)$/gm, '

$1

'); - html = html.replace(/^# (.+)$/gm, '

$1

'); - - // Horizontal rules - html = html.replace(/^(---|\*\*\*|___)$/gm, '
'); - - // Code blocks (```lang ... ```) — need to handle BEFORE inline code - html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => { - const langLabel = lang || 'code'; - return '
' + - '
' + - '' + escapeHtml(langLabel) + '' + - '📋 copy' + - '
' + - '
' + escapeHtml(code) + '
' + - '
'; - }); - - // Inline code - html = html.replace(/`([^`]+)`/g, '$1'); - - // Bold - html = html.replace(/\*\*(.+?)\*\*/g, '$1'); - - // Italic - html = html.replace(/\*(.+?)\*/g, '$1'); - - // Strikethrough - html = html.replace(/~~(.+?)~~/g, '$1'); - - // Links — allowlist safe URL schemes to prevent javascript:/data: XSS. - html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => { - const trimmed = url.trim(); - const safe = /^(https?:|mailto:|\/|#|\.\/|\.\.\/)/i.test(trimmed); - if (!safe) return label; - return '' + label + ''; - }); - - // Unordered lists (simple: lines starting with - or *) - html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '
  • $1
  • '); - html = html.replace(/(
  • .*<\/li>\n?)+/g, ''); - - // Paragraphs — wrap remaining non-tag text in

    - // 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, '
    ') + '

    '; - }).join('\n'); - - return html; + const lines = text.split('\n'); + const out = []; + let i = 0; + + const fenceOpen = (l) => /^```(\w*)\s*$/.exec(l); + const isFenceClose = (l) => /^```\s*$/.test(l); + const headerMatch = (l) => /^(#{1,4})\s+(.+)$/.exec(l); + const isHr = (l) => /^(---|\*\*\*|___)$/.test(l.trim()); + const ulItem = (l) => /^\s*[-*]\s+(.+)$/.exec(l); + const olItem = (l) => /^\s*\d+\.\s+(.+)$/.exec(l); + const isBlockStart = (l) => + !!fenceOpen(l) || !!headerMatch(l) || isHr(l) || !!ulItem(l) || !!olItem(l); + + while (i < lines.length) { + const line = lines[i]; + + if (line.trim() === '') { i++; continue; } + + // Fenced code block. An unterminated fence is closed by EOF so partial + // code still renders as a block while streaming. + const fence = fenceOpen(line); + if (fence) { + const lang = fence[1] || 'code'; + const buf = []; + i++; + while (i < lines.length && !isFenceClose(lines[i])) { buf.push(lines[i]); i++; } + if (i < lines.length) i++; // consume the closing fence + out.push(codeBlockHtml(lang, buf.length ? buf.join('\n') + '\n' : '')); + continue; + } + + const header = headerMatch(line); + if (header) { + const level = header[1].length; + out.push('' + inlineHtml(header[2]) + ''); + i++; + continue; + } + + if (isHr(line)) { out.push('
    '); i++; continue; } + + if (ulItem(line)) { + const items = []; + while (i < lines.length) { + const m = ulItem(lines[i]); + if (!m) break; + items.push('
  • ' + inlineHtml(m[1]) + '
  • '); + i++; + } + out.push(''); + continue; + } + + if (olItem(line)) { + const items = []; + while (i < lines.length) { + const m = olItem(lines[i]); + if (!m) break; + items.push('
  • ' + inlineHtml(m[1]) + '
  • '); + i++; + } + out.push('
      ' + items.join('') + '
    '); + continue; + } + + // Paragraph — consecutive lines up to a blank line or the next block. + const para = []; + while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) { + para.push(lines[i]); + i++; + } + out.push('

    ' + para.map(inlineHtml).join('
    ') + '

    '); + } + + return out.join('\n'); +} + +function codeBlockHtml(lang, code) { + return '
    ' + + '
    ' + + '' + escapeHtml(lang) + '' + + '' + + '
    ' + + '
    ' + escapeHtml(code) + '
    ' + + '
    '; +} + +// Inline tokenizer: scans left to right, emitting literal runs through +// escapeHtml. Every construct requires a closing delimiter; otherwise the +// opener is emitted literally and scanning continues after it. +function inlineHtml(text) { + let out = ''; + let lit = ''; + const flush = () => { if (lit) { out += escapeHtml(lit); lit = ''; } }; + let i = 0; + + while (i < text.length) { + const ch = text[i]; + + // Inline code — content is escaped, never re-processed. + if (ch === '`') { + const end = text.indexOf('`', i + 1); + if (end > i + 1) { + flush(); + out += '' + 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 += '' + inlineHtml(text.slice(i + 2, end)) + ''; + i = end + 2; + continue; + } + lit += '~~'; i += 2; continue; + } + + // Italic — not inside words (a*b*c stays literal): the opener must not + // follow a word char, the closer must not precede one. + if (ch === '*') { + const prev = i > 0 ? text[i - 1] : ''; + if (!WORD_CHAR.test(prev)) { + let end = -1; + for (let j = i + 1; j < text.length; j++) { + if (text[j] !== '*' || text[j + 1] === '*') continue; + if (j === i + 1) continue; // empty span + const after = j + 1 < text.length ? text[j + 1] : ''; + if (WORD_CHAR.test(after)) continue; + end = j; + break; + } + if (end > 0) { + flush(); + out += '' + inlineHtml(text.slice(i + 1, end)) + ''; + i = end + 1; + continue; + } + } + lit += ch; i++; continue; + } + + // Links — [text](url) with a scheme allowlist. + if (ch === '[') { + const close = text.indexOf('](', i + 1); + if (close > i + 1) { + const end = text.indexOf(')', close + 2); + if (end > close + 2) { + const label = text.slice(i + 1, close); + const url = text.slice(close + 2, end).trim(); + flush(); + if (SAFE_URL.test(url)) { + out += '' + inlineHtml(label) + ''; + } else { + out += escapeHtml(label); + } + i = end + 1; + continue; + } + } + lit += ch; i++; continue; + } + + lit += ch; i++; + } + + flush(); + return out; } diff --git a/cmd/odek/ui/js/markdown.test.js b/cmd/odek/ui/js/markdown.test.js new file mode 100644 index 0000000..2e78c8f --- /dev/null +++ b/cmd/odek/ui/js/markdown.test.js @@ -0,0 +1,200 @@ +// Golden tests for the streaming-safe markdown tokenizer. +// Run: node --test cmd/odek/ui/js/ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { markdownToHtml } from './markdown.js'; + +// ── Blocks ── + +test('headers h1–h4', () => { + assert.equal(markdownToHtml('# T'), '

    T

    '); + assert.equal(markdownToHtml('## T'), '

    T

    '); + assert.equal(markdownToHtml('### T'), '

    T

    '); + assert.equal(markdownToHtml('#### T'), '

    T

    '); + // Five hashes is not a header + assert.equal(markdownToHtml('##### T'), '

    ##### T

    '); +}); + +test('horizontal rules', () => { + assert.equal(markdownToHtml('---'), '
    '); + assert.equal(markdownToHtml('***'), '
    '); + assert.equal(markdownToHtml('___'), '
    '); +}); + +test('fenced code block with language', () => { + assert.equal( + markdownToHtml('```go\nfmt.Println("hi")\n```'), + '
    go' + + '
    ' + + '
    fmt.Println("hi")\n
    ' + ); +}); + +test('fenced code block without language defaults to "code"', () => { + const html = markdownToHtml('```\nx = 1\n```'); + assert.ok(html.includes('code')); + assert.ok(html.includes('
    x = 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, + '
    js' + + '
    ' + + '
    console.log(1)\nconsole.log(2)\n
    ' + ); +}); + +test('code fence content is escaped, never re-processed', () => { + const html = markdownToHtml('```\n**not bold** x\n```'); + assert.ok(html.includes('
    **not bold** <b>x</b>\n
    ')); + assert.ok(!html.includes('')); +}); + +test('unordered list', () => { + assert.equal( + markdownToHtml('- one\n- two'), + '' + ); + assert.equal( + markdownToHtml('* one\n* two'), + '' + ); +}); + +test('ordered list', () => { + assert.equal( + markdownToHtml('1. one\n2. two'), + '
    1. one
    2. two
    ' + ); +}); + +test('list items support inline markup', () => { + assert.equal( + markdownToHtml('- a **b** c'), + '' + ); +}); + +// ── Inline spans ── + +test('bold', () => { + assert.equal(markdownToHtml('a **b** c'), '

    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

    '); +}); + +test('inline code', () => { + assert.equal(markdownToHtml('use `npm test` now'), '

    use npm test now

    '); +}); + +test('inline code content is never re-processed', () => { + assert.equal(markdownToHtml('`**x**`'), '

    **x**

    '); +}); + +test('unterminated bold renders literally', () => { + assert.equal(markdownToHtml('**bold'), '

    **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)'), + '

    site

    ' + ); +}); + +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 becomes
    ', () => { + assert.equal( + markdownToHtml('one\ntwo\n\nthree'), + '

    one
    two

    \n

    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 = [ + '

    Title

    ', + '

    Some bold and italic text with code.

    ', + '
    • one
    • two
    ', + '
    js' + + '
    ' + + '
    console.log("hi");\n
    ', + '

    Check 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 (" → &quot;). - 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