Status: living document. Sections marked (planned) describe controls not yet implemented; they are recorded here so the architecture accommodates them.
HyMarkX is pre-release and has no published security contact yet. Until one exists,
report suspected vulnerabilities by opening a GitHub issue marked security: — with
the understanding that the project currently offers no security guarantees and
MUST NOT be used to render untrusted content in production.
Rendering an untrusted HMX document MUST NOT grant it code execution, filesystem access, network access, or the ability to raise its own privileges.
Everything below serves that sentence.
The trust level is a property of the host, never of the document. There is no
in-document construct that enables scripting. A document that could opt itself into
app mode would make the invariant unenforceable.
| Capability | Status |
|---|---|
<script>, <style> blocks |
rejected — HMX3001 |
inline event handlers (onclick=) |
rejected — HMX3002 |
| raw HTML | sanitized against an element/attribute allowlist |
| URL schemes | http, https, mailto, relative only — javascript:, data:, vbscript:, file: rejected (HMX3003) |
| imports / module resolution | disabled — HMX3004 |
| filesystem access | none |
| network access at build time | none |
| expressions | pure subset only, no host object access |
| output | deterministic; no ambient state observable |
Scripts, imports, and raw HTML are permitted. The host has asserted the content is
trusted. HMX still applies escaping in text positions and still refuses to construct
javascript: URLs from expression results.
Assets: the viewer's browser session, the build machine, the developer's secrets, and the integrity of generated output.
| # | Threat | Vector | Control |
|---|---|---|---|
| T1 | Stored XSS | untrusted document rendered on a site | document mode sanitization; escaping in text positions; security test suite |
| T2 | Scheme injection | [x](javascript:…), <img src=data:…> |
URL scheme allowlist, applied after expression evaluation, not before |
| T3 | Attribute injection | expression result breaking out of an attribute | context-aware escaping per attribute position |
| T4 | Privilege escalation | document tries to enable scripts | mode is host-only; no directive can change it; test asserts every escalation attempt fails |
| T5 | Build-time RCE | malicious component/plugin executes during hmx build |
no arbitrary build-time execution in 0.0.x; plugin API deferred until phases stabilize (ADR-0008) |
| T6 | Supply chain | compromised dependency | minimal dependency set, pinned lockfile, pnpm audit in CI, dependency rationale recorded in ADR-0003 |
| T7 | Secret leakage | env vars reaching client output | no env access in the expression language; server/client boundary specified before data features ship |
| T8 | Prototype pollution | attribute names like __proto__ |
attribute bags are null-prototype objects; __proto__/constructor/prototype keys rejected (HMX3005) |
| T9 | ReDoS / parser DoS | pathological nesting or backtracking input | micromark is linear-time by construction; nesting depth capped; whole-pipeline fuzzing in tests/fuzz/ |
| T10 | Path traversal | ../../etc/passwd in an import or asset path |
resolution confined to the project root; absolute and escaping paths rejected (HMX3006) |
| T11 | Entity-expansion DoS in frontmatter | a billion-laughs YAML document | maxAliasCount: 10; required test asserting prompt rejection with HMX2021 |
| T12 | Tag-driven object construction in frontmatter | YAML type tags that instantiate host objects | schema: 'core', customTags: [], merge: false, stringKeys: true |
| T13 | Prototype pollution via frontmatter keys | __proto__: in a mapping |
mappings rebuilt with Object.create(null); forbidden keys rejected with HMX3007 using the same list as directive attributes |
Not defended against yet, and documented as such rather than silently assumed:
timing side channels, denial of service by enormous inputs, malicious npm components
in app mode, and anything involving a server runtime (there isn't one).
tests/security/ is a required suite, not an optional one. Every control in the tables
above MUST have at least one test that fails if the control is removed. Every reported
vulnerability gains a regression test before its fix is merged.
Recorded rather than quietly patched, because the pattern is more useful than the individual bug.
Found: 2026-08-17, by the whole-pipeline fuzzer on its first run.
Reachable from: any untrusted document, with default options.
Impact: denial of service. parse() looped forever — not slowly, indefinitely.
:::card{ malformed
é
Any non-ASCII character on a later line would do: é, 中, 😀, a lone surrogate, a
combining mark. ASCII was fine, which is why every hand-written test missed it.
Cause, and it was ours. The directive-name guard rejects non-ASCII inside a directive
name, and stayed armed after the line ended. A non-ASCII character on a later line then
re-entered nok from a fresh tokenize call, and micromark-extension-gfm-table — whose
container continuation re-runs the line — turned that into an unbounded loop. Upstream
micromark-extension-directive was unaffected.
Fix: disarm the guard at the line ending, which SPEC.md §4.1 requires anyway since a
directive name cannot span lines. One condition; regression tests in
packages/parser/test/hang-regression.test.ts carry explicit timeouts because the failure
mode is a hang rather than a wrong answer.
Worth noting about the process: the fuzzer appeared to be "slow" for several minutes and was nearly written off as needing fewer cases. It was not slow. It had found the bug on the first pass, and the hang was mistaken for volume. With the fix in place the same suite runs 2,800 cases in four seconds.