Skip to content

Commit bebbc86

Browse files
committed
security: neutralize markdown images, restrict link schemes + safe storage + clear stats on error
Addresses audit v3 findings: #1 (High) — Markdown images auto-fetched on render, leaking viewer usage to third parties. Custom marked renderer: - images rendered as inert text (🖼️ alt [url]) — no HTTP fetch - links limited to http(s); other schemes (javascript:, data:, mailto:) fall back to plain text - clickable links get rel="noopener noreferrer nofollow" target="_blank" Privacy sections in all 6 READMEs updated to reflect accurate behavior (no automatic network requests). #4 (Low) — localStorage access wrapped with try/catch; survives strict privacy modes / third-party iframe contexts where storage throws. #5 (Low) — stats line cleared at start of loadFile() so a failed read doesn't leave the previous file's stats above the error message. Deferred: lang-change full rerender optimization (audit #2) — needs bigger refactor of render() into discrete updateStats/renderChunks steps.
1 parent df031b2 commit bebbc86

7 files changed

Lines changed: 58 additions & 11 deletions

File tree

README.ar.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393

9494
## الخصوصية
9595

96-
كل شيء يعمل محليًا في متصفحك. لا يُرسل العارض **أي** طلبات شبكية — لا CDN، ولا تحليلات، ولا خطوط بعيدة. تبقى سجلاتك على جهازك.
96+
كل شيء يعمل محليًا في متصفحك. العارض نفسه **لا يُرسل أي طلبات شبكية تلقائية** — لا CDN، ولا تحليلات، ولا خطوط بعيدة. يتم تحييد صور Markdown المضمّنة في السجلات: تُعرض كنص خامل مع ظهور الرابط، لكن لا يتم جلبها. الروابط الخارجية (فقط `http(s)`) تُفتح في علامة تبويب جديدة فقط عند النقر عليها، مع `rel="noopener noreferrer nofollow"`. تبقى سجلاتك على جهازك.
9797

9898
## قيود معروفة
9999

README.es.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Sin CDN, sin red, sin Subresource Integrity. Clona y ejecuta.
9191

9292
## Privacidad
9393

94-
Todo funciona localmente en tu navegador. El visor no hace **ninguna** petición de red — sin CDN, sin analítica, sin fuentes remotas. Tus transcripciones se quedan en tu máquina.
94+
Todo funciona localmente en tu navegador. El visor en sí **no realiza peticiones de red automáticas** — sin CDN, sin analítica, sin fuentes remotas. Las imágenes Markdown incrustadas en las transcripciones se neutralizan: se muestran como texto inerte con la URL visible, pero nunca se descargan. Los enlaces externos (sólo `http(s)`) se abren en una nueva pestaña sólo al hacer clic, con `rel="noopener noreferrer nofollow"`. Tus transcripciones se quedan en tu máquina.
9595

9696
## Limitaciones conocidas
9797

README.fr.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Pas de CDN, pas de réseau, pas de Subresource Integrity. Clonez et lancez.
9191

9292
## Vie privée
9393

94-
Tout fonctionne localement dans votre navigateur. La visionneuse n'effectue **aucune** requête réseau — pas de CDN, pas d'analytics, pas de polices distantes. Vos transcriptions restent sur votre machine.
94+
Tout fonctionne localement dans votre navigateur. La visionneuse elle-même **n'effectue aucune requête réseau automatique** — pas de CDN, pas d'analytics, pas de polices distantes. Les images Markdown incorporées dans les transcriptions sont neutralisées : affichées comme texte inerte avec l'URL visible, mais jamais récupérées. Les liens externes (uniquement `http(s)`) ne s'ouvrent dans un nouvel onglet que lorsque vous cliquez dessus, avec `rel="noopener noreferrer nofollow"`. Vos transcriptions restent sur votre machine.
9595

9696
## Limitations connues
9797

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ No CDN, no network, no Subresource Integrity dance. Clone and run.
9191

9292
## Privacy
9393

94-
Everything runs locally in your browser. The viewer makes **zero** network requests — no CDN, no analytics, no remote fonts. Your transcripts stay on your machine.
94+
Everything runs locally in your browser. The viewer itself makes **no automatic network requests** — no CDN, no analytics, no remote fonts. Markdown images embedded in transcripts are neutralized: shown as inert text with their URL visible, but never fetched. External links (`http(s)` only) open in a new tab only when you click them, with `rel="noopener noreferrer nofollow"`. Your transcripts stay on your machine.
9595

9696
## Known limits
9797

