feat(notes): trash + restore + DOMPurify sanitizer + 9 contract tests

Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.

Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).

Server (src/routes/notes.js):
  GET    /api/notes              now filters deleted_at IS NULL
  GET    /api/notes/trash        new — list trashed items, newest-
                                  deleted first
  DELETE /api/notes/:id          now soft-deletes (sets deleted_at)
  DELETE /api/notes/:id?hard=1   hard-delete, only allowed on items
                                  already in trash (UI bug can't
                                  erase an active note)
  POST   /api/notes/:id/restore  pull a note out of trash
  POST   /api/notes/trash/empty  hard-delete every trashed note for
                                  the user

Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
  • Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
  • Trash tab shows deleted-at timestamps, Restore + delete-forever
    per row, Empty-trash button at the bottom
  • Active list and trash count refresh in parallel after every
    save / delete / restore
  • Delete button in the editor now says "Move to trash" and uses
    the showConfirm helper (no native dialogs)

Sanitizer swap (public/js/notes.js):
  Replaced the homegrown allowlist walker with DOMPurify (already
  loaded from cdnjs in index.html, used by learningHub.js too).
  Custom HTML sanitizers historically have bypasses; DOMPurify is
  the right primitive.

Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
       as new dev deps):
  9 contract tests covering script-tag stripping, inline event
  handlers, img onerror, iframe/object, style attributes, every
  preserved tag in the allowlist, javascript: URI rejection,
  null/undefined input, and nested-script-inside-paragraph. Total
  test count: 37 → 46 passing.

SW cache bumped to pedscribe-v12-notes5.
This commit is contained in:
Daniel 2026-04-25 01:02:48 +02:00
parent ed516adb7a
commit ab3ce11539
9 changed files with 1474 additions and 71 deletions

View file

@ -0,0 +1,21 @@
/**
* Soft-delete for personal_notes Daniel asked for "deleted notes go to
* trash" so a slip of the finger doesn't lose work. Adds a deleted_at
* timestamp; NULL means active. Trash listing filters by NOT NULL,
* regular listing filters by NULL.
*
* Restore = clear deleted_at. Empty Trash = real DELETE. No retention
* policy yet items stay in trash until the user empties it.
*/
exports.up = (pgm) => {
pgm.addColumn('personal_notes', {
deleted_at: { type: 'timestamptz', notNull: false, default: null },
});
pgm.createIndex('personal_notes', ['user_id', 'deleted_at']);
};
exports.down = (pgm) => {
pgm.dropIndex('personal_notes', ['user_id', 'deleted_at']);
pgm.dropColumn('personal_notes', 'deleted_at');
};

1067
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,8 +25,8 @@
"@tiptap/extension-text-style": "^3.20.4",
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"axios": "^1.7.7",
"argon2": "^0.41.1",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@ -54,5 +54,9 @@
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@google-cloud/vertexai": "^1.9.0"
},
"devDependencies": {
"dompurify": "^3.4.1",
"jsdom": "^29.0.2"
}
}

View file

@ -8,7 +8,7 @@
visible and the right pane flips between reader/editor. -->
<div class="notes-layout" id="notes-layout" data-view="list">
<!-- Left pane: list + new-note button + search -->
<!-- Left pane: list + new-note button + search + trash toggle -->
<aside class="notes-sidebar">
<div class="notes-sidebar-head">
<button id="btn-notes-new" class="btn-primary notes-new-btn" type="button">
@ -18,10 +18,24 @@
<i class="fas fa-search"></i>
<input type="text" id="notes-search" placeholder="Search" autocomplete="off">
</div>
<div class="notes-tabs">
<button id="btn-notes-tab-active" class="notes-tab-btn active" type="button" data-view="active">
Notes
</button>
<button id="btn-notes-tab-trash" class="notes-tab-btn" type="button" data-view="trash">
<i class="fas fa-trash"></i> Trash <span class="notes-trash-count" id="notes-trash-count"></span>
</button>
</div>
</div>
<div id="notes-list" class="notes-list">
<div class="notes-empty">Loading…</div>
</div>
<div id="notes-trash-foot" class="notes-trash-foot hidden">
<button id="btn-notes-trash-empty" class="btn-sm" type="button"
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
<i class="fas fa-fire"></i> Empty trash
</button>
</div>
</aside>
<!-- Reader pane — shown by default after opening any saved note -->

