Read a Markdown file as the document it is
A .md previewed as its own source, which is reading the punctuation rather than the notes. It now opens rendered, with Rendered | Source in the dialog footer for anyone who wants what the file actually says. Markdown only; a .txt has nothing to toggle between. Rendering is `marked`, sanitised by DOMPurify -- the one the app already carries for mail. Markdown is not a safe subset of anything: raw HTML passes through it by design, so a <script> in a file somebody uploaded or shared into the account is a script tag unless something takes it out. Images become links rather than pictures. An image in a Markdown file is either a relative path, which has no base to resolve against here, or a URL somewhere else, which fetches on open and tells that server the file was read -- the tracking pixel this app blocks in mail. The link keeps the alt text and the address, so nothing vanishes silently. Fixes the PDF preview while here, which never worked: securityHeaders put X-Frame-Options: DENY on every response including the blob route, so the iframe showed Chrome's "refused to connect" where the file should have been -- in Files today and in mail attachments long before that. The middleware now leaves a header the route has set, and a PDF served inline says SAMEORIGIN. Nothing else on the server is framable.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||
|
||||
describe("isMarkdown", () => {
|
||||
it("takes the type when there is one", () => {
|
||||
expect(isMarkdown("text/markdown", "a")).toBe(true);
|
||||
expect(isMarkdown("text/x-markdown; charset=utf-8", "a")).toBe(true);
|
||||
expect(isMarkdown("text/plain", "notes.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the name, which is the usual case for an upload", () => {
|
||||
expect(isMarkdown("application/octet-stream", "README.md")).toBe(true);
|
||||
expect(isMarkdown("application/octet-stream", "NOTES.MARKDOWN")).toBe(true);
|
||||
expect(isMarkdown(null, "changelog.mkd")).toBe(true);
|
||||
expect(isMarkdown(null, "readme.txt")).toBe(false);
|
||||
expect(isMarkdown(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
it("renders the ordinary things", () => {
|
||||
const html = renderMarkdown("# Title\n\nSome **bold** and `code`.\n\n- one\n- two\n");
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain("<strong>bold</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
expect(html).toContain("<li>one</li>");
|
||||
});
|
||||
|
||||
it("renders GitHub tables and fenced code", () => {
|
||||
const html = renderMarkdown("| a | b |\n| - | - |\n| 1 | 2 |\n\n```js\nconst x = 1;\n```\n");
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain("<pre>");
|
||||
});
|
||||
|
||||
/*
|
||||
* Markdown passes raw HTML through by design, and the file came from
|
||||
* somewhere else -- an upload, or a share from another account. Every one of
|
||||
* these renders as a script tag without a sanitiser.
|
||||
*/
|
||||
it("takes out anything that would execute", () => {
|
||||
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
it("does not keep a javascript: link", () => {
|
||||
const html = renderMarkdown("[click](javascript:alert(1))");
|
||||
expect(html).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("shows an image as a link instead of fetching it", () => {
|
||||
// A remote image in a file is a tracking pixel by another name; this app
|
||||
// blocks those in mail and does not undo that here.
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain('class="md-img"');
|
||||
expect(html).toContain("a diagram");
|
||||
expect(html).toContain("https://tracker.example/px.png");
|
||||
});
|
||||
|
||||
it("keeps a relative image visible even though it cannot resolve", () => {
|
||||
const html = renderMarkdown("");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("local");
|
||||
// Nothing to link to, so it is text rather than a dead link.
|
||||
expect(html).not.toContain('href="./diagram.png"');
|
||||
});
|
||||
|
||||
it("sends links out of the app safely", () => {
|
||||
const html = renderMarkdown("[docs](https://docs.ihasmail.org)");
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
expect(html).toContain('target="_blank"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
/**
|
||||
* Markdown, rendered for the file viewer.
|
||||
*
|
||||
* The source is somebody else's file -- uploaded, or shared into the account
|
||||
* by another user -- so it is treated as hostile. Markdown is not a safe
|
||||
* subset of anything: raw HTML passes straight through it by design, so
|
||||
* `<script>` in a .md is a script tag unless something takes it out. That
|
||||
* something is DOMPurify, which the app already carries for mail.
|
||||
*
|
||||
* Rendered inline rather than in a shadow root the way mail bodies are: this
|
||||
* output is ours, sanitised and styled by `.md-body`, where an email arrives
|
||||
* with a design of its own that has to be quarantined from the app's.
|
||||
*/
|
||||
|
||||
marked.use({ gfm: true, breaks: false });
|
||||
|
||||
export function isMarkdown(type: string | null | undefined, name: string | null | undefined): boolean {
|
||||
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (t === "text/markdown" || t === "text/x-markdown") return true;
|
||||
// A .md upload usually arrives as application/octet-stream, so the name is
|
||||
// the only evidence -- the same reason previewKind falls back to it.
|
||||
return /\.(md|markdown|mdown|mkd)$/i.test(name ?? "");
|
||||
}
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
const html = marked.parse(source, { async: false });
|
||||
const clean = DOMPurify.sanitize(html, {
|
||||
WHOLE_DOCUMENT: false,
|
||||
RETURN_DOM: true,
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "noscript", "style"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "style"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
}) as unknown as HTMLElement;
|
||||
|
||||
/*
|
||||
* Pictures become links rather than pictures.
|
||||
*
|
||||
* An image in a Markdown file is either a relative path, which has no base
|
||||
* to resolve against here and would render broken, or a URL somewhere else,
|
||||
* which fetches on open and tells that server the file was read -- the same
|
||||
* tracking pixel this app blocks in mail. Neither is worth rendering. A link
|
||||
* keeps the alt text and the address visible, so nothing vanishes silently
|
||||
* and the reader chooses whether to fetch it.
|
||||
*/
|
||||
for (const img of [...clean.querySelectorAll("img")]) {
|
||||
const href = img.getAttribute("src") ?? "";
|
||||
const label = img.getAttribute("alt") || href || "image";
|
||||
const a = clean.ownerDocument.createElement("a");
|
||||
a.className = "md-img";
|
||||
a.textContent = label;
|
||||
if (/^https?:/i.test(href)) {
|
||||
a.setAttribute("href", href);
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
a.setAttribute("title", href);
|
||||
}
|
||||
img.replaceWith(a);
|
||||
}
|
||||
|
||||
// Links leave the app, so they leave it safely.
|
||||
for (const a of clean.querySelectorAll("a[href]")) {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
|
||||
return clean.innerHTML;
|
||||
}
|
||||
Reference in New Issue
Block a user