diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e17d5..7ace36f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,106 @@ same stability guarantee. ## [Unreleased] -## [0.7.4] — 2026-07-03 +## [0.8.0] — 2026-07-13 + +Production security & quality hardening for public release. The HTML and Typst +renderers are now safe to point at untrusted Markdown, and the parser has +resource limits that turn pathological input into clean errors instead of +crashes. + +### Security + +- **XSS — dangerous URL schemes are now filtered by default.** Links, images, + and autolinks whose destination scheme is `javascript:`, `vbscript:`, + `file:`, or a non-image `data:` are emitted with an empty `href`/`src`. + Detection normalises the scheme the way a browser does (leading control/space + stripping, single-byte HTML-entity decoding so `javascript:` is caught, + tab/newline/CR stripping). A denylist — not an allowlist — is used, so + unknown-but-harmless schemes (e.g. the spec's `made-up-scheme://`) and + `data:image/{png,gif,jpeg,webp}` still pass through, and CommonMark/GFM + conformance is unchanged (652/652 + 24/24). +- **XSS — footnote labels are now HTML-escaped.** `footnote_reference` and + `footnote_definition` previously interpolated the raw label into an attribute + and element text, allowing markup injection; both are now escaped. +- **XSS — opt-in `safe` mode escapes raw HTML.** `html.Options{ .safe = true }` + (also `zigmark.renderHtmlWithOptions`, the `zigmark_render_html_safe` C ABI + export, and the CLI `--safe` flag) renders raw/inline HTML as visible text + instead of passing it through. Default output is byte-identical to before, so + spec conformance is preserved; downstream consumers of untrusted input should + enable it. +- **Typst code injection — code spans and code blocks now use `raw()`.** Content + is emitted via `#raw("…")` / `#raw(block: true, …)` with the text in a Typst + string literal, so backticks or fences in the content can no longer break out + into executable Typst markup (Typst runs at compile time and can read local + files). Frontmatter-derived preamble fields are validated before + interpolation: `fontsize` must be a length literal, colours must be hex, and + string fields go through the string-literal escaper — invalid values fall + back to safe defaults. +- **DoS — parser resource limits.** New `Parser.max_nesting_depth` (default + 128) turns deeply nested blockquotes, lists, and inline links/images into + `error.NestingTooDeep` instead of a stack overflow; `Parser.max_input_bytes` + (default 16 MiB, `0` = unlimited) bounds `parseMarkdown`/`parseFromReader` + with `error.InputTooLarge`. Error paths now free the partial document/inline + tree (no leak on a rejected parse). +- **Panic — ATX heading `#` counter no longer overflows.** A line of ≥256 `#` + characters previously overflowed a `u8` in ReleaseSafe; counting now bails at + 7. Out-of-range frontmatter integers are coerced with `std.math.cast` instead + of a panicking `@intCast`. +- **Frontmatter — malformed YAML no longer leaks or writes to stderr (#73).** + A YAML parse failure previously leaked the parser's error bundle, asserted on + parser internals, and printed diagnostics to stderr from library code — all + removed. The CLI's `frontmatter`/`markdown` formats degrade gracefully + (empty object / verbatim passthrough) instead of aborting. Note: the + underlying rejection of a plain scalar containing an inline `" - "` is an + upstream `zig-yaml` limitation and still needs a fork-level fix. + +### Added + +- `html.Options` / `renderToWriterWithOptions` / `renderWithOptions`, + `zigmark.HtmlOptions` / `zigmark.renderHtmlWithOptions`, the + `zigmark_render_html_safe` C ABI export, and the CLI `--safe` flag. +- `Parser.max_nesting_depth` / `Parser.max_input_bytes` (with + `default_max_nesting_depth` / `default_max_input_bytes`). +- `src/markdown/security_test.zig` — a security-regression suite run by + `zig build test`. +- Fuzz smoke check in CI (`zig build fuzz` as a flake check); new fuzz targets + for safe-mode HTML, Typst document rendering, and ZON frontmatter. + +### Fixed + +- **Fuzz harness compiles again.** Every target used the pre-0.16 + `fn (void, []const u8)` signature and never built against Zig 0.16's + `std.testing.fuzz`; targets now take a `*std.testing.Smith`. Fixed the + `fuzz--fuzz` typo in the `fuzz` dev-shell app. + +### Known limitations + +- The inline link/emphasis scanner is O(n²) on pathological unmatched-bracket + input, a CPU-DoS bounded (not eliminated) by `max_input_bytes`. Untrusted- + input deployments should set a conservative `max_input_bytes` and a call-site + timeout; a delimiter-stack rewrite is tracked for a follow-up. + +## [0.7.4] — 2026-07-08 + +### Tests + +- **Pinned the intentional raw-HTML drop in the Typst renderer.** The Typst + renderer omits raw HTML with no Typst equivalent (block HTML is dropped + whole; inline HTML drops the tags but keeps the enclosed text), while the + HTML renderer preserves it. A downstream consumer (sc2in/policypress#117) + depends on this to flag raw HTML in policy bodies, so a test now makes the + contract explicit — if the behaviour ever changes, the failure surfaces + here. Renderer behaviour is unchanged. ## [0.7.3] — 2026-07-03 +### Changed + +- Bumped the `pozeiden` lazy dependency to v0.2.0, bringing thread-safe + rendering (threadlocal theme state, locked grammar init) and its Zig 0.16 + line into the CLI/WASM mermaid path. The dependency remains `lazy`, so + library consumers still don't fetch it. + ## [0.7.2] — 2026-07-03 ### Fixed diff --git a/README.md b/README.md index 8cb5b65..f6aaa0e 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,9 @@ Usage: zigmark [OPTIONS] [FILE] "ai", "terminal", "frontmatter", "markdown", or "normalize". -o, --output Write output to FILE instead of stdout. + --safe HTML only: escape raw/inline HTML to visible text + (for untrusted input). URL-scheme filtering is + always on. -s, --set ... Set a frontmatter field (KEY=VALUE). Repeatable. Applies to: markdown, normalize, frontmatter. -d, --delete ... Delete a frontmatter field (dot-path). Repeatable. @@ -236,6 +239,42 @@ Usage: zigmark [OPTIONS] [FILE] Applies to: normalize. ``` +## Rendering Untrusted Markdown + +zigmark is safe to point at untrusted input, with two things to know: + +- **URL-scheme filtering is always on.** Links and images whose destination + scheme is `javascript:`, `vbscript:`, `file:`, or a non-image `data:` are + rendered with an empty `href`/`src`. Ordinary, relative, fragment, and + `data:image/*` URLs are untouched, so CommonMark/GFM conformance is unchanged. +- **Raw HTML passthrough is opt-out.** By default the HTML renderer passes raw + HTML through (as CommonMark requires). For untrusted input, enable **safe + mode** to escape raw HTML to visible text: + + ```zig + // Library: html.Options.safe, threaded through the convenience helper + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try zigmark.renderHtmlWithOptions(allocator, &aw.writer, doc, .{ .safe = true }); + ``` + + ```bash + # CLI + zigmark --safe untrusted.md + ``` + + The C ABI exposes `zigmark_render_html_safe` for the same behaviour. + +For denial-of-service protection, the parser bounds recursion and input size: + +```zig +var parser = zigmark.Parser.init(); +parser.max_input_bytes = 1 << 20; // 1 MiB cap → error.InputTooLarge (default 16 MiB; 0 = unlimited) +parser.max_nesting_depth = 64; // → error.NestingTooDeep (default 128) +``` + +See [SECURITY.md](SECURITY.md) for the full threat model and known limitations. + ## Zig Library Usage ### Basic Parsing and Rendering diff --git a/SECURITY.md b/SECURITY.md index 929a4ad..d31b010 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,3 +30,28 @@ The following are in scope: Out of scope: issues in transitive build-only dependencies that do not affect the compiled output. + +## Handling untrusted Markdown + +When rendering Markdown from untrusted sources: + +- **HTML output** — dangerous URL schemes (`javascript:`, `vbscript:`, `file:`, + non-image `data:`) in links/images are neutralised by default. Raw HTML is + passed through by default (CommonMark requires this); to neutralise it, render + in **safe mode** — `html.Options{ .safe = true }` / + `zigmark.renderHtmlWithOptions` / `zigmark_render_html_safe` / + the CLI `--safe` flag — which escapes raw HTML to visible text. +- **Resource limits** — set `Parser.max_input_bytes` conservatively for your + workload and rely on `Parser.max_nesting_depth` to bound recursion. Both turn + pathological input into errors rather than crashes. +- **Typst output** — the Typst renderer escapes code content and validates + frontmatter-derived preamble fields, but Typst still executes at compile time. + Only compile renderer output you are willing to run; do not feed untrusted + Typst to `typst compile` without sandboxing. + +### Known limitations + +- The inline scanner has O(n²) worst-case behaviour on pathological + unmatched-bracket input. It is bounded by `max_input_bytes` but not + eliminated; pair a conservative input cap with a call-site timeout for + untrusted input. diff --git a/build.zig.zon b/build.zig.zon index 03972ba..a455287 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,7 +9,7 @@ .name = .zigmark, // This is a [Semantic Version](https://semver.org/). // In a future version of Zig it will be used for package deduplication. - .version = "0.7.4", + .version = "0.8.0", // Together with name, this represents a globally unique package // identifier. This field is generated by the Zig toolchain when the // package is first created, and then *never changes*. This allows diff --git a/flake.nix b/flake.nix index 5c5d956..b99f393 100644 --- a/flake.nix +++ b/flake.nix @@ -94,7 +94,7 @@ } ); - # `nix flake check` / omnix ci — runs `zig build test` + `zig build wasm` + # `nix flake check` / omnix ci — runs `zig build test` + `zig build fuzz` + `zig build wasm` checks = forAllSystems (system: { test = self.packages.${system}.default.overrideAttrs (old: { pname = "zigmark-test"; @@ -102,6 +102,22 @@ installPhase = "touch $out"; meta = (old.meta or {}) // {description = "Run zig build test — unit tests + CommonMark spec + GFM spec";}; }); + # Fuzz smoke: compile every fuzz target and run each once so the harness + # cannot silently rot (coverage-guided runs still use `zig build fuzz --fuzz`). + fuzz = self.packages.${system}.default.overrideAttrs (old: { + pname = "zigmark-fuzz-smoke"; + buildPhase = "zig build fuzz -Dversion=${version}"; + installPhase = "touch $out"; + meta = (old.meta or {}) // {description = "Run zig build fuzz smoke — each fuzz target once";}; + }); + # Formatting gate. Deliberate hand-alignment is preserved with + # `// zig fmt: off` / `// zig fmt: on` guards, so this stays green. + fmt = self.packages.${system}.default.overrideAttrs (old: { + pname = "zigmark-fmt-check"; + buildPhase = "zig fmt --check build.zig src examples"; + installPhase = "touch $out"; + meta = (old.meta or {}) // {description = "Run zig fmt --check";}; + }); wasm = self.packages.${system}.default.overrideAttrs (old: { pname = "zigmark-wasm-check"; buildPhase = "zig build wasm -Dversion=${version}"; @@ -133,7 +149,7 @@ text = '' PORT="''${1:-8080}" echo "▸ Starting fuzzer — web UI at http://127.0.0.1:$PORT" - zig build --listen fuzz--fuzz --webui="127.0.0.1:$PORT" + zig build --listen fuzz --fuzz --webui="127.0.0.1:$PORT" ''; }; in { diff --git a/include/zigmark.h b/include/zigmark.h index a9f2063..29bac59 100644 --- a/include/zigmark.h +++ b/include/zigmark.h @@ -40,6 +40,17 @@ void zigmark_free_document(ZigmarkDocument *doc); */ char *zigmark_render_html(ZigmarkDocument *doc); +/** + * Render the document to HTML in safe mode: raw and inline HTML from the input + * is escaped to visible text. URL-scheme filtering (blocking javascript:, + * vbscript:, file:, and non-image data: destinations) applies to both this and + * zigmark_render_html(). Use this for untrusted input. + * + * @return A NUL-terminated string, or NULL on failure. + * Free with zigmark_free_string(). + */ +char *zigmark_render_html_safe(ZigmarkDocument *doc); + /** * Render the document to a human-readable AST tree diagram. * diff --git a/src/fuzz.zig b/src/fuzz.zig index 2c711d8..0b4732b 100644 --- a/src/fuzz.zig +++ b/src/fuzz.zig @@ -1,11 +1,18 @@ //! Fuzz harness for zigmark's parser, renderers, and frontmatter parser. //! //! Run once (smoke test): zig build fuzz -//! Coverage-guided fuzzing: zig build fuzz -- --fuzz +//! Coverage-guided fuzzing: zig build fuzz --fuzz +//! +//! Targets take a `*std.testing.Smith` (the Zig 0.16 structured-input source) +//! and pull up to `max_input` bytes of fuzzer-chosen data via `smith.slice`. +//! Each target asserts only the absence of crashes/UB/leaks, not correctness. const std = @import("std"); const zigmark = @import("zigmark"); +const Smith = std.testing.Smith; +const max_input = 8192; + // ── Parser ──────────────────────────────────────────────────────────────────── test "fuzz_parse" { @@ -22,10 +29,18 @@ test "fuzz_parse_render_html" { try std.testing.fuzz({}, fuzzParseRenderHtml, .{}); } +test "fuzz_parse_render_html_safe" { + try std.testing.fuzz({}, fuzzParseRenderHtmlSafe, .{}); +} + test "fuzz_parse_render_markdown" { try std.testing.fuzz({}, fuzzParseRenderMarkdown, .{}); } +test "fuzz_parse_render_typst" { + try std.testing.fuzz({}, fuzzParseRenderTypst, .{}); +} + // ── Frontmatter ─────────────────────────────────────────────────────────────── test "fuzz_frontmatter_yaml" { @@ -40,23 +55,33 @@ test "fuzz_frontmatter_json" { try std.testing.fuzz({}, fuzzFrontmatterJson, .{}); } +test "fuzz_frontmatter_zon" { + try std.testing.fuzz({}, fuzzFrontmatterZon, .{}); +} + // ── Implementations ─────────────────────────────────────────────────────────── -fn fuzzParse(_: void, input: []const u8) anyerror!void { +fn fuzzParse(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var doc = zigmark.Parser.init().parseMarkdown(arena.allocator(), input) catch return; doc.deinit(arena.allocator()); } -fn fuzzParseCommonMark(_: void, input: []const u8) anyerror!void { +fn fuzzParseCommonMark(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var doc = (zigmark.Parser{ .gfm = false }).parseMarkdown(arena.allocator(), input) catch return; doc.deinit(arena.allocator()); } -fn fuzzParseRenderHtml(_: void, input: []const u8) anyerror!void { +fn fuzzParseRenderHtml(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const alloc = arena.allocator(); @@ -66,7 +91,22 @@ fn fuzzParseRenderHtml(_: void, input: []const u8) anyerror!void { _ = out; } -fn fuzzParseRenderMarkdown(_: void, input: []const u8) anyerror!void { +fn fuzzParseRenderHtmlSafe(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var doc = zigmark.Parser.init().parseMarkdown(alloc, input) catch return; + defer doc.deinit(alloc); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + zigmark.renderHtmlWithOptions(alloc, &aw.writer, doc, .{ .safe = true }) catch return; +} + +fn fuzzParseRenderMarkdown(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const alloc = arena.allocator(); @@ -76,23 +116,50 @@ fn fuzzParseRenderMarkdown(_: void, input: []const u8) anyerror!void { _ = out; } -fn fuzzFrontmatterYaml(_: void, input: []const u8) anyerror!void { +fn fuzzParseRenderTypst(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var doc = zigmark.Parser.init().parseMarkdown(alloc, input) catch return; + defer doc.deinit(alloc); + const out = zigmark.typst.renderDocument(alloc, doc, .{}) catch return; + _ = out; +} + +fn fuzzFrontmatterYaml(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var fm = zigmark.Frontmatter.init(arena.allocator(), input, .yaml) catch return; fm.deinit(); } -fn fuzzFrontmatterToml(_: void, input: []const u8) anyerror!void { +fn fuzzFrontmatterToml(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var fm = zigmark.Frontmatter.init(arena.allocator(), input, .toml) catch return; fm.deinit(); } -fn fuzzFrontmatterJson(_: void, input: []const u8) anyerror!void { +fn fuzzFrontmatterJson(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); var fm = zigmark.Frontmatter.init(arena.allocator(), input, .json) catch return; fm.deinit(); } + +fn fuzzFrontmatterZon(_: void, smith: *Smith) anyerror!void { + var buf: [max_input]u8 = undefined; + const input = buf[0..smith.slice(&buf)]; + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + var fm = zigmark.Frontmatter.init(arena.allocator(), input, .zon) catch return; + fm.deinit(); +} diff --git a/src/main.zig b/src/main.zig index cade1ee..ae700d3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -25,6 +25,8 @@ pub fn main(init: std.process.Init) !void { \\-f, --format Output format: "html" (default), "ast", "ai", "terminal", \\ "frontmatter", "markdown", "normalize", or "typst". \\-o, --output Write output to FILE instead of stdout. + \\--safe HTML format: escape raw/inline HTML to visible text + \\ (for untrusted input). URL-scheme filtering is always on. \\-s, --set ... Set a frontmatter field (KEY=VALUE). Repeatable. \\ Applies to: markdown, normalize, frontmatter formats. \\-d, --delete ... Delete a frontmatter field (dot-path). Repeatable. @@ -152,9 +154,12 @@ pub fn main(init: std.process.Init) !void { const body = input[body_off..]; if (body_off > 0) { - var fm = zigmark.Frontmatter.initFromMarkdown(alloc, input) catch |err| { - std.debug.print("error: failed to parse frontmatter: {}\n", .{err}); - return err; + var fm = zigmark.Frontmatter.initFromMarkdown(alloc, input) catch { + // Malformed frontmatter (e.g. upstream YAML quirk, #73): fall + // back to emitting the document verbatim rather than failing. + writer.interface.writeAll(input) catch {}; + writer.interface.flush() catch {}; + return; }; defer fm.deinit(); applyFrontmatterMods(&fm, res.args.set, res.args.delete); @@ -175,7 +180,10 @@ pub fn main(init: std.process.Init) !void { // ── Frontmatter JSON dump ("frontmatter") ──────────────────────────────── if (std.mem.eql(u8, format, "frontmatter")) { var fm = zigmark.Frontmatter.initFromMarkdown(alloc, input) catch |err| switch (err) { - error.InvalidFrontMatter => { + // No usable frontmatter — including malformed frontmatter that the + // parser rejects (e.g. the upstream YAML plain-scalar quirk, #73). + // Emit an empty object rather than aborting the command. + error.InvalidFrontMatter, error.ParseFailure, error.EmptyDocument => { writer.interface.writeAll("{}\n") catch {}; writer.interface.flush() catch {}; return; @@ -261,7 +269,10 @@ pub fn main(init: std.process.Init) !void { // ── HTML ───────────────────────────────────────────────────────────────── if (std.mem.eql(u8, format, "html")) { - zigmark.renderHtmlWithMermaid(alloc, &writer.interface, doc, &pozeiden.render) catch |err| { + zigmark.renderHtmlWithOptions(alloc, &writer.interface, doc, .{ + .safe = res.args.safe != 0, + .mermaid = &pozeiden.render, + }) catch |err| { std.debug.print("error: failed to render HTML: {}\n", .{err}); return err; }; @@ -315,6 +326,8 @@ pub fn main(init: std.process.Init) !void { /// Coerce a `std.json.Value` to `bool`, handling both native `.bool` values /// and string scalars (`"true"` / `"false"`) as produced by the YAML parser. fn jsonAsBool(v: std.json.Value) ?bool { + // Aligned prongs are intentional + // zig fmt: off return switch (v) { .bool => |b| b, .string => |s| if (std.mem.eql(u8, s, "true") or std.mem.eql(u8, s, "yes")) @@ -325,6 +338,7 @@ fn jsonAsBool(v: std.json.Value) ?bool { null, else => null, }; + // zig fmt: on } /// Map YAML/TOML/JSON frontmatter fields to `DocumentOptions`. @@ -333,6 +347,8 @@ fn jsonAsBool(v: std.json.Value) ?bool { fn frontmatterToTypstOpts(fm: *const zigmark.Frontmatter) zigmark.typst.DocumentOptions { var opts: zigmark.typst.DocumentOptions = .{}; + // Hand-aligned frontmatter → options mapping + // zig fmt: off if (fm.get("title")) |v| if (v == .string) { opts.title = v.string; }; if (fm.get("subtitle")) |v| if (v == .string) { opts.subtitle = v.string; }; if (fm.get("date")) |v| if (v == .string) { opts.date = v.string; }; @@ -354,12 +370,12 @@ fn frontmatterToTypstOpts(fm: *const zigmark.Frontmatter) zigmark.typst.Document if (fm.get("titlepage-color")) |v| if (v == .string) { opts.titlepage_color = v.string; }; if (fm.get("titlepage-text-color")) |v| if (v == .string) { opts.titlepage_text_color = v.string; }; if (fm.get("titlepage-rule-color")) |v| if (v == .string) { opts.titlepage_rule_color = v.string; }; - if (fm.get("titlepage-rule-height"))|v| if (v == .integer) { opts.titlepage_rule_height = @intCast(v.integer); }; + if (fm.get("titlepage-rule-height"))|v| if (v == .integer) { if (std.math.cast(u32, v.integer)) |n| { opts.titlepage_rule_height = n; } }; // ── TOC ─────────────────────────────────────────────────────────────────── if (fm.get("toc")) |v| { if (jsonAsBool(v)) |b| opts.toc = b; } if (fm.get("toc-title")) |v| if (v == .string) { opts.toc_title = v.string; }; - if (fm.get("toc-depth")) |v| if (v == .integer) { opts.toc_depth = @intCast(v.integer); }; + if (fm.get("toc-depth")) |v| if (v == .integer) { if (std.math.cast(u8, v.integer)) |n| { opts.toc_depth = n; } }; // ── Sections / links ────────────────────────────────────────────────────── if (fm.get("numbersections")) |v| { if (jsonAsBool(v)) |b| opts.numbersections = b; } @@ -375,6 +391,7 @@ fn frontmatterToTypstOpts(fm: *const zigmark.Frontmatter) zigmark.typst.Document if (fm.get("footer-left")) |v| if (v == .string) { opts.footer_left = v.string; }; if (fm.get("footer-center")) |v| if (v == .string) { opts.footer_center = v.string; }; if (fm.get("footer-right")) |v| if (v == .string) { opts.footer_right = v.string; }; + // zig fmt: on return opts; } diff --git a/src/markdown/combinators.zig b/src/markdown/combinators.zig index 965d168..4428a61 100644 --- a/src/markdown/combinators.zig +++ b/src/markdown/combinators.zig @@ -146,13 +146,14 @@ pub fn tryAtxHeading(allocator: Allocator, line: []const u8) ?HeadingResult { // Must not be 4+ spaces (that's an indented code block) if (pos < line.len and line[pos] == ' ') return null; if (pos >= line.len or line[pos] != '#') return null; - // Count # characters (1-6) + // Count # characters (1-6). Bail as soon as we pass 6 so a long run of + // '#' can neither overflow the counter nor waste time scanning it. var level: u8 = 0; while (pos < line.len and line[pos] == '#') { level += 1; pos += 1; + if (level > 6) return null; } - if (level > 6) return null; // After the #'s, must be end of line or a space/tab if (pos < line.len and line[pos] != ' ' and line[pos] != '\t') return null; // Skip spaces after # diff --git a/src/markdown/frontmatter.zig b/src/markdown/frontmatter.zig index 6e2c2d0..4b300f0 100644 --- a/src/markdown/frontmatter.zig +++ b/src/markdown/frontmatter.zig @@ -69,12 +69,11 @@ pub fn init(alloc: Allocator, source: []const u8, input_kind: Kind) !FrontMatter var y = Yaml{ .source = source }; y.load(alloc) catch |err| switch (err) { error.ParseFailure => { - std.debug.assert(y.parse_errors.errorMessageCount() > 0); - const ebuf = try alloc.alloc(u8, 64 * 1024); - defer alloc.free(ebuf); - var errw = std.Io.Writer.fixed(ebuf); - y.parse_errors.renderToWriter(.{}, &errw) catch {}; - std.debug.print("{s}", .{errw.buffered()}); + // Free the parser's error bundle and partial state, then + // return the error. A library must not write diagnostics to + // stderr, nor assert on the vendored parser's internals; the + // caller decides how (and whether) to report the failure. + y.deinit(alloc); return error.ParseFailure; }, else => return err, diff --git a/src/markdown/inline.zig b/src/markdown/inline.zig index 5411bb6..2011866 100644 --- a/src/markdown/inline.zig +++ b/src/markdown/inline.zig @@ -753,8 +753,24 @@ fn indexOfBreakChar(input: []const u8, pos: usize, gfm: bool) usize { return input.len; } +/// Maximum inline nesting depth (links/images within link text) before parsing +/// bails with `error.NestingTooDeep`. Guards against stack overflow on crafted +/// input such as deeply nested `[[[…](u)](u)` or `![![…](u)](u)`. +pub const max_inline_nesting_depth: usize = 128; + pub fn parseInlineElements(allocator: Allocator, input: []const u8, ref_map: ?*const RefMap, gfm: bool) !std.ArrayList(AST.Inline) { + return parseInlineElementsDepth(allocator, input, ref_map, gfm, 0); +} + +fn parseInlineElementsDepth(allocator: Allocator, input: []const u8, ref_map: ?*const RefMap, gfm: bool, depth: usize) anyerror!std.ArrayList(AST.Inline) { + if (depth > max_inline_nesting_depth) return error.NestingTooDeep; var inlines = std.ArrayList(AST.Inline).empty; + // On any error path, free what we accumulated so pathological input that + // trips the depth guard (or an OOM) does not leak the partial tree. + errdefer { + for (inlines.items) |*it| it.deinit(allocator); + inlines.deinit(allocator); + } var delimiters = std.ArrayList(Delimiter).empty; defer delimiters.deinit(allocator); var pos: usize = 0; @@ -814,7 +830,7 @@ pub fn parseInlineElements(allocator: Allocator, input: []const u8, ref_map: ?*c // Image ![alt](url) or ![alt][ref] if (c == '!' and pos + 1 < input.len and input[pos + 1] == '[') { if (tryParseLink(input, pos + 1)) |r| { - const alt = try flattenInlineText(allocator, r.text, ref_map, gfm); + const alt = try flattenInlineText(allocator, r.text, ref_map, gfm, depth); try inlines.append(allocator, .{ .image = .{ .alt_text = alt, .destination = .{ @@ -827,7 +843,7 @@ pub fn parseInlineElements(allocator: Allocator, input: []const u8, ref_map: ?*c continue; } if (ref_map) |rm| { - if (tryParseImageRefLink(allocator, input, pos, rm, gfm)) |r| { + if (tryParseImageRefLink(allocator, input, pos, rm, gfm, depth)) |r| { try inlines.append(allocator, r.inline_node); pos = r.end; continue; @@ -847,7 +863,7 @@ pub fn parseInlineElements(allocator: Allocator, input: []const u8, ref_map: ?*c } } if (tryParseLink(input, pos)) |r| { - var nested = try parseInlineElements(allocator, r.text, ref_map, gfm); + var nested = try parseInlineElementsDepth(allocator, r.text, ref_map, gfm, depth + 1); if (containsLink(nested.items)) { for (nested.items) |*item| item.deinit(allocator); nested.deinit(allocator); @@ -866,7 +882,7 @@ pub fn parseInlineElements(allocator: Allocator, input: []const u8, ref_map: ?*c continue; } if (ref_map) |rm| { - if (tryParseRefLink(allocator, input, pos, rm, gfm)) |r| { + if (tryParseRefLink(allocator, input, pos, rm, gfm, depth)) |r| { try inlines.append(allocator, r.inline_node); pos = r.end; continue; @@ -939,7 +955,10 @@ pub fn parseInlineElements(allocator: Allocator, input: []const u8, ref_map: ?*c while (domain_end > pos + 1 and input[domain_end - 1] == '.') domain_end -= 1; const domain = input[pos + 1 .. domain_end]; var has_period = false; - for (domain) |ch| if (ch == '.') { has_period = true; break; }; + for (domain) |ch| if (ch == '.') { + has_period = true; + break; + }; const last_ok = domain.len > 0 and domain[domain.len - 1] != '-' and domain[domain.len - 1] != '_'; if (has_period and last_ok) { // Strip already-emitted local-part chars from inlines/delimiters @@ -1069,13 +1088,19 @@ fn gfmWwwLinkEnd(text: []const u8, after_www: usize) ?usize { const domain_part = trimmed[4..]; // after "www." var has_period = false; for (domain_part) |ch| { - if (ch == '.' and ch != domain_part[0]) { has_period = true; break; } + if (ch == '.' and ch != domain_part[0]) { + has_period = true; + break; + } if (ch == '/' or ch == '?' or ch == '#') break; if (ch == '.') has_period = true; } // Simpler: check if there's any '.' in domain_part for (domain_part) |ch| { - if (ch == '.') { has_period = true; break; } + if (ch == '.') { + has_period = true; + break; + } if (ch == '/' or ch == '?' or ch == '#') break; } if (!has_period) return null; @@ -1126,7 +1151,10 @@ fn gfmEmailMatch(text: []const u8, at_pos: usize) ?struct { start: usize, end: u // Domain must have at least one period var has_period = false; - for (domain) |ch| if (ch == '.') { has_period = true; break; }; + for (domain) |ch| if (ch == '.') { + has_period = true; + break; + }; if (!has_period) return null; // Domain last char must not be '-' or '_' @@ -1280,8 +1308,8 @@ fn tryParseCodeSpan(allocator: Allocator, input: []const u8, pos: usize) ?struct return null; } -fn flattenInlineText(allocator: Allocator, input: []const u8, ref_map: ?*const RefMap, gfm: bool) Allocator.Error![]const u8 { - var inlines = try parseInlineElements(allocator, input, ref_map, gfm); +fn flattenInlineText(allocator: Allocator, input: []const u8, ref_map: ?*const RefMap, gfm: bool, depth: usize) anyerror![]const u8 { + var inlines = try parseInlineElementsDepth(allocator, input, ref_map, gfm, depth + 1); defer { for (inlines.items) |*item| item.deinit(allocator); inlines.deinit(allocator); @@ -1407,7 +1435,7 @@ pub fn tryParseHtmlTag(input: []const u8, pos: usize) ?usize { const InlineParseResult = struct { inline_node: AST.Inline, end: usize }; -fn tryParseImageRefLink(allocator: Allocator, input: []const u8, start: usize, rm: *const RefMap, gfm: bool) ?InlineParseResult { +fn tryParseImageRefLink(allocator: Allocator, input: []const u8, start: usize, rm: *const RefMap, gfm: bool, depth: usize) ?InlineParseResult { if (start >= input.len or input[start] != '!' or start + 1 >= input.len or input[start + 1] != '[') return null; const be = findClosingBracket(input, start + 2) orelse return null; const raw_alt = input[start + 2 .. be]; @@ -1417,7 +1445,7 @@ fn tryParseImageRefLink(allocator: Allocator, input: []const u8, start: usize, r // Collapsed: ![alt][] if (be + 2 < input.len and input[be + 2] == ']') { if (resolveRef(allocator, rm, raw_alt)) |ref| { - const flat = flattenInlineText(allocator, raw_alt, rm, gfm) catch return null; + const flat = flattenInlineText(allocator, raw_alt, rm, gfm, depth) catch return null; return .{ .inline_node = .{ .image = .{ .alt_text = flat, .destination = .{ .url = ref.url, .title = ref.title }, .link_type = .collapsed } }, .end = be + 3 }; } } else { @@ -1425,7 +1453,7 @@ fn tryParseImageRefLink(allocator: Allocator, input: []const u8, start: usize, r while (le < input.len and input[le] != ']') le += 1; if (le < input.len) { if (resolveRef(allocator, rm, input[be + 2 .. le])) |ref| { - const flat = flattenInlineText(allocator, raw_alt, rm, gfm) catch return null; + const flat = flattenInlineText(allocator, raw_alt, rm, gfm, depth) catch return null; return .{ .inline_node = .{ .image = .{ .alt_text = flat, .destination = .{ .url = ref.url, .title = ref.title }, .link_type = .reference } }, .end = le + 1 }; } } @@ -1433,13 +1461,13 @@ fn tryParseImageRefLink(allocator: Allocator, input: []const u8, start: usize, r } // Shortcut: ![alt] if (resolveRef(allocator, rm, raw_alt)) |ref| { - const flat = flattenInlineText(allocator, raw_alt, rm, gfm) catch return null; + const flat = flattenInlineText(allocator, raw_alt, rm, gfm, depth) catch return null; return .{ .inline_node = .{ .image = .{ .alt_text = flat, .destination = .{ .url = ref.url, .title = ref.title }, .link_type = .shortcut } }, .end = be + 1 }; } return null; } -fn tryParseRefLink(allocator: Allocator, input: []const u8, start: usize, rm: *const RefMap, gfm: bool) ?InlineParseResult { +fn tryParseRefLink(allocator: Allocator, input: []const u8, start: usize, rm: *const RefMap, gfm: bool, depth: usize) ?InlineParseResult { if (start >= input.len or input[start] != '[') return null; const be = findClosingBracket(input, start + 1) orelse return null; const link_text = input[start + 1 .. be]; @@ -1450,21 +1478,21 @@ fn tryParseRefLink(allocator: Allocator, input: []const u8, start: usize, rm: *c // Collapsed: [text][] if (be + 2 < input.len and input[be + 2] == ']') { if (resolveRef(allocator, rm, link_text)) |ref| - return buildRefLink(allocator, link_text, ref, .collapsed, be + 3, rm, gfm); + return buildRefLink(allocator, link_text, ref, .collapsed, be + 3, rm, gfm, depth); } else { // Full: [text][label] var le: usize = be + 2; while (le < input.len and input[le] != ']') le += 1; if (le < input.len) { if (resolveRef(allocator, rm, input[be + 2 .. le])) |ref| - return buildRefLink(allocator, link_text, ref, .reference, le + 1, rm, gfm); + return buildRefLink(allocator, link_text, ref, .reference, le + 1, rm, gfm, depth); } } } // Shortcut: [text] if (!tried_full) { if (resolveRef(allocator, rm, link_text)) |ref| - return buildRefLink(allocator, link_text, ref, .shortcut, be + 1, rm, gfm); + return buildRefLink(allocator, link_text, ref, .shortcut, be + 1, rm, gfm, depth); } return null; } @@ -1477,9 +1505,10 @@ fn buildRefLink( end: usize, rm: ?*const RefMap, gfm: bool, + depth: usize, ) ?InlineParseResult { var link = AST.Link.init(allocator, .{ .url = ref.url, .title = ref.title }, link_type); - var nested = parseInlineElements(allocator, text, rm, gfm) catch return null; + var nested = parseInlineElementsDepth(allocator, text, rm, gfm, depth + 1) catch return null; if (containsLink(nested.items)) { // Can't nest links — free the ref-owned url/title and nested inlines. for (nested.items) |*item| item.deinit(allocator); diff --git a/src/markdown/library_test.zig b/src/markdown/library_test.zig index 04eeaa8..3b7cce7 100644 --- a/src/markdown/library_test.zig +++ b/src/markdown/library_test.zig @@ -457,8 +457,7 @@ test "library: multiple fm filters AND semantics" { defer tst.allocator.free(results.?); try tst.expectEqual(@as(usize, 1), results.?.len); - try tst.expectEqualStrings("Access Control Policy", - results.?[0].entry.frontmatter.?.get("title").?.string); + try tst.expectEqualStrings("Access Control Policy", results.?[0].entry.frontmatter.?.get("title").?.string); } test "library: multiple fm filters partial match excluded" { @@ -501,10 +500,8 @@ test "library: sortBy field ascending" { Library.sortBy(results.?, "title", true); - try tst.expectEqualStrings("Access Control Policy", - results.?[0].entry.frontmatter.?.get("title").?.string); - try tst.expectEqualStrings("HR Policy", - results.?[1].entry.frontmatter.?.get("title").?.string); + try tst.expectEqualStrings("Access Control Policy", results.?[0].entry.frontmatter.?.get("title").?.string); + try tst.expectEqualStrings("HR Policy", results.?[1].entry.frontmatter.?.get("title").?.string); } test "library: sortBy field descending" { @@ -519,10 +516,8 @@ test "library: sortBy field descending" { Library.sortBy(results.?, "title", false); - try tst.expectEqualStrings("HR Policy", - results.?[0].entry.frontmatter.?.get("title").?.string); - try tst.expectEqualStrings("Access Control Policy", - results.?[1].entry.frontmatter.?.get("title").?.string); + try tst.expectEqualStrings("HR Policy", results.?[0].entry.frontmatter.?.get("title").?.string); + try tst.expectEqualStrings("Access Control Policy", results.?[1].entry.frontmatter.?.get("title").?.string); } test "library: sortBy missing field sorts last" { @@ -530,7 +525,7 @@ test "library: sortBy missing field sorts last" { defer lib.deinit(); try lib.add(no_frontmatter, null); // no title - try lib.add(policy_a, null); // "Access Control Policy" + try lib.add(policy_a, null); // "Access Control Policy" const results = try lib.query(tst.allocator, ""); defer tst.allocator.free(results.?); diff --git a/src/markdown/parser.zig b/src/markdown/parser.zig index 879fc7f..29eed9b 100644 --- a/src/markdown/parser.zig +++ b/src/markdown/parser.zig @@ -48,6 +48,17 @@ pub const parsers = @import("combinators.zig"); const RefMap = Inline.RefMap; +// Resource limits (DoS guards). + +/// Default maximum block nesting depth (blockquotes/lists). Beyond this the +/// parser returns `error.NestingTooDeep` instead of recursing until the native +/// stack overflows. See `Parser.max_nesting_depth`. +pub const default_max_nesting_depth: usize = 128; + +/// Default maximum input size accepted by `parseMarkdown`/`parseFromReader`, +/// in bytes (16 MiB, `0` = unlimited). See `Parser.max_input_bytes`. +pub const default_max_input_bytes: usize = 16 * 1024 * 1024; + // ── Small shared helpers ───────────────────────────────────────────────────── fn trimLine(line: []const u8) []const u8 { @@ -261,6 +272,14 @@ const Self = @This(); /// continuation lines which must not form setext headings. has_lazy_setext: bool = false, gfm: bool = true, +/// Maximum block nesting depth before parsing bails with `error.NestingTooDeep` +/// (DoS guard against stack overflow on deeply nested blockquotes/lists). +max_nesting_depth: usize = default_max_nesting_depth, +/// Maximum input size in bytes for `parseMarkdown`/`parseFromReader` +/// (`0` = unlimited); larger input yields `error.InputTooLarge`. +max_input_bytes: usize = default_max_input_bytes, +/// Current block-nesting depth (internal; incremented on each recursion). +depth: usize = 0, pub fn init() Self { return Self{}; @@ -274,6 +293,7 @@ fn appendInlines(allocator: Allocator, dest: *std.ArrayList(AST.Inline), content } pub fn parseMarkdown(self: Self, allocator: Allocator, input: []const u8) !AST.Document { + if (self.max_input_bytes != 0 and input.len > self.max_input_bytes) return error.InputTooLarge; var ref_map = RefMap.init(allocator); defer { var it = ref_map.iterator(); @@ -299,7 +319,11 @@ pub fn parseMarkdown(self: Self, allocator: Allocator, input: []const u8) !AST.D pub fn parseFromReader(self: Self, allocator: Allocator, reader: *std.Io.Reader) !AST.Document { var buf: std.ArrayList(u8) = .empty; defer buf.deinit(allocator); - try reader.appendRemainingUnlimited(allocator, &buf); + const limit: std.Io.Limit = if (self.max_input_bytes == 0) .unlimited else .limited(self.max_input_bytes); + reader.appendRemaining(allocator, &buf, limit) catch |err| switch (err) { + error.StreamTooLong => return error.InputTooLarge, + else => |e| return e, + }; return self.parseMarkdown(allocator, buf.items); } @@ -635,6 +659,7 @@ fn stripIndent(line: []const u8, n: usize) StripResult { } fn parseList( + parent: Self, allocator: Allocator, lines: []const []const u8, start: usize, @@ -697,6 +722,8 @@ fn parseList( if (trimLine(effective_content).len > 0) { var inner = init(); + inner.max_nesting_depth = parent.max_nesting_depth; + inner.depth = parent.depth + 1; var inner_doc = try inner.parseMarkdownWithRefs(allocator, effective_content, ref_map); for (inner_doc.children.items) |block| try item.children.append(allocator, block); // Free the ArrayList backing memory without deiniting moved children. @@ -919,7 +946,11 @@ fn htmlBlockTypeEndFound(line: []const u8, block_type: HtmlBlockType) bool { } fn parseMarkdownWithRefs(self: Self, allocator: Allocator, input: []const u8, ref_map: *const RefMap) !AST.Document { + if (self.depth > self.max_nesting_depth) return error.NestingTooDeep; var doc = AST.Document.init(allocator); + // Free the partially-built document if parsing fails partway (e.g. a nested + // block trips the depth guard), so callers using a plain gpa don't leak. + errdefer doc.deinit(allocator); doc.gfm = self.gfm; var lines_list = try splitLines(allocator, input); defer lines_list.deinit(allocator); @@ -948,7 +979,10 @@ fn parseMarkdownWithRefs(self: Self, allocator: Allocator, input: []const u8, re var heading = AST.Heading.init(allocator, h.level); const owned_content = try allocator.dupe(u8, h.content); heading.inline_source = owned_content; - try appendInlines(allocator, &heading.children, owned_content, ref_map, self.gfm); + appendInlines(allocator, &heading.children, owned_content, ref_map, self.gfm) catch |err| { + heading.deinit(allocator); + return err; + }; try doc.children.append(allocator, .{ .heading = heading }); i += 1; continue; @@ -1071,6 +1105,8 @@ fn parseMarkdownWithRefs(self: Self, allocator: Allocator, input: []const u8, re defer allocator.free(bq_str); var inner = init(); inner.has_lazy_setext = has_lazy_setext_line; + inner.max_nesting_depth = self.max_nesting_depth; + inner.depth = self.depth + 1; var inner_doc = try inner.parseMarkdownWithRefs(allocator, bq_str, ref_map); var bq = AST.Blockquote.init(allocator); for (inner_doc.children.items) |block| try bq.children.append(allocator, block); @@ -1082,7 +1118,7 @@ fn parseMarkdownWithRefs(self: Self, allocator: Allocator, input: []const u8, re // Unordered list if (bulletListContentColumn(raw)) |first| { - const result = try parseList(allocator, lines, i, ref_map, .{ .list_type = .unordered, .marker = first.marker, .delimiter = 0, .start_num = 0 }); + const result = try parseList(self, allocator, lines, i, ref_map, .{ .list_type = .unordered, .marker = first.marker, .delimiter = 0, .start_num = 0 }); try doc.children.append(allocator, .{ .list = result.list }); i = result.next_line; continue; @@ -1090,7 +1126,7 @@ fn parseMarkdownWithRefs(self: Self, allocator: Allocator, input: []const u8, re // Ordered list if (orderedListContentColumn(raw)) |first| { - const result = try parseList(allocator, lines, i, ref_map, .{ .list_type = .ordered, .marker = 0, .delimiter = first.delimiter, .start_num = first.num }); + const result = try parseList(self, allocator, lines, i, ref_map, .{ .list_type = .ordered, .marker = 0, .delimiter = first.delimiter, .start_num = first.num }); try doc.children.append(allocator, .{ .list = result.list }); i = result.next_line; continue; @@ -1127,7 +1163,11 @@ fn parseMarkdownWithRefs(self: Self, allocator: Allocator, input: []const u8, re var para = AST.Paragraph.init(allocator); const fn_pc = try allocator.dupe(u8, fd.content); para.inline_source = fn_pc; - try appendInlines(allocator, ¶.children, fn_pc, ref_map, self.gfm); + appendInlines(allocator, ¶.children, fn_pc, ref_map, self.gfm) catch |err| { + para.deinit(allocator); + fn_def.deinit(allocator); + return err; + }; try fn_def.children.append(allocator, .{ .paragraph = para }); try doc.children.append(allocator, .{ .footnote_definition = fn_def }); i += 1; @@ -1178,8 +1218,11 @@ fn parseMarkdownWithRefs(self: Self, allocator: Allocator, input: []const u8, re } const pc = try allocator.dupe(u8, content); para_buf.deinit(allocator); - try appendInlines(allocator, ¶.children, pc, ref_map, self.gfm); para.inline_source = pc; + appendInlines(allocator, ¶.children, pc, ref_map, self.gfm) catch |err| { + para.deinit(allocator); + return err; + }; } if (is_setext and para.children.items.len > 0) { const st = trimLine(lines[i]); @@ -1272,13 +1315,19 @@ fn parseTableDelimiter(line: []const u8) ?TableDelimResult { var left_colon = false; var right_colon = false; - if (t[i] == ':') { left_colon = true; i += 1; } + if (t[i] == ':') { + left_colon = true; + i += 1; + } var dash_count: usize = 0; while (i < t.len and t[i] == '-') : (i += 1) dash_count += 1; if (dash_count < 1) return null; - if (i < t.len and t[i] == ':') { right_colon = true; i += 1; } + if (i < t.len and t[i] == ':') { + right_colon = true; + i += 1; + } if (result.count >= result.alignments.len) return null; result.alignments[result.count] = if (left_colon and right_colon) @@ -1453,5 +1502,6 @@ fn tryTableStart( test { _ = @import("test.zig"); + _ = @import("security_test.zig"); tst.refAllDecls(@This()); } diff --git a/src/markdown/renderers/html.zig b/src/markdown/renderers/html.zig index 6f0d28e..e6c4b23 100644 --- a/src/markdown/renderers/html.zig +++ b/src/markdown/renderers/html.zig @@ -245,6 +245,15 @@ fn encodeUtf8Buf(cp: u21, buf: *[4]u8) u3 { /// Result of decoding an HTML entity: UTF-8 bytes and their length. const EntityResult = struct { bytes: [8]u8, len: u4 }; +// Guard the fixed 8-byte entity buffer against future table edits: no entity's +// UTF-8 expansion may exceed it (`len` is a `u4`, so the hard ceiling is 15). +comptime { + for (EntryMap) |e| { + if (e.bytes.len > @typeInfo(@FieldType(EntityResult, "bytes")).array.len) + @compileError("HTML entity '" ++ e.name ++ "' exceeds EntityResult buffer"); + } +} + /// Resolve a named HTML entity to its UTF-8 byte sequence. /// Returns the bytes and length, or null if the entity is not recognized. /// Supports multi-codepoint entities (e.g. ≧̸ → U+2267 U+0338). @@ -387,6 +396,72 @@ fn writeUrlEncodedWithEntities(writer: anytype, s: []const u8) !void { } } +// ── URL scheme safety (default-on XSS guard) ────────────────────────────────── + +/// True if a destination URL carries a scheme that can execute script or read +/// local resources (`javascript:`, `vbscript:`, `file:`, or a non-image +/// `data:`). Such destinations are neutralised to an empty `href`/`src` by the +/// HTML renderer, matching the safe behaviour of the CommonMark reference +/// implementation. This is always applied, independent of the `safe` option. +/// +/// Detection mirrors what a browser sees: leading control/space bytes are +/// ignored, single-byte HTML entities in the scheme are decoded (so +/// `javascript:` is caught), and tab/newline/CR are stripped from within +/// the scheme (so `java script:` is caught). A `/`, `?`, or `#` before any +/// `:` means the URL is relative (no scheme) and therefore safe. +fn urlSchemeIsDangerous(url: []const u8) bool { + var buf: [11]u8 = undefined; // longest tracked scheme is "javascript" (10) + var n: usize = 0; + var i: usize = 0; + while (i < url.len and url[i] <= 0x20) : (i += 1) {} + while (i < url.len) { + var c = url[i]; + var adv: usize = 1; + if (c == '&') { + if (tryDecodeEntity(url, i)) |ent| { + if (ent.len != 1) return false; // multi-byte can't be part of a scheme + c = ent.bytes[0]; + adv = ent.consumed; + } + } + // Browsers strip tab/newline/CR from URLs before scheme parsing. + if (c == '\t' or c == '\n' or c == '\r') { + i += adv; + continue; + } + switch (c) { + ':' => { + const scheme = buf[0..n]; + if (std.ascii.eqlIgnoreCase(scheme, "javascript") or + std.ascii.eqlIgnoreCase(scheme, "vbscript") or + std.ascii.eqlIgnoreCase(scheme, "file")) + return true; + if (std.ascii.eqlIgnoreCase(scheme, "data")) + return !dataUrlIsSafeImage(url[i + 1 ..]); + return false; + }, + '/', '?', '#' => return false, // relative URL — no scheme + else => { + if (n >= buf.len) return false; // scheme longer than any we block + buf[n] = std.ascii.toLower(c); + n += 1; + }, + } + i += adv; + } + return false; +} + +/// True if a `data:` payload (bytes after `data:`) is one of a few inert image +/// media types that are safe to embed. +fn dataUrlIsSafeImage(rest: []const u8) bool { + const safe = [_][]const u8{ "image/png", "image/gif", "image/jpeg", "image/webp" }; + for (safe) |m| { + if (rest.len >= m.len and std.ascii.eqlIgnoreCase(rest[0..m.len], m)) return true; + } + return false; +} + /// Write title text with HTML entity decoding, backslash escape processing, and HTML escaping. fn writeEscapedTitleWithEntities(writer: anytype, s: []const u8) !void { var i: usize = 0; @@ -444,7 +519,9 @@ const disallowed_html_tags = std.StaticStringMap(void).initComptime(.{ .{ "noframes", {} }, .{ "script", {} }, .{ "plaintext", {} }, }); -fn writeHtmlFiltered(writer: anytype, s: []const u8, gfm: bool) !void { +fn writeHtmlFiltered(writer: anytype, s: []const u8, gfm: bool, safe: bool) !void { + // Safe mode: neutralise raw HTML by escaping it to visible text. + if (safe) return writeEscaped(writer, s); if (!gfm) return writer.writeAll(s); var i: usize = 0; while (i < s.len) { @@ -483,7 +560,7 @@ fn writeHtmlFiltered(writer: anytype, s: []const u8, gfm: bool) !void { // ── Inline renderer ─────────────────────────────────────────────────────────── -fn renderInline(writer: anytype, item: AST.Inline, gfm: bool) !void { +fn renderInline(writer: anytype, item: AST.Inline, ctx: *const RenderCtx) !void { switch (item) { .text => |t| try writeEscapedWithEntities(writer, t.content), .soft_break => try writer.writeByte('\n'), @@ -495,22 +572,23 @@ fn renderInline(writer: anytype, item: AST.Inline, gfm: bool) !void { }, .emphasis => |e| { try writer.writeAll(""); - for (e.children.items) |child| try renderInline(writer, child, gfm); + for (e.children.items) |child| try renderInline(writer, child, ctx); try writer.writeAll(""); }, .strong => |s| { try writer.writeAll(""); - for (s.children.items) |child| try renderInline(writer, child, gfm); + for (s.children.items) |child| try renderInline(writer, child, ctx); try writer.writeAll(""); }, .strikethrough => |s| { try writer.writeAll(""); - for (s.children.items) |child| try renderInline(writer, child, gfm); + for (s.children.items) |child| try renderInline(writer, child, ctx); try writer.writeAll(""); }, .link => |l| { try writer.writeAll("'); - for (l.children.items) |child| try renderInline(writer, child, gfm); + for (l.children.items) |child| try renderInline(writer, child, ctx); try writer.writeAll(""); }, .image => |img| { try writer.writeAll("\""); |al| { try writer.writeAll("`) can carry a bad scheme. + const dangerous = !al.is_email and !al.is_gfm_www and urlSchemeIsDangerous(al.url); if (al.is_email) try writer.writeAll("mailto:"); if (al.is_gfm_www) try writer.writeAll("http://"); - try writeUrlEncodedLiteral(writer, al.url); + if (!dangerous) try writeUrlEncodedLiteral(writer, al.url); try writer.writeAll("\">"); try writeEscaped(writer, al.url); try writer.writeAll(""); }, .footnote_reference => |fr| { - try writer.print("{s}", .{ fr.label, fr.label }); + try writer.writeAll(""); + try writeEscaped(writer, fr.label); + try writer.writeAll(""); }, - .html_in_line => |hi| try writeHtmlFiltered(writer, hi.content, gfm), + .html_in_line => |hi| try writeHtmlFiltered(writer, hi.content, ctx.gfm, ctx.safe), } } @@ -555,6 +641,8 @@ fn renderInline(writer: anytype, item: AST.Inline, gfm: bool) !void { const RenderCtx = struct { gfm: bool, allocator: Allocator, + /// When true, raw/inline HTML is escaped to visible text (see `Options`). + safe: bool = false, mermaid: ?*const fn (Allocator, []const u8) anyerror![]const u8 = null, }; @@ -572,7 +660,7 @@ fn renderBlock(writer: *std.Io.Writer, block: AST.Block, ctx: *const RenderCtx) .center => try writer.writeAll(""), .right => try writer.writeAll(""), } - for (cell.children.items) |inl| try renderInline(writer, inl, ctx.gfm); + for (cell.children.items) |inl| try renderInline(writer, inl, ctx); try writer.writeAll("\n"); } try writer.writeAll("\n\n"); @@ -589,7 +677,7 @@ fn renderBlock(writer: *std.Io.Writer, block: AST.Block, ctx: *const RenderCtx) .center => try writer.writeAll(""), .right => try writer.writeAll(""), } - for (cell.children.items) |inl| try renderInline(writer, inl, ctx.gfm); + for (cell.children.items) |inl| try renderInline(writer, inl, ctx); try writer.writeAll("\n"); } try writer.writeAll("\n"); @@ -602,12 +690,12 @@ fn renderBlock(writer: *std.Io.Writer, block: AST.Block, ctx: *const RenderCtx) .heading => |h| { try writer.print("", .{h.level}); - for (h.children.items) |item| try renderInline(writer, item, ctx.gfm); + for (h.children.items) |item| try renderInline(writer, item, ctx); try writer.print("\n", .{h.level}); }, .paragraph => |p| { try writer.writeAll("

