Skip to content

Security: dryotta/mdownreview

Security

docs/security.md

Security & Reliability

Canonical for threat-model and safety rules. Cite violations as "violates rule N in docs/security.md". Charter: docs/principles.md.

Principles

  1. Local-only, offline-first trust model. No outbound calls except the signed updater check and user-initiated openUrl links. The IPC surface is local-trust only.
  2. Custom IPC commands replace tauri-plugin-fs scope. File access is intentionally unscoped at the plugin layer and gated by command-level guards (size, binary, canonicalization). Every custom command enforces its own bounds.
  3. Rendered content is structurally sanitized by default. Markdown renders through rehype-raw paired with rehype-sanitize configured by src/components/viewers/markdown/sanitizeSchema.ts — the schema is the canonical XSS boundary (script/iframe/object/embed/form structurally dropped, on* handlers stripped, inline style not allow-listed). Any dangerouslySetInnerHTML outside the markdown pipeline is paired with an explicit sanitizer or produces output from a library whose output is known-safe.
  4. Atomic writes, never partial sidecars. Comment persistence uses temp-write + rename so a crash or watcher race never leaves a half-written .review.yaml. The only acceptable failure is "no write".
  5. Fail closed, log, continue. Command handlers return Result<_, String> and log; React renders behind ErrorBoundary; the Rust panic hook logs before propagating; promise rejections route to the log. A viewer never crashes to a blank window on malformed input.

Rules

File-read bounds

  1. Every Rust command that opens a file enforces a 10 MB hard cap. The cap is a single module-level const MAX_SIZE: usize = 10 * 1024 * 1024; shared by both readers. Both read_text_file_inner and read_binary_file_inner go through a single private helper read_file_capped that pre-checks metadata().len() on the open handle (fstat — TOCTOU-safe vs. path swap) before allocating, then pairs it with a File::take(MAX_SIZE + 1) + post-read length check so special files (/dev/zero, FIFOs) reporting len() == 0 cannot slip through. (commands/fs/read.rs:44-67 defines read_file_capped: :53-55 fstat pre-check, :56-62 bounded Vec::with_capacity + take(MAX_SIZE+1) read, :63-65 post-read length check.)
  2. read_text_file rejects binaries by scanning the first 512 bytes for NUL, and only succeeds on valid UTF-8. (commands/fs/read.rs:91-94 NUL scan; :98-101 UTF-8 decode — both in read_text_file_inner, after read_file_capped has bounded the read.)
  3. Size pre-check uses file.metadata() on the open handle (fstat, not a second path lookup) and is paired with File::take(MAX+1) + post-read length check to defend against special files (/dev/zero, FIFOs, named pipes, network FS) reporting len() == 0 while streaming unbounded bytes. Mirrors core/sidecar.rs::read_capped. The cap covers load_sidecar and patch_comment; sidecar YAML further rejects anchors/aliases before parsing (see rule 8).
  4. read_dir canonicalizes the requested path and rejects any request whose canonical form differs from the canonicalized input. (commands/fs/dir.rs:43-55.)

Sidecar atomicity & integrity

  1. Sidecar writes are temp-file + atomic rename; a failed rename cleans up the temp file. (core/sidecar.rs:91-101.)
  2. Saving an empty comment list deletes the sidecar rather than writing an empty YAML. (core/sidecar.rs:74-79.)
  3. Sidecar loading prefers YAML over JSON and treats a missing file as Ok(None), never an error. (core/sidecar.rs:42-62.)
  4. Malformed YAML/JSON surfaces as a typed error (SidecarError::YamlParse / JsonParse), not a panic. (core/sidecar.rs:45,57.) Sidecar YAML additionally rejects anchors (&name) and aliases (*name) before parsing — defense-in-depth against billion-laughs amplification past the 10 MB byte cap. The writer never emits anchors, so refusal is wholesale. (core/sidecar.rs::reject_yaml_anchors.) Regression tests on this parser boundary follow rule 28 in docs/test-strategy.md.

