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,