"); - for (p.children.items) |item| try renderInline(writer, item, ctx.gfm); + for (p.children.items) |item| try renderInline(writer, item, ctx); try writer.writeAll("

\n"); }, .thematic_break => try writer.writeAll("
\n"), @@ -689,7 +777,7 @@ fn renderBlock(writer: *std.Io.Writer, block: AST.Block, ctx: *const RenderCtx) for (item.children.items) |child| { switch (child) { .paragraph => |p| { - for (p.children.items) |inl| try renderInline(writer, inl, ctx.gfm); + for (p.children.items) |inl| try renderInline(writer, inl, ctx); wrote_inline = true; }, else => { @@ -717,12 +805,16 @@ fn renderBlock(writer: *std.Io.Writer, block: AST.Block, ctx: *const RenderCtx) try writer.print("\n", .{tag}); }, .footnote_definition => |fd| { - try writer.print("
\n", .{fd.label}); + try writer.writeAll("
\n"); for (fd.children.items) |child| { switch (child) { .paragraph => |p| { - try writer.print("

{s}: ", .{fd.label}); - for (p.children.items) |inl| try renderInline(writer, inl, ctx.gfm); + try writer.writeAll("

"); + try writeEscaped(writer, fd.label); + try writer.writeAll(": "); + for (p.children.items) |inl| try renderInline(writer, inl, ctx); try writer.writeAll("