Launch-args & CLI handling

  1. The get_launch_args handler drains per-window pending args from the WindowRegistry so each window's launch args are consumed exactly once. (registry.rs::drain_args; commands/launch.rs::get_launch_args.)
  2. Single-instance and macOS-open callbacks route args exclusively through route_args_through_registry, which either focuses existing windows or creates new ones with args queued per-window. (lib.rs::route_args_through_registry.)
  3. CLI argument parsing canonicalizes every path via core::paths::canonicalize_no_verbatim (which strips Windows \\?\ verbatim prefixes — see issue #89) and silently drops paths that fail. (commands/launch.rs:68-125; sidecar resolution under core/paths.rs::resolve_sidecar adds stricter folder-root containment.) 11a. Every Rust→TS path crosses the IPC boundary in non-verbatim form. All canonicalization in commands, watcher, scanner, and CLI goes through core::paths::canonicalize_no_verbatim. std::fs::canonicalize and Path::canonicalize method-form are forbidden in code that produces an IPC payload, persists a path, or compares against a frontend-supplied path — they leak the Windows \\?\ verbatim prefix and desynchronise from the bare-form paths the frontend already holds (read_dir output, dialog results, persisted root / tabs / recentItems). Producer-side fix only — renderers must not paper over a regression with defensive strips. Issue #89.

Markdown rendering safety

  1. Markdown rendering uses rehype-raw (to admit inline HTML for GitHub-style <details>/<sub>/<kbd> etc.) ONLY when paired with rehype-sanitize configured by the schema in src/components/viewers/markdown/sanitizeSchema.ts. The schema is the canonical XSS boundary: script/iframe/object/embed/form are absent from tagNames (and so dropped); on* event handler attributes are absent from per-tag and * attribute lists (and so stripped). style is allow-listed on span and math only as a narrow KaTeX exception (KaTeX emits inline style attributes for math layout); a custom rehype-katex-style preprocessor (src/components/viewers/markdown/rehype-katex-style.ts) walks the tree and STRIPS style from any span/math whose className does not start with katex AND whose nearest ancestor is not katex-classed, so raw HTML cannot smuggle styles through the KaTeX hole. Plugin order in MarkdownViewer.tsx MUST be rehypeRawrehypeFootnotePrefixrehypeKatex (lazy) → rehypeKatexStylerehypeSanitize(sanitizeSchema) → downstream plugins so user HTML cannot piggy-back through later transforms. Footnote ids/hrefs are kept in sync via the default clobberPrefix: "user-content-"; rehypeFootnotePrefix strips a redundant prefix that remark-gfm itself sometimes emits, so sanitize re-adds exactly one prefix. 12a. HTML-preview iframe sandbox is hard-locked to allow-same-origin only — never paired with allow-scripts. HtmlPreviewView (src/components/viewers/HtmlPreviewView.tsx) sets sandbox="allow-same-origin" unconditionally: CSS and fonts render, no JavaScript runs, and the host can reach into contentDocument to install a click handler that routes anchors through routeLinkClick (src/lib/url-policy.ts). Combining allow-same-origin with allow-scripts would re-grant the iframe full host-origin access (DOM, storage, IPC) — defeating the sandbox entirely; the toggle does not exist. No script is ever spliced into the iframe's srcDoc and no postMessage bridge runs.
  2. Markdown anchor clicks open only http(s) URLs, blocking file://, javascript:, etc. (MarkdownViewer.tsx:146-148.)
  3. Local image src is piped through convertFileSrc so the WebView loads via asset:, never raw file://. (MarkdownViewer.tsx:302-309.)
  4. Mermaid runs with securityLevel: "strict". (mermaid-singleton.ts:56.)
  5. SourceView's dangerouslySetInnerHTML payload comes only from Shiki output, escapeHtml, or search highlight built from escapeHtml-segmented pieces. (SourceView.tsx:184-190; useSourceHighlighting.ts:8-10.)

Process-level hardening

  1. The CSP disallows inline scripts, object, frame-ancestors, and whitelists asset: (and the Windows http://asset.localhost form — Tauri 2.x switched the WebView2 asset scheme from https:// to http://, see @tauri-apps/api convertFileSrc docstring) for img-src and media-src. media-src is required for inline <audio>/<video> HTML elements that markdown content may embed — the same chokepoint (convertAssetUrl) is used for <img>, <audio>, and <video>. Audio and video files themselves have no dedicated viewers; they route through BinaryPlaceholder. Asset-protocol scope is narrowed at runtime, not declared globally. tauri.conf.json ships a static seed assetProtocol.scope: ["/__mdownreview_seed__/__never__"] — a path-shaped placeholder that matches no real file (Tauri requires at least one entry to enable the protocol; an empty array would disable it). At window registration time crate::window_scope::extend_window_scope calls app.asset_protocol_scope().allow_directory(...) to extend the runtime scope: ScopeGrant::Folder adds the canonical folder recursively, ScopeGrant::FilesParents adds each unique file-parent directory non-recursively (siblings beyond the requested files MUST NOT become silently readable). Asset-scope grants are distinct from watcher-allowlist seeds (issue #359 / AC3): register_window_file (called on every user-initiated tab-open) seeds the watcher allowlist ONLY — opening a file does NOT grant access to embedded relative-path image siblings. extend_window_scope_files (called by the "Allow for this session" banner click and by CLI/single-instance forwarding) grants BOTH asset-scope AND watcher-seed via ScopeGrant::FilesParents. This split implements the banner-opt-in security model: a tab opens without granting access to sibling files until the user explicitly clicks the banner. Chokepoint sites: register_window_file (src-tauri/src/commands/window_register.rs) — watcher-only; extend_window_scope_files (src-tauri/src/commands/window_register.rs) — full grant; route_args_through_registry AddToWindow arm (src-tauri/src/launch_routing.rs) — extends an existing window via ScopeGrant::FilesParents for forwarded files (CLI / single-instance / RunEvent::Opened); route_args_to_window (src-tauri/src/launch_routing.rs, target-aware variant called by commands::drag_drop::handle_dropped_paths) — same grant, applied for the drag-drop flow so dropped files / folders extend the dropped-on window's scope before open-file-tab / args-received is emitted; reset_window_scope_for_test (test-only, #[cfg(debug_assertions)]-gated) — clears tree_watched_dirs[label], watched_paths[label], and extra_watched_dirs[label] for the calling window via crate::window_scope::reset_window_scope; used by e2e/native/fixtures.ts between specs to prevent cross-spec state leak (asset-protocol scope is intentionally NOT cleared — it is additive in Tauri v2 with no public revoke; issue #366). open_file_via_test (test-only, #[cfg(debug_assertions)]-gated) — enqueues a single file as launch args and emits args-received to drive the renderer's useLaunchArgsBootstrapstore.openFileregister_window_file chain end-to-end; does NOT extend asset-protocol scope (banner opt-in via extend_window_scope_files remains the asset-scope chokepoint); used by e2e/native/09-outside-file-open.spec.ts to mirror CLI / OS file-open without spawning a second binary instance (issue #369). Every WindowRegistry::register site routes through this single chokepoint — main-window setup, additional-window setup, single-instance / OS file-open / second-instance forwarding (route_args_through_registry), drag-drop (route_args_to_window via commands::drag_drop), the renderer's register_window_folder IPC, and the test-only set_root_via_test command. allow_directory is additive; failures are LOGGED via tracing (target window-scope) and never propagated, per the Reliable pillar — failure to extend a grant must not abort window registration. Watcher-allowlist three-slot stratification (issue #369). Inside WatcherState the allowlist is split by lifecycle into three peer slots — tree_watched_dirs[label], watched_paths[label], and extra_watched_dirs[label] — each with a single writer and a single mutation rule. tree_watched_dirs carries renderer-projected workspace dirs and uses REPLACE semantics, owned by the update_tree_watched_dirs IPC and seeded with the workspace root by register_window_folder. watched_paths carries per-tab canonical files plus their .review.yaml / .review.json sidecar variants and uses REBUILD semantics, owned by update_watched_files and seeded by a new seed_window_file(label, canonical) writer that register_window_file_inner invokes when a tab opens an outside-workspace file. extra_watched_dirs carries persistent per-window directory grants, uses ADDITIVE semantics, and is owned by seed_window_extra_dirs(label, dirs) — the only caller is the FilesParents arm in crate::window_scope::extend_window_scope (the banner opt-in for inline images outside the workspace). All three slots are unioned on read by is_path_allowed (watched_paths exact match → tree_watched_dirs starts_withextra_watched_dirs starts_with) and by is_path_or_parent_allowed's parent-canonical fallback. remove_window and reset_window_scope clear all three per-window maps. mrsf_targets continues to exact-match against tree_watched_dirs ONLY (so extra_watched_dirs and watched_paths cannot leak .mrsf.yaml events across windows). This split closes the race documented in #369 — before the split, register_window_file_inner and the FilesParents arm both seeded tree_watched_dirs indirectly, which the renderer's periodic update_tree_watched_dirs REPLACE then clobbered; with each slot owned by exactly one writer, the REPLACE is non-interfering. It also closes a Medium-severity sibling-read regression: opening ~/Downloads/note.md no longer allowlists ~/Downloads/secret.txt. (src-tauri/src/window_scope.rs, src-tauri/tauri.conf.json:18.) 17a. No inline <style> elements in shipped HTML. Extends rule 17. index.html and any other HTML asset Tauri serves must NOT contain inline <style> elements. Reason: Tauri's codegen (tauri-utils::html::inject_nonce_token) adds a STYLE_NONCE_TOKEN placeholder to every existing <style> element, which the runtime (tauri::manager::replace_csp_nonce) substitutes with a fresh nonce AND appends to the style-src directive in the response CSP. Per CSP Level 3 algorithm "Does a source list allow all inline behavior for type" (§7 in the current W3C Editor's Draft), the presence of any nonce or hash source in style-src causes user agents to ignore 'unsafe-inline' for BOTH <style> elements AND style= attributes — silently breaking Shiki token highlighting (MarkdownViewer, SourceView), KaTeX math layout, Mermaid SVG diagrams, and any React style={{...}} prop. The authoritative CSP (src-tauri/tauri.conf.json app.security.csp) is style-src 'self' 'unsafe-inline' and remains so; we restore its effective behaviour by not triggering Tauri's nonce-injection path. FOUC mitigation (issue #265) is preserved by the html, body { background-color: var(--color-bg) } rule keyed on [data-theme] in src/styles/app.css — it ships in the entry CSS chunk loaded via the render-blocking <link> Vite injects in <head>; even though Vite places the module script before the <link> in the built dist/index.html, the stylesheet is still render-blocking and module scripts auto-defer until both DOM parse and stylesheet load complete, so first paint always uses the correct theme background. The inline <script> in index.html that synchronously sets [data-theme] from localStorage is allowed: Tauri SHA-256-hashes inline scripts into script-src and the script's own hash satisfies the directive. Regression tests: src/__tests__/index-html-no-inline-style.test.ts parses both the source index.html AND the built dist/index.html (when present) and asserts zero <style> elements in each. NOTE: the OS-painted background_color set on WebviewWindowBuilder is now resolved from persisted OnboardingState.theme (via commands::config::resolve_window_bg) rather than the hardcoded WINDOW_BG constant from PR #265. The capability ACL surface is unchanged — set_theme is a custom #[mdr_command] registered through tauri_specta::collect_commands!, not a Tauri plugin permission, so no entries are added to src-tauri/capabilities/. Production-CSP enforcement is currently NOT regression-tested at the e2e layer — the native test harness loads from devUrl not frontendDist, so Tauri's runtime CSP path is not exercised; tracked as a Gap below. Rejected alternatives: (a) app.security.dangerousDisableAssetCspModification: ["style-src"] — the per-directive form is a Tauri-supported declarative opt-out and is functionally adequate, but it is less discoverable and less self-documenting than removing the trigger in index.html: future maintainers reading tauri.conf.json would not know why styling works without also reading rule 17a. Removing the trigger keeps Tauri's hardening on for any future asset that does contain a legitimate <style>. (b) Hand-authored Shiki palette CSS — duplicate source of truth that drifts on Shiki upgrades and conflicts with the Lean and Never-Increase-Engineering-Debt meta-principles. (c) @shikijs/transformers + Constructable Stylesheets — sound architectural improvement but does not address the underlying nonce-injection trigger and leaves KaTeX / Mermaid still broken; tracked as a follow-up under the Gaps below. 17b. The system-locations DENY list applies to content-initiated loads only, not to user-initiated opens. The classifier in core::security::system_locations returns Tier::System for sensitive prefixes (/etc/, ~/.ssh/, C:\Users\<user>\AppData\…, C:\Windows\, UNC, etc.; the const tables in system_locations.rs are the canonical audit surface). The user-intent override applies UNIFORMLY across every Tier::System sub-location and flavor (POSIX / Windows / UNC) — there is no per-prefix carve-out. A deliberate user gesture (file picker, CLI argv, OS double-click, drag-drop, tree click, banner "Allow once" click) is the highest-confidence signal we have; second-guessing it with sub-rules would be ad-hoc policy and brittle to maintain. Chokepoint asymmetry table:

    Chokepoint Initiator Classifies? Tier::System action
    useLinkRouter (renderer, consuming commands::path_classify) content (markdown anchor click, HTML preview link) yes block + warn
    core::html_assets::resolve_local_assets content (HTML-preview <img src> / <link href> inlining) yes (is_system_blocked) skip asset (preserve original tag)
    Asset-protocol scope (<img> / <iframe> / <audio> / <video> loads at runtime) content (Tauri asset:// loader) no (relies on scope-narrowing) load only if asset_protocol_scope was widened by a user-initiated chokepoint
    commands::window_register::register_window_file user (file picker, CLI argv, OS double-click, tree click, single-instance forward) yes (informational only — surfaces to renderer for read-only badge) accept
    commands::window_register::extend_window_scope_files user (banner "Allow once" click — sole renderer caller) yes (informational) accept
    commands::window_register::register_window_folder user (folder picker) no (containment via watcher allowlist alone) accept
    commands::fs::update_tree_watched_dirs renderer (tree expansion projecting open folders) no accept (containment via watcher allowlist)
    commands::fs::ensure_readable system (post-allowlist read defense-in-depth) no (trusts upstream user-intent gate) accept
    window_scope::extend_window_scope (direct call) user (CLI / single-instance / drag-drop / RunEvent::Opened via launch_routing) no accept (atomic asset-scope + watcher seed)

    Content-initiated chokepoints are where a hallucinating LLM or malicious document could smuggle the user toward credential stores via [bait](../../Users/me/.ssh/id_rsa) — the threat model that motivated issue #338's tier-3 hard block. User-initiated chokepoints carry explicit user intent and override the content-policy DENY list. The integrity guards remain at every chokepoint (..-rejection, verbatim-rejection, relative-rejection via canonicalize_no_verbatim); the asymmetry concerns only the Tier::System policy bit. register_window_file returns the PathClassification to the renderer so a "system-location" / "outside-workspace" badge can surface as informational metadata — the renderer renders it but never converts it into a refused open. Chokepoint-discipline tension with rule 1: rule 1 of docs/architecture.md names a single Rust function per IPC; rule 17b layers a per-surface policy divergence on top of that single chokepoint. Each chokepoint is still owned by one Rust function; only the policy bit differs. Rationale: a user opening a .md file under %LOCALAPPDATA%\<vendor>\artifacts\ (e.g. AI-tool outputs) has expressed unambiguous intent that no content-policy heuristic can second-guess; the muscle-memory-phishing argument that justified tier-3 for content links does not apply to a deliberate file picker / CLI gesture. (src-tauri/src/commands/window_register.rs, src-tauri/src/commands/fs/mod.rs::ensure_readable, src-tauri/src/core/html_assets.rs::resolve_local_assets, src-tauri/src/core/security/system_locations.rs.)

  2. The window requests only the minimal Tauri capability set: log, dialog open, clipboard write-text, opener open-url, updater. (capabilities/default.json:5-16.)

  3. The updater verifies payloads via the configured minisign public key. (tauri.conf.json:55.)

  4. set_root_via_test compiles out of release via #[cfg(debug_assertions)]. (commands/launch.rs:31-33.)

Watcher integrity

  1. The watcher watches parent directories (not individual files) to survive atomic-rename saves, and emits only for paths on the current watch list. (watcher.rs:146-169, 80-102.) Debounce window: rule 4 in docs/performance.md.
  2. Watcher bookkeeping stores both canonical and raw paths so deleted files (which cannot canonicalize) still match. (watcher.rs:295-324.)
  3. Closing a tab evicts that path from lastSaveByPath so stale timestamps cannot suppress a later event. (store/index.ts:156-159.)

Logging & crash capture

  1. Release builds forward only warn/error from WebView console.* to the log. (lib.rs:75-77.)
  2. Log retention is enforced by a pre-init log-rotator Tauri plugin (src-tauri/src/log_rotation.rs) registered before tauri-plugin-log: each app launch archives the previous mdownreview.log to mdownreview.<UTC stamp>.log, then prunes the log directory to at most 10 mdownreview*.log files (active + archives combined, oldest mtime first). The active file is never deleted. Pruning uses fs::remove_file, which removes a symlink as the link itself rather than following it — a malicious symlink dropped into the log dir cannot cause data loss outside the dir. The match pattern accepts both our startup-archive separator (.) and tauri-plugin-log's intra-session size-rotation separator (_), so the plugin's 5 MB intra-session cap (RotationStrategy::KeepAll) does not leak unbounded archives.
  3. Rust panics are logged with location via a panic hook installed in setup. (lib.rs:109-123.)

Remote asset fetching

  1. fetch_remote_asset enforces five bounds before returning bytes to the renderer: (a) URL must parse and use scheme https (http, file, javascript, data rejected); (b) connect + read timeouts are 10 s each via a single shared reqwest::Client; (c) body is streamed and aborted on overflow at an 8 MB cap; (d) Content-Type (sans parameters) must match the image allowlist image/{png,jpeg,gif,webp,svg+xml,avif}; (e) HTTP status must equal 200. Bytes are handed to the frontend and converted to a blob: URL — the CSP img-src/connect-src directives are never widened to permit remote origins. (commands/remote_asset.rs.)
  2. reveal_in_folder only accepts paths that pass the workspace allowlist before any OS handler is spawned: the input is canonicalised and matched against WatcherState.watched_paths (open-tab files) or any tree_watched_dirs ancestor — anything outside (including .. traversals or symlinks pointing out of the workspace) is rejected with SystemError::PathOutsideWorkspace. Per-platform spawns use fixed argv (Windows explorer /select,<path>; macOS open -R <path>; Linux xdg-open <parent>) — no shell is invoked and no user-controlled flag string is concatenated. (commands/system.rs.)

Workspace-write IPC bounds

  1. Workspace-write IPC enforces five bounds before any byte hits disk. The two commands are write_workspace_text (commands/fs_write.rs:197) and write_workspace_binary (commands/fs_write.rs:241); they share a private ensure_writable helper (commands/fs_write.rs:122). Bounds: (a) the user-supplied filename has no : (NTFS Alternate Data Stream defence — runs before any canonicalization since the byte string is what we're guarding); (b) the parent directory canonicalizes inside an active workspace folder (mirrors rule 4 for read paths and uses WatcherState::is_path_or_parent_allowed for parent-relaxed semantics); (c) the destination filename has a lowercased-suffix match in the workspace-write allowlist [".excalidraw", ".excalidrawlib", ".excalidraw.png", ".excalidraw.svg"]; (d) the payload size is ≤ 10 MB (symmetric with rule 1's read cap — write_workspace_text checks UTF-8 byte length, write_workspace_binary rejects pre-decode if the base64 string exceeds the size required to represent 10 MB); (e) the write goes through core::atomic::write_atomic (mirrors rule 5 for sidecars, now extended to user files). A failure at any bound returns a typed WorkspaceWriteError discriminator and writes nothing — reaching the on-disk side requires every bound to pass. Architecture rule 32 in docs/architecture.md names this the second IPC chokepoint of the project (the first being read-side commands/fs.rs). 29a. Read-side NTFS Alternate Data Stream guard symmetric to bound (a) above. commands/launch.rs::path_has_no_ads is invoked by parse_launch_args for every CLI argv, single-instance forwarded path, RunEvent::Opened path on macOS, and drag-drop path before any canonicalize / metadata call. The guard rejects any : in the path outside the Windows drive-letter prefix (so C:\Users\foo is fine, C:\Users\foo.md:hidden is rejected). Two asymmetries with the write-side guard at bound (a) are deliberate: (i) the read-side check operates on the whole canonicalised path string (catching ADS markers in non-leaf components an attacker could embed, e.g. C:\Users\dir:tag\foo.md), whereas the write-side checks only the leaf filename; (ii) on POSIX a colon in a filename is rare but legal, and the read-side rejects it for cross-platform symmetry — better to over-reject one POSIX edge case than miss the Windows variant. Drive-letter false-positive avoidance: the function strips a leading <letter>: prefix only on Windows. Coverage tests at src-tauri/src/commands/launch.rs::tests::path_has_no_ads_* and parse_launch_args_rejects_ntfs_ads_path.

Cross-doc references

Gaps

  • UI-redress via narrow style allow-list (KaTeX). style is permitted on span/math so KaTeX can lay out math, but only when the element (or an ancestor) is katex-classed (rehype-katex-style). The remaining residual risk is a katex-classed wrapper crafted in raw HTML to host an oversized transparent overlay over interactive UI. Defenses in depth: CSP img-src blocks remote fetches and there is no script-src exposure here; the page chrome lives outside the markdown DOM; React's reconciler keeps event handlers bound to our own components, not user HTML.
  • No path-origin restriction on mutation commands. add_comment, edit_comment, delete_comment, update_comment, add_reply, get_file_comments accept any file_path string; a confused renderer call could write <any_path>.review.yaml. Mitigation: allowlist against open tabs/root.
  • check_path_exists and read_binary_file lack the canonicalization guard used by read_dir (commands/fs/mod.rs:56-62 check_path_exists; commands/fs/read.rs:115-122 read_binary_file). A symlink could redirect image loads outside the workspace.
  • Sidecar selected_text and text have no per-field length limit (core/types.rs:17-45); the file-level 10 MB cap (rule 3) bounds total sidecar size, but a single comment can still occupy most of that budget.
  • Full file paths are logged unredacted (across commands/*.rs tracing::error! sites and watcher.rs:158). Shared logs leak workspace structure and usernames.
  • MRSF schema version gate. load_sidecar rejects sidecars with unsupported major versions (> 1) via reject_unsupported_version. Minor versions within major 1 are accepted per spec §5.
  • Mermaid SVG injected via direct innerHTML (MermaidRenderer.tsx:129) relies on upstream securityLevel: "strict" with no defense in depth.
  • Supply-chain rule is not codified. No deny.toml / cargo-deny or npm audit gate in CI.
  • patch_comment in core/sidecar.rs is public and internally reachable. Future wiring without with_sidecar_mut would bypass atomic-save.
  • Launch-args race on macOS "Open With" (lib.rs:258-287): if get_launch_args fires between the is_none check and emit, files can be silently lost.
  • Production-CSP runtime regression test (rule 17a). The native test harness (e2e/native/global-setup.ts:163-183) starts a Vite dev server and points the debug Tauri binary at devUrl, so Tauri's inject_nonce_token + replace_csp_nonce path is never exercised in CI. The unit test at src/__tests__/index-html-no-inline-style.test.ts catches re-introduction of <style> in source AND in dist/index.html (when built); the production-runtime CSP path remains verified manually only. Closing this gap requires a build-backed native harness (release-mode frontendDist load) or a synthetic test that serves the bundled HTML with a Tauri-shaped CSP header — out of scope of the original CSP fix.
  • 'unsafe-inline' retained on style-src (rule 17a follow-up). Shiki emits per-token style="color:#…"; KaTeX MathML emits inline transforms; Mermaid SVGs embed inline styles. Narrowing style-src to 'self' requires a Shiki chokepoint via @shikijs/transformers + Constructable Stylesheets, KaTeX MathML output mode, and a Mermaid post-processor that lifts inline styles into adopted stylesheets. All three are pre-requisites; no single one is sufficient on its own.
  • Shiki call-site duplication (architectural debt; not a security concern but cited here for cross-reference completeness). src/components/viewers/markdown/MarkdownComponentsMap.tsx and src/hooks/useSourceHighlighting.ts each call Shiki's codeToHtml directly with their own language-load retry and theme-mode logic; a single chokepoint in src/lib/shiki.ts would let the 'unsafe-inline' narrowing above land cleanly. See docs/architecture.md Gaps for the canonical entry.

There aren't any published security advisories