diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc2acf5..46761e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,8 @@ on: branches: [main] pull_request: branches: [main] +permissions: + contents: read jobs: test: runs-on: ubuntu-latest @@ -11,7 +13,14 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' + - name: Shell sanity + run: | + bash -n open-reader.command + sudo apt-get update -qq + sudo apt-get install -y -qq shellcheck + shellcheck --severity=warning --format=gcc open-reader.command - run: npm install + - run: node test/sanitize.mjs - run: node test/generate-bundle.mjs - run: node test/heartbeat_v16.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e137528..30624c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,9 @@ MD Reader is a single HTML file with zero external dependencies. This is the pro git clone https://github.com/andersyin/md-reader.git cd md-reader npm install # installs playwright-core for testing +node test/sanitize.mjs # no browser node test/generate-bundle.mjs # generate test bundle -node test/heartbeat_v16.mjs # run 34 assertions +node test/heartbeat_v16.mjs # run 37 Playwright assertions ``` ## Guidelines @@ -26,14 +27,17 @@ node test/heartbeat_v16.mjs # run 34 assertions ### HTML/JS changes - All code goes in `md-reader.html` — single file, no build step - Escape all user content (markdown is untrusted input) -- Test XSS resistance with `test/xss-sample.md` +- Reject `javascript:` / `data:` / `vbscript:` even when camouflaged (ZWSP, HTML entities, percent-encoding) +- Test XSS resistance with `test/xss-sample.md` and `node test/sanitize.mjs` - Keep file size under 100KB ### Testing ```bash +node test/sanitize.mjs # regenerates nothing; must stay green node test/generate-bundle.mjs # regenerate bundle after fixture changes -node test/heartbeat_v16.mjs # must pass all 34 assertions +node test/heartbeat_v16.mjs # must pass all 37 assertions ``` +Linux CI runs `bash -n` + ShellCheck on `open-reader.command`, then the Node checks above. It cannot exercise Finder double-click. ### Pull requests - One feature/fix per PR diff --git a/README.md b/README.md index f6d0518..9226b18 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ AI tools and developers produce a lot of markdown — reports, analysis, documen ### Security -- **XSS hardened** — all HTML is escaped; ` - + \`\`\` @@ -226,8 +252,9 @@ Legal link [GitHub](https://github.com) return { scripts: el.querySelectorAll('script').length, dangerLinks: [...el.querySelectorAll('a, img')].filter(n => { - const u = (n.getAttribute('href') || n.getAttribute('src') || '').trim().toLowerCase(); - return u.startsWith('javascript:') || u.startsWith('data:') || u.startsWith('vbscript:'); + const u = (n.getAttribute('href') || n.getAttribute('src') || '').trim().toLowerCase() + .replace(/[\u0000-\u0020\u00a0\u200b-\u200f\ufeff]/g, ''); + return /^(javascript|data|vbscript):/.test(u) || u.includes('javascript:') || u.includes('vbscript:') || u.startsWith('//'); }).length, codeSafe: el.innerText.includes("alert('edit-code-safe')"), legalOk: [...el.querySelectorAll('a')].some(a => a.getAttribute('href') === 'https://github.com'), diff --git a/test/sanitize.mjs b/test/sanitize.mjs new file mode 100644 index 0000000..13df386 --- /dev/null +++ b/test/sanitize.mjs @@ -0,0 +1,110 @@ +// Cheap Node-only checks: sanitizer correctness, zero-CDN, first-run copy. +// No browser. Run: node test/sanitize.mjs +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); +const htmlPath = path.join(root, 'md-reader.html'); +const html = fs.readFileSync(htmlPath, 'utf8'); +const xssSrc = fs.readFileSync(path.join(__dirname, 'xss-sample.md'), 'utf8'); + +let pass = 0, fail = 0; +const ok = (name, cond, extra = '') => { + if (cond) { pass++; console.log(` ✅ ${name}${extra ? ' — ' + extra : ''}`); } + else { fail++; console.log(` ❌ ${name}${extra ? ' — ' + extra : ''}`); } +}; + +const start = html.indexOf('function esc(s){'); +const end = html.indexOf('/* ── 统计与确定性提炼'); +if (start < 0 || end < 0) { + console.error('Could not extract sanitizer from md-reader.html'); + process.exit(1); +} +const { esc, safeUrl, MD, asList } = vm.runInNewContext( + '"use strict";\n' + html.slice(start, end) + '\n({esc, safeUrl, MD, asList});' +); + +console.log('\n[sanitize] URL allowlist'); +const blocked = [ + 'javascript:alert(1)', + 'JAVASCRIPT:alert(1)', + 'vbscript:msgbox', + 'data:text/html,alert(1)', + 'data:image/svg+xml,', + '\tjavascript:alert(1)', + 'java\tscript:alert(1)', + '\u200bjavascript:alert(1)', + 'java\u200bscript:alert(1)', + '\u00a0javascript:alert(1)', + 'javascript:alert(1)', + 'javascript:alert(1)', + 'javascript:alert(1)', + 'javascript%3Aalert(1)', + 'java%09script:alert(1)', + '//evil.example/x', + 'https://x.com" onerror="alert(1)', +]; +for (const u of blocked) { + ok('block ' + JSON.stringify(u), safeUrl(u) === false && safeUrl(esc(u)) === false); +} +const allowed = [ + 'https://github.com', + 'http://example.com/a', + 'mailto:a@b.com', + '#anchor', + './rel.md', + '/abs/path.md', + 'file:///tmp/notes.md', +]; +for (const u of allowed) { + ok('allow ' + JSON.stringify(u), safeUrl(u) === true); +} + +console.log('\n[sanitize] Markdown render'); +const xssHtml = MD.parse(xssSrc).html; +ok('xss-sample: no