Guard Markdown

CommonMark validator + sanitizer for user-supplied markdown. Refuses raw HTML by default, applies a URL-scheme allowlist on inline links / autolinks / images / reference defs, and caps image dimensions and structural depth to defang renderer DoS. KIND="content" — the gate consumes ctx.bytes / ctx.bodyText.

The primitive is a SOURCE-LEVEL gate: it inspects raw markdown text BEFORE any downstream renderer (marked / markdown-it / commonmark / remark / parsedown) sees it. Source-level discipline matters because the most dangerous shapes — __proto__ in JSON, in markdown — exploit specific parser internals; sanitizing on the post-parse tree is too late.

Threat catalog grounded in current CVE research: CVE-2026-30838 (CommonMark DisallowedRawHtml whitespace-tag bypass — / evades naive ", { profile: "strict" }); } catch (e) { e.code; // → "markdown.dangerous-tag" }

b.guardMarkdown.gate(opts?) #

stable0.7.16hipaapci-dssgdprsoc2
{
  name:                   string,    // gate label for audit / observability
  profile:                "strict"|"balanced"|"permissive",
  compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
  ...:                    same shape as b.guardMarkdown.validate opts,
}

Build a guard gate whose async check(ctx) returns { ok, action, issues }, consumable by b.guardAll, b.staticServe, b.fileUpload, and any host that ingests user-supplied markdown. The gate decodes ctx.bytes / ctx.bodyText, runs validate, and maps severity to action: zero issues serve; only low/medium audit-only; sanitizable issues sanitize (returning the cleaned bytes); any unfixable critical refuse.

var g = b.guardMarkdown.gate({ profile: "strict" });
var rv = await g.check({ bytes: Buffer.from("# hello\n", "utf8") });
rv.action;                                         // → "serve"

var bad = await g.check({ bytes: Buffer.from("[x](javascript:1)", "utf8") });
bad.action;                                        // → "refuse"

b.guardMarkdown.render(source, opts?) #

stable0.18.44hipaapci-dssgdprsoc2
{
  profile:            "strict"|"balanced"|"permissive",
  compliancePosture:  "hipaa"|"pci-dss"|"gdpr"|"soc2",
  ...:                same shape as b.guardMarkdown.validate opts,
}

Render Markdown to an HTML fragment, escaping by default.

Every text node leaves through the shared markup escaper, every link target is screened before it can become an href, and raw HTML is emitted as escaped text rather than passed through. Those are the three things a hand-rolled emitter gets wrong, and each of them is a stored-XSS hole wherever author-supplied prose is shown to a visitor.

The subset is deliberate: paragraphs, ATX headings, bullet and ordered lists, fenced and indented code, blockquotes, thematic breaks, emphasis, strong, code spans and links. Anything outside it - images, tables, reference links, footnotes, raw HTML - renders as escaped text. That is a display limitation by choice: an unrecognized construct that shows its own source is a formatting bug, while one that becomes markup is a vulnerability.

Link targets are limited to http, https, mailto and relative references. A target carrying any other scheme, an attribute-breaking character, or a control character is refused - the link's TEXT is still rendered, so a refusal never silently deletes the author's words. Anchors carry rel="nofollow noopener noreferrer".

BIDI, zero-width, C0-control and NUL characters are stripped before parsing regardless of profile. Unlike validation, where an operator may want to be told about them and decide, an invisible character reaching rendered HTML is never what the author meant.

The output is a fragment, not a document: no wrapper element, no doctype. It is meant to be inserted into a page whose own Content-Security-Policy is doing its job, not to replace one.

What the profile changes is worth stating, because the two halves differ. The SAFETY floor is profile-independent — escaping, the link-target allowlist and raw-HTML-as-text are identical at every profile, since there is no safe way to loosen them. What varies is the SIZE budget, enforced before anything is parsed: maxBytes (1 MiB / 8 MiB / 64 MiB, measured in BYTES so a non-ASCII document is not silently allowed several times the stated size), maxLines (4,096 / 32,768 / 262,144) and maxBlockquoteDepth (16 / 64 / 256). Choose the profile for the document sizes you intend to accept, not for how much escaping you want.

A maxBlockquoteDepth raised past 512 is bounded at 512, because the renderer recurses once per level and what the call stack survives is not an operator setting. Nesting beyond the effective bound is refused with markdown/blockquote-depth, which is a verdict a caller can handle, rather than the stack overflow it would otherwise become.

The size budget refuses before anything is parsed: markdown/too-large past maxBytes and markdown/too-many-lines past maxLines. A source that is not a string refuses with markdown/bad-input.

Two further budgets refuse during the render rather than before it: markdown/too-many-delimiters when the source carries more bracket delimiters than the inline index will hold, and markdown/output-amplification when the rendered output would exceed its multiple of the source size.

b.guardMarkdown.render("# Title\n\nA [link](https://example.com).");
// -> "

Title

\n

A link.

\n" b.guardMarkdown.render("[x](javascript:alert(1))"); // -> "

x

\n" (the target is refused, the text survives)

b.guardMarkdown.compliancePosture(name) #

stable0.7.16hipaapci-dssgdprsoc2

Look up a compliance-posture overlay by name (one of "hipaa" / "pci-dss" / "gdpr" / "soc2"). Returns a fresh clone of the posture overlay so the caller may mutate it freely without disturbing the shared table. Throws GuardMarkdownError with code "markdown.bad-posture" when the name is not one this guard maps. Wired by gateContract.defineGuard through gateContract.lookupCompliancePosture, so the clone semantics and error code are identical across every guard in the family.

var posture = b.guardMarkdown.compliancePosture("hipaa");
posture;                                             // → overlay clone (mutable)

try {
  b.guardMarkdown.compliancePosture("not-a-regime");
} catch (e) {
  e.code;                                            // → "markdown.bad-posture"
}

b.guardMarkdown.buildProfile(opts) #

stable0.7.16
{
  extends:   string|string[],   // base profile name(s) to compose
  ...:       any guard key,      // inline override of resolved keys
}

Compose a derived profile from one or more named bases plus inline overrides, resolving names through this guard's own PROFILES table. opts.extends is a base profile name ("strict" / "balanced" / "permissive") or an array of names — later entries shadow earlier ones, and inline opts keys win last. Wired by gateContract.defineGuard through gateContract.makeProfileBuilder, so operator-defined profiles stay traceable to a baseline instead of a hand-typed dictionary.

var custom = b.guardMarkdown.buildProfile({ extends: "strict" });
custom;                                              // → composed profile object

b.guardMarkdown.loadRulePack(pack) #

stable0.7.16

Register an operator-supplied rule pack with this guard's rule-pack registry. The pack is identified by pack.id (a non-empty string) and stored for later dispatch by gates that opt in via opts.rulePackId. Returns the pack unchanged on success; throws GuardMarkdownError with code "markdown.bad-opt" when pack is missing or pack.id is not a non-empty string. Wired by gateContract.defineGuard through gateContract.makeRulePackLoader, so storage shape and validation are identical across the family.

var pack = b.guardMarkdown.loadRulePack({ id: "tenant-policy", rules: [] });
pack.id;                                             // → "tenant-policy"

b.guardMarkdown.resolveOpts(opts?) #

stable0.7.16
{
  profile:           string,    // one of PROFILES; default this guard's default
  compliancePosture: string,    // overlay one of hipaa/pci-dss/gdpr/soc2
}

Resolve caller opts against this guard's PROFILES + compliance-posture overlays into the fully-defaulted option set the guard runs on — the same resolution validate / sanitize / gate apply internally. Wired by gateContract.defineGuard from the guard's binding config (profiles / postures / defaults / error class), so a guard's bespoke gate calls resolveOpts instead of re-declaring the per-guard resolver wrapper. Throws GuardMarkdownError with code "markdown.bad-opt" / "markdown.bad-posture" on an unknown profile or posture name.

var resolved = b.guardMarkdown.resolveOpts({ profile: "strict" });
resolved.profile;                                    // → "strict"

Last updated 2026-10-12T14:21:56.879Z by seeder.