View file

@ -1022,3 +1022,26 @@ textarea.full-input{resize:vertical;}
@media (min-width:901px){
.notes-layout[data-view] .notes-sidebar{display:flex !important;}
}
/* ── Notes: trash + tabs ──────────────────────────────────────── */
.notes-tabs{display:flex;gap:4px;margin-top:6px;}
.notes-tab-btn{flex:1;padding:6px 10px;background:transparent;border:1px solid var(--g300);border-radius:6px;font-size:12px;font-weight:500;color:var(--g600);cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;justify-content:center;gap:4px;transition:background 0.1s;}
.notes-tab-btn:hover{background:var(--g100);}
.notes-tab-btn.active{background:var(--blue-light);color:var(--blue);border-color:var(--blue);}
.notes-trash-count{font-size:11px;color:var(--g500);margin-left:2px;}
.notes-tab-btn.active .notes-trash-count{color:var(--blue);}
.notes-trash-foot{padding:8px 10px;border-top:1px solid var(--g200);background:var(--g50);}
.notes-trash-foot.hidden{display:none;}
.notes-trash-foot .btn-sm{width:100%;justify-content:center;}
.notes-list-row{position:relative;display:flex;align-items:stretch;gap:0;}
.notes-list-row .notes-list-item{flex:1;min-width:0;}
.notes-trash-actions{display:flex;flex-direction:column;gap:4px;padding:6px 6px 6px 0;align-items:stretch;}
.notes-restore-btn,.notes-hard-delete-btn{font-size:11px;font-weight:500;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;background:white;color:var(--g700);cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:4px;}
.notes-restore-btn:hover{background:var(--blue-light);color:var(--blue);border-color:var(--blue);}
.notes-hard-delete-btn{padding:4px 6px;color:var(--red);border-color:var(--red-light);}
.notes-hard-delete-btn:hover{background:var(--red);color:white;border-color:var(--red);}
/* In trash view, list items aren't clickable as drafts — make that visible */
.notes-list[data-pane="trash"] .notes-list-item{cursor:default;opacity:0.85;}

View file