\n"); }, else => try renderBlock(writer, child, ctx), @@ -730,16 +822,48 @@ fn renderBlock(writer: *std.Io.Writer, block: AST.Block, ctx: *const RenderCtx) } try writer.writeAll("
\n"); }, - .html_block => |hb| try writeHtmlFiltered(writer, hb.content, ctx.gfm), + .html_block => |hb| try writeHtmlFiltered(writer, hb.content, ctx.gfm, ctx.safe), } } // ── Top-level render ────────────────────────────────────────────────────────── +/// Rendering options for the HTML back-end. +/// +/// URL-scheme filtering — neutralising `javascript:`, `vbscript:`, `file:`, and +/// non-image `data:` destinations in links and images — is *always* applied and +/// is not configurable; it does not affect CommonMark conformance. These options +/// only control the additional, opt-in hardening below. +pub const Options = struct { + /// When true, raw and inline HTML from the input is escaped to visible text + /// (`)", .{}); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "src=\"\"") != null); + try tst.expect(mem.indexOf(u8, out, "data:text/html") == null); +} + +test "security: image data: URI is preserved" { + const out = try renderHtml(tst.allocator, "![i](data:image/png;base64,iVBORw0KGgo=)", .{}); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "data:image/png;base64,iVBORw0KGgo=") != null); +} + +test "security: unknown but harmless scheme is preserved (spec conformance guard)" { + // The CommonMark spec's `` example must still pass. + const out = try renderHtml(tst.allocator, "", .{}); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "made-up-scheme://foo,bar") != null); +} + +test "security: ordinary and relative URLs are untouched" { + const out = try renderHtml(tst.allocator, "[a](https://example.com) [b](/rel/path) [c](#frag)", .{}); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "href=\"https://example.com\"") != null); + try tst.expect(mem.indexOf(u8, out, "href=\"/rel/path\"") != null); + try tst.expect(mem.indexOf(u8, out, "href=\"#frag\"") != null); +} + +// ── XSS: footnote label injection ───────────────────────────────────────────── + +test "security: footnote label is escaped in attribute and text" { + const src = "x[^a\">]\n\n[^a\">]: note\n"; + const out = try renderHtml(tst.allocator, src, .{}); + defer tst.allocator.free(out); + // No unescaped attribute-breaking quote or raw tag from the label. + try tst.expect(mem.indexOf(u8, out, "hi
"; + const safe = try renderHtml(tst.allocator, src, .{ .safe = true }); + defer tst.allocator.free(safe); + try tst.expect(mem.indexOf(u8, safe, "<div") != null); + try tst.expect(mem.indexOf(u8, safe, "hi") != null); +} + +// ── Panic: ATX heading counter overflow ─────────────────────────────────────── + +test "security: 300 leading '#' does not panic" { + var buf: [304]u8 = undefined; + @memset(buf[0..300], '#'); + buf[300] = ' '; + buf[301] = 'x'; + const out = try renderHtml(tst.allocator, buf[0..302], .{}); + defer tst.allocator.free(out); + // Not a heading (>6 hashes) — just make sure we got here without a panic. + try tst.expect(out.len > 0); +} + +// ── DoS: nesting depth guard (no stack overflow, no leak) ───────────────────── + +test "security: deep blockquote nesting returns NestingTooDeep" { + var p = Parser.init(); + p.max_nesting_depth = 8; + var buf: [64]u8 = undefined; + @memset(buf[0..40], '>'); + buf[40] = ' '; + buf[41] = 'x'; + try tst.expectError(error.NestingTooDeep, p.parseMarkdown(tst.allocator, buf[0..42])); +} + +test "security: deep list nesting returns NestingTooDeep" { + var p = Parser.init(); + p.max_nesting_depth = 6; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(tst.allocator); + var i: usize = 0; + while (i < 40) : (i += 1) { + try out.appendNTimes(tst.allocator, ' ', i * 2); + try out.appendSlice(tst.allocator, "- x\n"); + } + try tst.expectError(error.NestingTooDeep, p.parseMarkdown(tst.allocator, out.items)); +} + +test "security: deep inline link nesting returns NestingTooDeep" { + var p = Parser.init(); + p.max_nesting_depth = 1000; // isolate the inline cap + var out: std.ArrayList(u8) = .empty; + defer out.deinit(tst.allocator); + const n = 400; + try out.appendNTimes(tst.allocator, '[', n); + try out.append(tst.allocator, 'x'); + var i: usize = 0; + while (i < n) : (i += 1) try out.appendSlice(tst.allocator, "](u)"); + try tst.expectError(error.NestingTooDeep, p.parseMarkdown(tst.allocator, out.items)); +} + +// ── DoS: input size cap ─────────────────────────────────────────────────────── + +test "security: oversized input returns InputTooLarge" { + var p = Parser.init(); + p.max_input_bytes = 64; + const big = "a" ** 128; + try tst.expectError(error.InputTooLarge, p.parseMarkdown(tst.allocator, big)); +} + +test "security: input size cap of 0 disables the limit" { + var p = Parser.init(); + p.max_input_bytes = 0; + const src = "hello world"; + var doc = try p.parseMarkdown(tst.allocator, src); + doc.deinit(tst.allocator); +} + +// ── Typst code-injection ────────────────────────────────────────────────────── + +test "security: typst code span uses raw() and escapes content" { + const out = try renderTypstDoc(tst.allocator, "`a`b`", .{}); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "#raw(") != null); +} + +test "security: typst fenced block cannot break out" { + const src = "````\n```\n#read(\"/etc/passwd\")\n````"; + const out = try renderTypstDoc(tst.allocator, src, .{}); + defer tst.allocator.free(out); + // The #read must sit inside a raw() string literal, not as bare markup. + try tst.expect(mem.indexOf(u8, out, "#raw(block: true") != null); + try tst.expect(mem.indexOf(u8, out, "#read(\\\"/etc/passwd\\\")") != null); +} + +// ── Typst preamble field validation ─────────────────────────────────────────── + +test "security: malicious fontsize falls back to default" { + const out = try renderTypstDoc(tst.allocator, "hi", .{ + .fontsize = "1pt); #read(\"/etc/passwd\"); text(size:(1pt", + }); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "#read") == null); + try tst.expect(mem.indexOf(u8, out, "size: 11pt") != null); +} + +test "security: malicious link colour falls back to default" { + const out = try renderTypstDoc(tst.allocator, "hi", .{ + .colorlinks = true, + .linkcolor = "000\"); #read(\"/etc/passwd\"); rgb(\"#000", + }); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "#read") == null); + try tst.expect(mem.indexOf(u8, out, "rgb(\"#A50000\")") != null); +} + +test "security: valid fontsize and colour pass through" { + const out = try renderTypstDoc(tst.allocator, "hi", .{ .fontsize = "12pt", .colorlinks = true, .linkcolor = "abc123" }); + defer tst.allocator.free(out); + try tst.expect(mem.indexOf(u8, out, "size: 12pt") != null); + try tst.expect(mem.indexOf(u8, out, "rgb(\"#abc123\")") != null); +} + +// ── Frontmatter robustness ──────────────────────────────────────────────────── + +test "security: large frontmatter integer does not panic" { + const src = "---\ntoc-depth: 999999999999\n---\n\nbody\n"; + var fm = Frontmatter.initFromMarkdown(tst.allocator, src) catch return; // parse-only; no panic + fm.deinit(); +} + +test "security: malformed YAML frontmatter fails cleanly without leaking (#73)" { + // Upstream zig-yaml rejects a plain scalar containing an inline " - " with + // ParseFailure. The fix here guarantees the failure frees the parser's + // error bundle — testing.allocator would flag a leak otherwise. + const src = "---\nauthor: Foo - Bar Baz\n---\n\nbody\n"; + try tst.expectError(error.ParseFailure, Frontmatter.initFromMarkdown(tst.allocator, src)); +} diff --git a/src/root.zig b/src/root.zig index 84ab4c1..3fdc292 100644 --- a/src/root.zig +++ b/src/root.zig @@ -79,6 +79,24 @@ pub const typst = typst_mod; /// invalid free. pub const MermaidRendererFn = *const fn (std.mem.Allocator, []const u8) anyerror![]const u8; +/// Options for the HTML renderer. `safe` escapes raw/inline HTML to visible +/// text; `mermaid` supplies an optional diagram renderer. URL-scheme filtering +/// (blocking `javascript:`/`vbscript:`/`file:`/non-image `data:` in links and +/// images) is always applied regardless of these options. +pub const HtmlOptions = html.Options; + +/// Render `doc` to HTML with explicit `HtmlOptions` — use this to enable `safe` +/// mode (raw-HTML escaping) for untrusted input, optionally with a mermaid +/// renderer. +pub fn renderHtmlWithOptions( + allocator: std.mem.Allocator, + writer: *std.Io.Writer, + doc: AST.Document, + opts: HtmlOptions, +) !void { + return html.renderToWriterWithOptions(allocator, writer, doc, opts); +} + /// Render `doc` to HTML, converting mermaid fenced blocks to inline SVG /// `
` elements using `mermaid` (pass `null` to skip conversion). pub fn renderHtmlWithMermaid( @@ -241,6 +259,21 @@ export fn zigmark_render_html(doc: ?*OpaqueDoc) ?[*:0]u8 { return renderC(doc, HTMLRenderer); } +/// Render the document to HTML in *safe* mode: raw and inline HTML from the +/// input is escaped to visible text. URL-scheme filtering (blocking +/// `javascript:`/`vbscript:`/`file:`/non-image `data:`) applies to both this +/// and `zigmark_render_html`. Use this for untrusted input. +export fn zigmark_render_html_safe(doc: ?*OpaqueDoc) ?[*:0]u8 { + const w = doc orelse return null; + const buf = html.renderWithOptions(w.allocator, w.doc, .{ .safe = true }) catch return null; + const c_str = w.allocator.realloc(buf, buf.len + 1) catch { + w.allocator.free(buf); + return null; + }; + c_str[buf.len] = 0; + return c_str[0..buf.len :0]; +} + /// Render the document to a human-readable AST tree diagram. export fn zigmark_render_ast(doc: ?*OpaqueDoc) ?[*:0]u8 { return renderC(doc, ASTRenderer); diff --git a/src/spec_runner.zig b/src/spec_runner.zig index cb78419..d237b77 100644 --- a/src/spec_runner.zig +++ b/src/spec_runner.zig @@ -185,6 +185,8 @@ fn printResultsTable( if (t == 0) continue; const ms: f64 = @as(f64, @floatFromInt(s.result.time_ns)) / @as(f64, @floatFromInt(std.time.ns_per_ms)); + // Aligned bindings are intentional + // zig fmt: off const pass_s = try std.fmt.allocPrint(allocator, "{d}", .{s.result.passed}); defer allocator.free(pass_s); const fail_s = try std.fmt.allocPrint(allocator, "{d}", .{s.result.failed}); @@ -193,6 +195,7 @@ fn printResultsTable( defer allocator.free(total_s); const ms_s = try std.fmt.allocPrint(allocator, "{d:.2}", .{ms}); defer allocator.free(ms_s); + // zig fmt: on const row = try zigmark.AST.TableRow.fromStrings( allocator, @@ -204,6 +207,8 @@ fn printResultsTable( // TOTAL row (bold via inline Markdown in the cell text). { const total_ms: f64 = @as(f64, @floatFromInt(total_time_ns)) / @as(f64, @floatFromInt(std.time.ns_per_ms)); + // Aligned bindings are intentional + // zig fmt: off const pass_s = try std.fmt.allocPrint(allocator, "{d}", .{all.passed}); defer allocator.free(pass_s); const fail_s = try std.fmt.allocPrint(allocator, "{d}", .{all.failed}); @@ -212,6 +217,7 @@ fn printResultsTable( defer allocator.free(total_s); const ms_s = try std.fmt.allocPrint(allocator, "{d:.2}", .{total_ms}); defer allocator.free(ms_s); + // zig fmt: on const row = try zigmark.AST.TableRow.fromStrings( allocator,