README.ru.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Claude Code пишет каждую сессию в `~/.claude/projects/<project
9191

9292
## Приватность
9393

94-
Всё работает локально в браузере. Вьюер не делает **ни одного** сетевого запроса — ни CDN, ни аналитики, ни удалённых шрифтов. Транскрипты остаются на вашей машине.
94+
Всё работает локально в браузере. Сам вьюер **не делает автоматических сетевых запросов** — ни CDN, ни аналитики, ни удалённых шрифтов. Markdown-картинки из транскриптов нейтрализуются: показываются как инертный текст с видимым URL, но не загружаются. Внешние ссылки (только `http(s)`) открываются в новой вкладке только по клику, с `rel="noopener noreferrer nofollow"`. Транскрипты остаются на вашей машине.
9595

9696
## Известные ограничения
9797

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Claude Code 的记录位于:
9191

9292
## 隐私
9393

94-
一切都在你的浏览器本地运行。查看器****发起任何网络请求 — 没有 CDN,没有分析,没有远程字体。你的记录留在你的机器上。
94+
一切都在你的浏览器本地运行。查看器本身**不发起任何自动网络请求** — 没有 CDN,没有分析,没有远程字体。记录中嵌入的 Markdown 图片会被中和:以惰性文本形式显示(URL 可见但不会被获取)。外部链接(仅限 `http(s)`)只在你点击时才会在新标签页打开,并带有 `rel="noopener noreferrer nofollow"`。你的记录留在你的机器上。
9595

9696
## 已知限制
9797

index.html

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,18 @@
345345
color: var(--accent);
346346
}
347347

348+
/* Neutralized markdown image: no auto-fetch, just an inert label with URL */
349+
.md-image {
350+
display: inline-block;
351+
color: var(--muted);
352+
font-style: italic;
353+
}
354+
.md-image-url {
355+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
356+
font-size: 0.9em;
357+
opacity: 0.8;
358+
}
359+
348360
main.hide-thinking .e.thinking {
349361
display: none;
350362
}
@@ -563,7 +575,23 @@
563575
ar: "ar-EG",
564576
};
565577

566-
const savedLang = localStorage.getItem("lang");
578+
// localStorage can throw in strict privacy modes / third-party iframe contexts.
579+
const storage = {
580+
get(k) {
581+
try {
582+
return localStorage.getItem(k);
583+
} catch {
584+
return null;
585+
}
586+
},
587+
set(k, v) {
588+
try {
589+
localStorage.setItem(k, v);
590+
} catch {}
591+
},
592+
};
593+
594+
const savedLang = storage.get("lang");
567595
const browserLang = (navigator.language || "en").toLowerCase().split("-")[0];
568596
let locale = savedLang || (I18N[browserLang] ? browserLang : "en");
569597
const L = () => I18N[locale];
@@ -592,7 +620,23 @@
592620
const fbox = Object.fromEntries(FILTERS.map((k) => [k, document.getElementById("f-" + k)]));
593621
Object.values(fbox).forEach((el) => el.addEventListener("change", applyFilters));
594622

595-
marked.setOptions({ breaks: true, gfm: true });
623+
// Privacy: neutralize markdown images (no auto-fetch) and restrict link schemes.
624+
// Without this, [text](...) and ![alt](...) in transcripts would fetch remote
625+
// resources on render and leak viewer usage to whoever crafted the transcript.
626+
const mdRenderer = new marked.Renderer();
627+
mdRenderer.image = function (href, title, text) {
628+
const alt = esc(text || "image");
629+
const url = href ? ` <span class="md-image-url">${esc(href)}</span>` : "";
630+
return `<span class="md-image">🖼️ ${alt}${url}</span>`;
631+
};
632+
mdRenderer.link = function (href, title, text) {
633+
// Only http(s) links are clickable; other schemes (javascript:, data:, file:, mailto:)
634+
// are rendered as plain text. `text` is already escaped/rendered by marked.
635+
if (!href || !/^https?:\/\//i.test(href)) return text;
636+
const t = title ? ` title="${esc(title)}"` : "";
637+
return `<a href="${esc(href)}" rel="noopener noreferrer nofollow" target="_blank"${t}>${text}</a>`;
638+
};
639+
marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
596640

597641
// ===== truncation =====
598642
const MAX_PROSE = 20_000;
@@ -620,6 +664,9 @@
620664

621665
// ===== file loading =====
622666
async function loadFile(f) {
667+
// Clear stale stats from a previous file — on error the old stats row
668+
// would otherwise stay above the new error message and mislead the user.
669+
statsEl.textContent = "";
623670
chatEl.innerHTML = `<div class="empty">${L().reading}</div>`;
624671
try {
625672
const rows = await parseFile(f);
@@ -990,7 +1037,7 @@
9901037
langSel.value = locale;
9911038
langSel.addEventListener("change", () => {
9921039
locale = langSel.value;
993-
localStorage.setItem("lang", locale);
1040+
storage.set("lang", locale);
9941041
applyLocale();
9951042
renderFilterLabels();
9961043
if (state.rows.length) render(state.rows, state.fname);
@@ -999,12 +1046,12 @@
9991046

10001047
themeBtn.addEventListener("click", () => {
10011048
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
1002-
localStorage.setItem("theme", next);
1049+
storage.set("theme", next);
10031050
applyTheme(next);
10041051
});
10051052

10061053
applyLocale();
1007-
applyTheme(localStorage.getItem("theme") || "dark");
1054+
applyTheme(storage.get("theme") || "dark");
10081055
renderFilterLabels();
10091056
renderEmptyState();
10101057
</script>

0 commit comments

Comments
 (0)