Security
Treat Markdown as untrusted input. Alto makes HTML safe by default, but the host application still owns request limits, path authorization, and trusted extension code.
Report a suspected library vulnerability privately through this repository's Report a vulnerability button, or see the org-wide security policy. Do not include exploit details in a public issue.
Start with the safe default
The default policy:
- escapes raw HTML;
- filters unsafe link and image schemes;
- allows
http,https,mailto,tel, relative paths, and fragments; - never fetches links or images.
<?php
use Alto\Markdown\Markdown;
$markdown = "Click [here](javascript:alert(1)).\n\n<script>alert(1)</script>\n";
$html = Markdown::github()->toHtml($markdown);The result keeps the text but removes the unsafe destination and escapes the script:
<p>Click <a href="">here</a>.</p>
<script>alert(1)</script>
Generated text and attributes are escaped for their HTML context. No option is needed for this behavior.
Installed extensions are trusted PHP code. A custom HTML renderer returns
markup, so HtmlPolicy::safe() does not escape that complete return value.
Extension renderers must pass every dynamic value through the output context's
escapeText(), escapeAttribute(), or escapeUrl() helper. If extension
output also needs an independent boundary, use HtmlPolicy::curated(): its
final fragment sanitizer covers both core and extension HTML. The spec policy
passes extension HTML through unchanged.
The built-in default-attributes extension follows the same trust boundary.
It escapes values and filters configured href and src URLs, but permits
application-defined style, event, and other URL-bearing attributes.
HtmlPolicy::curated() removes unsupported and event attributes from the
completed fragment.
Author-controlled attributes use a narrower boundary.
AttributesExtension accepts only names listed in AttributesPolicy, whose
default is id and class. Event attributes can never be enabled. Source
href and src values use the active URL policy, all values are escaped, and
parser limits bound list size, value size, and attribute count. The curated
policy remains the final authority and may remove an otherwise allowed source
attribute.
Preserve HTML deliberately
| Policy | Authored HTML | URL filtering | Use |
|---|---|---|---|
HtmlPolicy::safe() |
Escaped | Yes | Default for untrusted input |
HtmlPolicy::curated() |
Sanitized allowlist | Yes | Preserve common document HTML |
HtmlPolicy::spec() |
Passed through | No | Trusted input and conformance only |
The curated policy removes scripts, event handlers, SVG, MathML, unsafe URLs, and unsupported attributes after the complete Markdown fragment is rendered:
<?php
use Alto\Markdown\Markdown;
use Alto\Markdown\Render\HtmlPolicy;
use Alto\Markdown\Render\RenderOptions;
$markdown = '<h1 onclick="alert(1)">Title</h1><script>alert(1)</script>';
$options = new RenderOptions(htmlPolicy: HtmlPolicy::curated());
$html = Markdown::github()->toHtml($markdown, renderOptions: $options);The result is <h1>Title</h1>. Curated rendering requires PHP's DOM extension
and never changes the Markdown source.
For a stricter policy, keep safe URL filtering and strip raw HTML:
<?php
use Alto\Markdown\Render\HtmlPolicy;
use Alto\Markdown\Render\RawHtmlPolicy;
$policy = HtmlPolicy::safe()
->withAllowedSchemes('https')
->withRawHtml(RawHtmlPolicy::Strip);Applications may provide an audited full-fragment HtmlSanitizer.
HtmlPolicy::spec() is not a sanitizer and must not be used for untrusted
input.
Generated tables of contents follow the same boundary. The safe default keeps
their <nav> wrapper and heading IDs. The curated policy keeps the list
content but removes the wrapper and target IDs, so those fragment links are
not interactive. Preserve them only with an audited application sanitizer, or
with the spec policy when all input and extensions are trusted.
Generated footnotes use numeric same-document fragment links. The curated
policy keeps only their specific IDs, roles, and footnotes class. Footnote
definitions still use the active Markdown and raw HTML policy, so enabling the
extension does not grant authored HTML any additional permission.
Generated tabs follow the same rule. Titles are always escaped text, panel bodies use the active Markdown and raw HTML policy, and no script is emitted. The curated policy preserves only Alto's deterministic tab IDs, fragment links, and CSS classes. All panels remain present and readable without client-side code.
Front matter decoders are explicit trusted application code. Parsing,
rendering, querying, and saving never invoke them automatically. decode()
passes opaque content bytes and the fence to the supplied decoder, propagates
its result or exception, and does not change the document. Apply the selected
YAML or TOML library's own limits and safe-loading options inside that decoder.
Bound parsing and file access
Parsing limits are explicit:
<?php
use Alto\Markdown\Markdown;
use Alto\Markdown\Operation\SaveOptions;
use Alto\Markdown\Operation\SymlinkPolicy;
use Alto\Markdown\Parser\ParseOptions;
$options = new ParseOptions(
maxNestingDepth: 64,
maxSourceBytes: 1_000_000,
maxBlockCount: 10_000,
maxInlineCount: 50_000,
maxReferenceCount: 1_000,
);
$document = Markdown::github()->fromString("# Bounded\n", $options);
$file = Markdown::github()->open('content/security-policy.md');
$file->section('Security')->append("\nSecurity review completed.\n");
$file->save(new SaveOptions(
compareBeforeWrite: true,
symlinks: SymlinkPolicy::Reject,
));Nesting defaults to 256 simultaneously open blocks. The other limits are
unbounded until configured. All limit failures extend ParseLimitException;
the precise subclasses report source, block, inline, or reference exhaustion.
Validate the correct boundary
Alto has no generic validate() method. Markdown is a permissive language, and
one boolean result would combine unrelated questions:
- use
ParseOptionsand catchParseLimitExceptionfor resource acceptance; - use
HtmlPolicyfor output safety; - use
lint()for project content rules; - construct factories during startup to validate trusted extension contracts;
- use save options and typed file exceptions for persistence conflicts.
Each operation already performs the checks required by its boundary. A separate validator would parse or render the same input again without making the later operation safe. Validate application-specific authorization before calling Alto, then handle the typed failure from the operation that consumes the input.
The application must still:
- reject oversized requests before PHP work begins;
- set appropriate memory and execution limits;
- authorize paths before
open()andsaveAs(); - validate canonical parent directories, including intermediate symlinks;
- use
SaveOptions(compareBeforeWrite: true)when another writer may change a file.
Custom extensions that read source or include files should receive a
FilesystemResourceResolver instead of joining paths themselves. It confines
relative references to one canonical root, applies an extension allowlist and
byte limit, rejects every symbolic-link component, and verifies the opened
file's type, device, and inode before returning bytes. Opaque resource IDs may
anchor later relative requests, but they are not capabilities and do not
authorize access.
Filesystem validation is best effort under PHP. A path can still change
between checks and fopen(), and the resolver does not promise immutable
contents while a file is being read. Use an application resolver backed by an
OS sandbox or a stronger file-opening primitive when another hostile process
can mutate the resource tree.
ImportExtension, SourceExtension, IncludeExtension, and EmbedExtension
perform their explicit resource reads while parsing. Enable them only with an
authorized resolver and expected input, because toHtml() and fromString()
may then perform I/O. Imports and source displays render returned bytes as
escaped code. Includes intentionally parse returned bytes as Markdown under
the current profile and HTML policy.
IncludePolicy adds per-tree depth, resource-count, aggregate-byte, block,
inline, reference, and nesting limits. Cycle detection uses stable resolved
resource IDs, not author-controlled references. Root-only syntax prevents a
directive hidden inside another container from opening a new resolution
context. Resolver failures and include limit failures remain typed exceptions;
Alto does not hide them behind successful placeholder output.
EmbedPolicy requires an explicit host allowlist, matches at DNS label
boundaries, defaults to HTTPS, refuses userinfo and non-default ports, and
bounds both URL and resolved HTML bytes. A disallowed URL never reaches the
resolver. The default safe HTML policy renders a fallback link rather than
resolver-provided HTML.
The resolver still owns the network boundary. It must restrict redirects, private and link-local addresses, DNS rebinding, response sizes, content types, timeouts, and credentials. Resolved embed HTML is trusted application data. Emit it only behind a final sanitizer designed for the allowed providers and a restrictive Content Security Policy. Alto performs no network request itself.
The final path component rejects symlinks by default. Explicit
SymlinkPolicy::Follow resolves the target before conflict checking and
writing, but cannot remove filesystem time-of-check/time-of-use races. Atomic
save and compare-before-write are not a cross-process lock. File reads and
writes reject direct directories, FIFOs, sockets, and devices. Relative paths
are anchored when opened or adopted by saveAs(), so a later
working-directory change cannot redirect a save.
On POSIX, atomic replacement preserves permission and special mode bits or
fails before replacement. It does not preserve ACLs, ownership, extended
attributes, or other platform metadata. New files use 0666 & ~umask.
Windows replacement and mode semantics are platform-limited and may fail when
the destination exists or is open. See Exceptions for recovery and
Editing for the complete save flow.