@ -16,7 +16,9 @@
(function() {
var _inited = false;
var _notes = [];
var _notes = []; // active notes (deleted_at IS NULL)
var _trash = []; // trashed notes (deleted_at IS NOT NULL)
var _pane = 'active'; // 'active' | 'trash' — drives the list view
var _activeId = null; // currently open note id (reader OR editor)
var _editor = null; // Tiptap instance
var _search = '';
@ -67,12 +69,39 @@
renderList();
});
listEl.addEventListener('click', function(e) {
// Trash-view actions get handled by their own classes; route them
// first so a click on Restore or × doesn't also open the note.
var restoreBtn = e.target.closest('.notes-restore-btn');
if (restoreBtn) {
e.stopPropagation();
restoreNote(parseInt(restoreBtn.dataset.id));
return;
}
var hardBtn = e.target.closest('.notes-hard-delete-btn');
if (hardBtn) {
e.stopPropagation();
hardDeleteNote(parseInt(hardBtn.dataset.id));
return;
}
var item = e.target.closest('.notes-list-item');
if (!item) return;
var id = parseInt(item.dataset.id);
if (id) openReader(id);
if (!id) return;
if (_pane === 'active') openReader(id);
// In trash, clicking the row body is a no-op — explicit Restore /
// delete-forever buttons are the only actions, so a stray click
// can't accidentally open a stale draft.
});
// Active <-> Trash tab toggle
var tabActive = $('btn-notes-tab-active');
var tabTrash = $('btn-notes-tab-trash');
if (tabActive) tabActive.addEventListener('click', function() { switchPane('active'); });
if (tabTrash) tabTrash.addEventListener('click', function() { switchPane('trash'); });
var emptyTrashBtn = $('btn-notes-trash-empty');
if (emptyTrashBtn) emptyTrashBtn.addEventListener('click', emptyTrash);
// Empty-state CTA
var newFromEmpty = $('btn-notes-new-empty');
if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
@ -179,50 +208,140 @@
}
// ── Data fetching ─────────────────────────────────────────
// Always pulls both lists in parallel so the trash-count badge stays
// current regardless of which pane the user has open.
function refreshList() {
var listEl = $('notes-list');
if (!listEl) return;
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { listEl.innerHTML = '<div class="notes-empty">' + esc(data.error || 'Failed to load') + '</div>'; return; }
_notes = data.notes || [];
Promise.all([
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
fetch('/api/notes/trash', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
])
.then(function(results) {
var active = results[0], trash = results[1];
if (active && active.success) _notes = active.notes || [];
if (trash && trash.success) _trash = trash.notes || [];
renderList();
updateTrashCount();
})
.catch(function(err) {
listEl.innerHTML = '<div class="notes-empty">' + esc(err.message || 'Load failed') + '</div>';
});
}
function switchPane(pane) {
if (pane === _pane) return;
_pane = pane;
var tabA = $('btn-notes-tab-active');
var tabT = $('btn-notes-tab-trash');
if (tabA) tabA.classList.toggle('active', pane === 'active');
if (tabT) tabT.classList.toggle('active', pane === 'trash');
var foot = $('notes-trash-foot');
if (foot) foot.classList.toggle('hidden', pane !== 'trash');
// Switching to trash clears any open editor — trashed notes are
// read-only previews, opening them is intentionally disabled.
if (pane === 'trash') closeToList();
renderList();
}
function updateTrashCount() {
var el = $('notes-trash-count');
if (!el) return;
el.textContent = _trash.length ? '(' + _trash.length + ')' : '';
}
function renderList() {
var listEl = $('notes-list');
if (!listEl) return;
var filtered = _notes;
var source = _pane === 'trash' ? _trash : _notes;
var filtered = source;
if (_search) {
filtered = _notes.filter(function(n) {
filtered = source.filter(function(n) {
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
return hay.indexOf(_search) !== -1;
});
}
if (filtered.length === 0) {
listEl.innerHTML = '<div class="notes-empty">'
+ (_search ? 'No matches' : '')
+ (_search ? 'No matches' : (_pane === 'trash' ? 'Trash is empty' : ''))
+ '</div>';
return;
}
listEl.innerHTML = filtered.map(function(n) {
var snippet = stripTags(n.body || '').substring(0, 110);
var when = formatWhen(n.updated_at);
var active = (n.id === _activeId) ? ' active' : '';
return '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
+ '<div class="notes-list-when">' + esc(when) + '</div>'
+ '</button>';
var when = formatWhen(_pane === 'trash' ? n.deleted_at : n.updated_at);
var active = (n.id === _activeId && _pane === 'active') ? ' active' : '';
var trashActions = _pane === 'trash'
? '<div class="notes-trash-actions">'
+ '<button type="button" class="notes-restore-btn" data-id="' + n.id + '" title="Restore">'
+ '<i class="fas fa-rotate-left"></i> Restore'
+ '</button>'
+ '<button type="button" class="notes-hard-delete-btn" data-id="' + n.id + '" title="Delete forever">'
+ '<i class="fas fa-times"></i>'
+ '</button>'
+ '</div>'
: '';
return '<div class="notes-list-row">'
+ '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
+ '<div class="notes-list-when">'
+ (_pane === 'trash' ? 'Deleted ' : '') + esc(when)
+ '</div>'
+ '</button>'
+ trashActions
+ '</div>';
}).join('');
}
function restoreNote(id) {
fetch('/api/notes/' + id + '/restore', {
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Restore failed', 'error'); return; }
if (typeof showToast === 'function') showToast('Note restored', 'success');
refreshList();
})
.catch(function(err) {
if (typeof showToast === 'function') showToast(err.message || 'Restore failed', 'error');
});
}
function hardDeleteNote(id) {
if (typeof showConfirm !== 'function') return;
showConfirm('Delete forever? This cannot be undone.', function() {
fetch('/api/notes/' + id + '?hard=1', {
method: 'DELETE', credentials: 'include', headers: getAuthHeaders(),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Delete failed', 'error'); return; }
refreshList();
});
}, { danger: true, confirmText: 'Delete forever' });
}
function emptyTrash() {
if (typeof showConfirm !== 'function') return;
if (_trash.length === 0) return;
showConfirm('Empty the trash? This will permanently delete ' + _trash.length + ' note' + (_trash.length === 1 ? '' : 's') + '.',
function() {
fetch('/api/notes/trash/empty', {
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Empty trash failed', 'error'); return; }
if (typeof showToast === 'function') showToast('Trash emptied', 'success');
refreshList();
});
},
{ danger: true, confirmText: 'Empty trash' });
}
// ── Reader mode (default after opening or saving) ─────────
function openReader(id) {
flushAutosave();
@ -386,29 +505,27 @@
var note = _notes.find(function(n) { return n.id === _activeId; });
var label = (note && note.title) ? note.title : 'this note';
var doDelete = function() {
// Soft-delete: row gets deleted_at timestamp and moves to Trash.
// The user can Restore from there or Empty trash to actually erase.
var doMove = function() {
flushAutosave();
fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { updateStatus(data.error || 'Delete failed', 'err'); return; }
if (!data.success) { updateStatus(data.error || 'Move to trash failed', 'err'); return; }
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
_activeId = null;
_dirty = false;
if (_editor) { _editor.destroy(); _editor = null; }
closeToList();
if (typeof showToast === 'function') showToast('Note deleted', 'success');
if (typeof showToast === 'function') showToast('Moved to trash', 'success');
refreshList();
})
.catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); });
.catch(function(err) { updateStatus(err.message || 'Move to trash failed', 'err'); });
};
// showConfirm is loaded by app.js before any tab activates, so the
// "if missing" fallback used to be a native window.confirm() —
// dropped here because Daniel's rule is no native dialogs anywhere
// in the frontend. If showConfirm is somehow unavailable, the click
// silently no-ops; the user can try again after a reload.
if (typeof showConfirm === 'function') {
showConfirm('Delete "' + label + '"?', doDelete, { danger: true, confirmText: 'Delete' });
showConfirm('Move "' + label + '" to trash?', doMove, { confirmText: 'Move to trash' });
}
}
@ -713,36 +830,26 @@
var d = document.createElement('div'); d.innerHTML = html;
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
}
// Allowlist-based sanitizer for reader view. The body is also
// encrypted in the DB and only rendered back to the same user
// who wrote it, so the threat model here is "clean up your own
// pasted HTML" rather than hostile content — but we still drop
// script/event-handler/iframe/object to be safe.
var ALLOWED_TAGS = /^(p|br|strong|em|b|i|u|s|a|ul|ol|li|blockquote|h2|h3|h4|code|pre|hr)$/i;
// Sanitize via DOMPurify (loaded from cdnjs in index.html, also used by
// learningHub.js). DOMPurify's allowlist + attribute filter is the right
// primitive — homegrown regex/walker sanitizers historically have bypasses.
// If DOMPurify somehow fails to load, refuse to render HTML rather than
// falling back to a hand-rolled walker.
function sanitizeHtml(html) {
var doc = document.createElement('div'); doc.innerHTML = html;
(function walk(node) {
var children = Array.from(node.children);
children.forEach(function(el) {
if (!ALLOWED_TAGS.test(el.tagName)) {
// Replace with its text content
var text = document.createTextNode(el.textContent || '');
el.parentNode.replaceChild(text, el);
return;
}
// Strip every attribute except href on <a>
Array.from(el.attributes || []).forEach(function(attr) {
if (el.tagName.toLowerCase() === 'a' && attr.name === 'href') {
if (!/^(https?:|mailto:|#)/i.test(attr.value)) el.removeAttribute('href');
} else {
el.removeAttribute(attr.name);
}
});
if (el.tagName.toLowerCase() === 'a') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
walk(el);
});
})(doc);
return doc.innerHTML;
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
console.warn('[notes] DOMPurify unavailable — rendering as plain text.');
var d = document.createElement('div');
d.textContent = String(html == null ? '' : html);
return d.innerHTML;
}
return window.DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','s','h2','h3','h4',
'ul','ol','li','a','blockquote','code','pre','hr'],
ALLOWED_ATTR: ['href','target','rel'],
ADD_ATTR: ['target'],
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false
});
}
function formatWhen(iso) {
if (!iso) return '';

View file

@ -4,7 +4,7 @@
// API calls always fresh (critical for medical data accuracy)
// ============================================================
var CACHE_NAME = 'pedscribe-v12-notes4';
var CACHE_NAME = 'pedscribe-v12-notes5';
var SHELL_ASSETS = [
'/',
'/index.html',

View file

@ -27,14 +27,11 @@ function decryptRow(row) {
return row;
}
// ── GET list ────────────────────────────────────────────────
// Returns all notes for the current user, newest-updated first.
// Title + body are decrypted on the way out; caller renders body
// HTML through the existing sanitize-html pipeline on display.
// ── GET list (active only — items in trash are filtered out) ────
router.get('/notes', async function (req, res) {
try {
var rows = await db.all(
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE user_id = $1 ORDER BY updated_at DESC',
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE user_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC',
[req.user.id]
);
rows.forEach(decryptRow);
@ -45,6 +42,21 @@ router.get('/notes', async function (req, res) {
}
});
// ── GET trash list — items the user soft-deleted, newest-deleted first ──
router.get('/notes/trash', async function (req, res) {
try {
var rows = await db.all(
'SELECT id, title, body, created_at, updated_at, deleted_at FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC',
[req.user.id]
);
rows.forEach(decryptRow);
res.json({ success: true, notes: rows });
} catch (e) {
logger.error('GET /notes/trash', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── GET one ─────────────────────────────────────────────────
router.get('/notes/:id', async function (req, res) {
try {
@ -114,18 +126,71 @@ router.put('/notes/:id', async function (req, res) {
}
});
// ── DELETE ──────────────────────────────────────────────────
// ── DELETE = soft-delete (move to trash) ───────────────────────
// Sets deleted_at = NOW(); the row stays in personal_notes but is
// filtered out of the active list. Use POST /notes/:id/restore to
// undo, or DELETE /notes/:id?hard=1 to actually remove.
router.delete('/notes/:id', async function (req, res) {
try {
await db.run('DELETE FROM personal_notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
var hard = req.query.hard === '1' || req.query.hard === 'true';
if (hard) {
// Hard-delete only items already in trash, so a UI bug can't
// accidentally erase an active note.
await db.run(
'DELETE FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
[req.params.id, req.user.id]
);
logger.audit(req.user.id, 'delete_note_hard', 'Permanently deleted personal note', req, { category: 'notes' });
} else {
await db.run(
'UPDATE personal_notes SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL',
[req.params.id, req.user.id]
);
logger.audit(req.user.id, 'delete_note', 'Moved personal note to trash', req, { category: 'notes' });
}
res.json({ success: true });
logger.audit(req.user.id, 'delete_note', 'Deleted personal note', req, { category: 'notes' });
} catch (e) {
logger.error('DELETE /notes/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /notes/:id/restore — pull a note out of trash ─────────
router.post('/notes/:id/restore', async function (req, res) {
try {
var existing = await db.get(
'SELECT id FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
[req.params.id, req.user.id]
);
if (!existing) return res.status(404).json({ error: 'Note not in trash' });
await db.run(
'UPDATE personal_notes SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
res.json({ success: true });
logger.audit(req.user.id, 'restore_note', 'Restored personal note from trash', req, { category: 'notes' });
} catch (e) {
logger.error('POST /notes/:id/restore', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /notes/trash/empty — hard-delete every trashed note for the user ─
router.post('/notes/trash/empty', async function (req, res) {
try {
var result = await db.run(
'DELETE FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL',
[req.user.id]
);
res.json({ success: true, removed: result.changes || 0 });
logger.audit(req.user.id, 'empty_notes_trash', 'Emptied notes trash', req, { category: 'notes' });
} catch (e) {
logger.error('POST /notes/trash/empty', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /api/notes/from-voice ──────────────────────────────
// Takes a raw voice transcript (produced by the shared
// /api/transcribe endpoint — whichever STT provider the admin

110
test/notes-sanitize.test.js Normal file
View file

@ -0,0 +1,110 @@
// ============================================================
// Contract test for the Notes reader sanitizer. Confirms that
// DOMPurify (loaded via jsdom here) strips the payloads we care
// about — script tags, inline event handlers, iframes, object
// tags — while preserving the formatting tags we DO allow.
//
// Why this test exists:
// The original Notes module used a homegrown walker-based
// sanitizer. When the body round-trips through localStorage or
// a compromised row, a bypass in the sanitizer means session
// theft. We swapped to DOMPurify; this test holds the contract
// so a regression is loud.
// ============================================================
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');
// Match the allowlist used in public/js/notes.js `sanitizeHtml`.
const ALLOWED_TAGS = ['p','br','strong','em','b','i','u','s','h2','h3','h4',
'ul','ol','li','a','blockquote','code','pre','hr'];
const ALLOWED_ATTR = ['href','target','rel'];
function makeSanitizer() {
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
return function sanitize(html) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS, ALLOWED_ATTR,
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false,
});
};
}
test('strips <script> tags completely', () => {
const sanitize = makeSanitizer();
const out = sanitize('<p>ok</p><script>alert(1)</script>');
assert.ok(!out.includes('<script'));
assert.ok(!out.includes('alert'));
assert.match(out, /<p>ok<\/p>/);
});
test('strips inline event handlers on otherwise-allowed tags', () => {
const sanitize = makeSanitizer();
const out = sanitize('<a href="https://x" onclick="steal()">link</a>');
assert.ok(!out.includes('onclick'));
assert.ok(!out.includes('steal'));
assert.match(out, /href="https:\/\/x"/);
});
test('drops <img onerror="..."> — img is not in the allowlist', () => {
const sanitize = makeSanitizer();
const out = sanitize('<img src=x onerror="alert(1)">');
assert.ok(!out.includes('<img'));
assert.ok(!out.includes('onerror'));
});
test('drops <iframe> and <object> (framing/plugin injection)', () => {
const sanitize = makeSanitizer();
assert.ok(!sanitize('<iframe src="evil"></iframe>').includes('iframe'));
assert.ok(!sanitize('<object data="evil"></object>').includes('object'));
});
test('drops style attributes — CSS injection / expression() on older browsers', () => {
const sanitize = makeSanitizer();
const out = sanitize('<p style="background:url(evil)">text</p>');
assert.ok(!out.includes('style'));
assert.match(out, /<p>text<\/p>/);
});
test('preserves every tag in the allowlist', () => {
const sanitize = makeSanitizer();
for (const tag of ALLOWED_TAGS) {
const html = `<${tag}>x</${tag}>`;
const out = sanitize(html);
// <br> and <hr> are self-closing; DOMPurify may emit them either way.
if (tag === 'br' || tag === 'hr') {
assert.match(out, new RegExp(`<${tag}`), `expected <${tag}> preserved`);
} else {
assert.match(out, new RegExp(`<${tag}>x</${tag}>`), `expected <${tag}>x</${tag}> preserved`);
}
}
});
test('preserves mailto + https links; drops javascript: URIs', () => {
const sanitize = makeSanitizer();
assert.match(sanitize('<a href="https://ok">x</a>'), /href="https:\/\/ok"/);
assert.match(sanitize('<a href="mailto:a@b.c">x</a>'), /href="mailto:a@b\.c"/);
const jsAttempt = sanitize('<a href="javascript:alert(1)">x</a>');
assert.ok(!jsAttempt.includes('javascript:'), 'javascript: URI should be stripped');
});
test('empty + null + non-string input does not throw', () => {
const sanitize = makeSanitizer();
assert.equal(sanitize(''), '');
assert.equal(sanitize(null), '');
assert.equal(sanitize(undefined), '');
});
test('nested tags: <script> inside <p> still stripped', () => {
const sanitize = makeSanitizer();
const out = sanitize('<p>safe<script>evil()</script>still safe</p>');
assert.ok(!out.includes('<script'));
assert.ok(!out.includes('evil()'));
// Text content before + after the stripped tag should survive
assert.match(out, /safe/);
assert.match(out, /still safe/);
});