Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ Usage: zigmark [OPTIONS] [FILE]
"ai", "terminal", "frontmatter", "markdown", or
"normalize".
-o, --output <str> 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 <str>... Set a frontmatter field (KEY=VALUE). Repeatable.
Applies to: markdown, normalize, frontmatter.
-d, --delete <str>... Delete a frontmatter field (dot-path). Repeatable.
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,30 @@
}
);

# `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";
buildPhase = "zig build test -Dversion=${version}";
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}";
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions include/zigmark.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading
Loading