What a public deployment exposes, what it does not, and where the boundaries are.
On the public origin: Apache (or nginx), the PHP runtime, one entry point of
under ninety lines and the renderer, router, listing, page shell and Bundle
writer in site/src/ — about 3,200 lines in all. That is the whole of what
runs there —
there is no other code, and none of it is somebody else's. One file the origin
serves is somebody else's: mermaid.min.js, which the server only hands
over, and which runs in the browser of a reader whose page has a Diagram and
nowhere else (see The one script on the page).
No database. No login, session, cookie or token. No form, no upload, no POST
route — the site handles GET and has no code path that writes anything: a
page and a Bundle's ZIP alike go to the response and nowhere else. No
third-party library in the PHP: no Parsedown, no framework, no composer
dependency. One third-party file on the origin with a CVE feed to track —
Mermaid, vendored and pinned, which only a page with a Diagram loads (see
The one script on the page). No CDN request, no external font. The one inline script on a
published page adds a copy button, a Palette toggle and a text-size control,
and the Full View on a page with a picture; it reads and writes two
localStorage keys and touches nothing else, and it runs under a per-request
nonce rather than unsafe-inline.
The editor has no server component at all, so it has no endpoints to attack. It
cannot write to content/, which means a compromise of the web tier cannot
alter published content — publishing requires credentials to the transport
(SSH, git), which the software never handles.
The editor never sends a document anywhere: it holds one in the browser tab and
emits it by download. What it fetches is two things, and the document is in
neither. Once a document holds a Diagram, the page loads its own copy of
Mermaid — mermaid.min.js, the file beside ui.js, from wherever the editor
itself was opened — to draw it in the read pane; a document without a Diagram
never asks for it. The other is a request on the author's behalf. Previewing
an image line shows the actual picture, so the browser fetches the file that
line names, resolved as the site resolves it: an absolute local path as it
stands, and anything else — an https:// src included — reduced to a file
name in the Document's Media, ../site/public/media/<category>/<name>/. Both
are resolved against wherever the editor page itself was opened from — for a
page opened off the filesystem, that is the filesystem — so no Image Line makes
the preview contact another host, and the page names no-referrer besides. An
image that does not load is looked for once more without a trailing language
in the name, and then falls back to the box with the file name in it,
silently. The document is no part of any of this and still goes nowhere.
That is checked, not asserted. The source section of bin/test — the one that
scans the site's PHP for the calls that reach the system — reads
editor/editor.js and editor/ui.js for the ways a page opens a connection:
fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon, a dynamic
import, the constructors that open one and window.open. The editor names
none of them, and a change that added one would fail the suite naming the file
and the call. The scan says the editor opens no connection of its own; the
picture and the library that load are the browser acting on the <img> and
the <script> the editor wrote, which no scan of the source can see and the
paragraph above is what says. The vendored mermaid.min.js is not scanned: it
is third-party code, unchanged, and a scan of it would report what the library
contains rather than what the editor does. What it may do is bounded by
securityLevel: 'strict' and by the policy below. The same section checks
that the page names no-referrer.
The editor's page also names a policy, in a <meta>, so that markup reaching
its DOM by an escaping miss cannot run:
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self';
img-src 'self' https: http: data:; connect-src 'none';
base-uri 'none'; form-action 'none'; object-src 'none'">
Script runs only from the editor's own files beside the page — 'self',
which matches a page opened from the filesystem as well as one served — and
nothing inline: not an <script> element, not an on…= attribute. The page
carries none. The starter document sits in an inert
<script type="text/markdown"> that ui.js reads, and an image that does not
load is swapped for its box by a listener ui.js attaches. Stylesheets come
from files too — the ones the page links — and never from a <style> element
or a style attribute. A Diagram's stylesheet is a constructed
CSSStyleSheet the document adopts, which no style-src governs, and
Mermaid's per-element styles are written back through the CSSOM, as on a
published page. connect-src 'none' makes the browser refuse what the source
scan says the editor never does.
What it is not:
- Not a nonce. A
file://page has no request to make one for, and a nonce the page printed is one injected markup could copy. The policy names no nonce and no hash. - Not a fence around the editor's own files.
'self'on a page opened from the filesystem matches any local file, in Chromium and in Firefox, so markup injected into the page could link a stylesheet already on the disk and the browser would apply it. A script it could not run that way: the editor writes markup throughinnerHTML, and a browser never runs a<script>written so, whatever file it names. On an editor served over HTTP,'self'is that origin. - The same on every copy. It is part of
editor/index.html, not made per load; anyone can read it, and nothing about it is secret. - Not a stop on the image request.
img-srcallows anyhttp:,https:ordata:src. An Image Line's src never reaches another origin — the read pane reduces it as the site does — but markup injected into the page could make that kind of request. - Not a stop on leaving the page. No directive governs where the tab goes.
The editor's own script keeps the two ordinary ways out from taking the
document: a link clicked in the preview, the page's or a Diagram's, opens in
a tab of its own, and a drop that is not a file is refused
(ADR-0021). A
<meta http-equiv="refresh">that reached the page as markup would still take the tab, and the document with it. - Not quiet. Mermaid measures each drawing in a scratch element with inline
styles, which the policy refuses; Chromium reports each one as a
style-src-attrviolation. The picture is right because the placement carries the styles.
bin/test fails if the <meta> is removed, or moved out of <head> or below
anything the page loads — a browser obeys it nowhere else — if any directive
names 'unsafe-inline', 'unsafe-eval', 'unsafe-hashes', a nonce or a hash,
if connect-src is anything but 'none', or if the page or the read pane's
markup carries an inline script or an event-handler attribute. It fails too
if the editor's own scripts write a style attribute or make a <style>
element, or if any stylesheet either half ships holds a url(), an @import
or an @font-face: a stylesheet that asked for something would be a request
the paragraphs above do not name. The browser probe runs under the same
policy, since it is the same page. It fails if the policy refused any script
or stylesheet during the run, and it fails if the policy does not refuse the
one thing the probe asks it to: a handler written into the page as markup,
which must not run
(ADR-0020).
Renderer.php never passes file bytes into its output. It matches a line to a
known type and emits tags it chose itself, with the text escaped by the single
e() helper. Raw HTML in a document is not sanitised, it is unrepresentable
— there is no code path from file content to unescaped markup
(ADR-0003).
Link targets are rebuilt from an allowlist: http://, https://, mailto: to
an address, a local absolute path, a fragment. Everything else renders as plain
text — javascript: and data: because they are not on the list, and four
shapes that look local and are not:
| written | why it is not a link |
|---|---|
//evil.example |
a protocol-relative URL: another origin in disguise |
/../secret |
a path that climbs, in a link that cannot |
/a\b |
a backslash is a path separator to some clients |
//evil.example |
the first one, written as entities |
The last row is why the target is decoded before it is judged and escaped again
before it is printed: inline() escapes the whole line before it looks for
links, so the five entities e() produces — and any numeric reference below
128, which is every character a scheme, a slash and a colon are made of — come
back off first. One pass, so &#47; stays the text /.
editor/editor.js holds the same allowlist. The editor previews a document by
rendering it, so a link the preview shows and the site drops would mean the
preview is not what the site sends. bin/test renders one list of targets
through both halves and fails if a single byte differs.
The footer in site.php is the only string outside content/ that reaches
the page as writing, and it takes the same path: Renderer::inline() escapes it
and allowlists its links exactly as it does a paragraph's. There is one inline
renderer on the origin, not two.
bin/test asserts this with a document full of <script>, onerror= and
javascript: payloads, and with a footer carrying the same, and fails if any of
it survives.
Two headings with the same words get two ids, so an in-page link cannot be made
ambiguous by a document repeating itself. An img path is either a local
absolute path or a name in its Document's directory under /media/; a
protocol-relative URL, a backslash, a control character and a path trying to
climb out of the document root are all reduced to the file they name. The logo
and the favicon in site.php are two more paths that reach the page, and they
take the same reduction before they are escaped into the page shell.
The request string never becomes a filesystem path. The category must be
identical to one declared in site.php, and so must a Sub-category, among
the ones its category declares; the slug is compared for equality against
real filenames from scandir(). An address has at most three segments, and
only the first two can name a directory, each by being one site.php already
named. Path traversal has no expression in the code rather than being
filtered out of it (ADR-0004).
Only the category and the Sub-category are checked for shape, and they are
checked where they are read: TerminalCms\Site drops a declared category
that is not one segment of a URL, at either level, so a malformed site.php
entry is never routed, never navigated to and never turned into a path. The
slug gets no shape rule, deliberately — comparing it with the real file names
is the boundary, and a rule on top of it would only make a document whose
file is called Release-1.2.md unreachable while its own listing still
linked to it.
bin/test covers ../, percent-encoded ../, unknown categories and
Sub-categories, over-deep paths, malformed category entries, and the request
lines (//, ///) that parse_url cannot read as a path at all. A category
declared in site.php with no directory behind it is an empty listing, not a
warning printed into the page.
A document whose Meta has a published line that is not true, and a
category whose declaration has a published that is not true, is not
served: its address is a 404, and no
listing, navigation, language indicator, Bundle or Page Build names it. The
setting fails closed — a misspelt value hides — and is asked in two places
that every reader of content/ goes through: Site drops a category that is
not Published as it drops a malformed one, and the listing drops a file that
is not (ADR-0023).
It keeps a document off the site and is not a secret. The file is on the server, in the repository and in its history, and anyone who can read those reads it. Do not put in a draft what must not be read. Its pictures are not kept back at all: a document's Media is under the document root, and the web server hands a file there to whoever names it.
A page's address with .zip on it answers the page's Bundle: the document or
the category as files, with the Editor
(ADR-0025). The suffix is
taken off first and the rest is routed exactly as the page is, so a Bundle
exists only where a page does, and a .zip address reaches no file a page
address could not.
What a Bundle reads is what the site could already show. Every path is a
category path from site.php or a name scandir() gave, under three
directories: content/, public/media/ and site/editor/. It carries no
document that is not Published and none whose Meta says bundle: false.
It follows no symbolic link inside the three: a document that is a link, or
is in a directory a link leads to, is left out with its pictures, and so are
pictures a link leads to. content/ or media/ may itself be a link, and is
read where it leads. It takes no dot file. site.php and
src/ are in none. A reader of a Bundle gets the markdown source, where a
reader of the page gets the HTML — the same writing, and a Meta line the
page does not print is in the file.
A document's pictures are its directory under media/ and everything in it,
with one exception. When a directory of the document's own name is beside it
under content/ — notes/wip.md next to notes/wip/ — the directories
inside its Media belong to that directory's documents, which site.php may
not publish or not declare. The Bundle then takes the files and none of the
directories.
The ZIP is written to the response as its files are read. Nothing is written
to disk and no extension is used; the response carries the same headers and
the same policy as a page. site/editor/ is a copy of the Editor above the
document root, read for Bundles and served at no address. 'bundle' => false
in site.php makes a .zip address a 404, but for a document whose own Meta
says bundle: true.
site/migrate changes an instance's own files when a release needs them in a
new shape (ADR-0027). It is
not part of what answers a request. It sits in site/, above the document
root, and refuses to run under any SAPI but the command line, so no address
runs it. The operator runs it, with their own permissions: the web user still
needs to write nowhere.
It copies a file to a path that does not exist and inserts commented-out lines
into site.php, and can do nothing else: it removes nothing and overwrites
nothing, and a bare run only reports. A copy lands inside the directory its
source and its target share — public/media/, for every copy 0.3.0 makes —
once every symbolic link on the way is followed, or the whole run is refused
before anything is written. A site.php that is a link is inserted into
where it leads. It finds the keys of site.php by
reading the file as text; the 0.3.0 Migration also runs site.php, as every
request does, to learn the instance's languages.
Each page carries about fifty lines of inline JavaScript that add a copy button
to code blocks, a Palette toggle and a text-size control. The toggle sets
data-palette on <html> to light or dark and keeps the reader's choice;
until there is one, the page opens in whatever data-palette it was served
with, or with none in the browser's preference. They fetch nothing, store two
strings in localStorage, and are inline so there is no third-party origin to
trust. Every
page is complete and readable with it blocked — no content depends on it. Delete
the enhancement() method in site/src/Page.php if you want a page with zero
script.
A page with a Diagram — a code block in the mermaid Dialect — gets about a
hundred more lines in the same script, and that is the only page that does.
They find each Diagram, add one <script src="/mermaid.min.js"> for the copy
of Mermaid served from the site's own origin, and draw each Diagram over its
code block with securityLevel: 'strict'. The added <script> carries the
nonce, read from document.currentScript.nonce of the running script — the
nonce is never written into the script's text, and script-src names nothing
new. Mermaid's picture arrives with a <style> element and style attributes,
which the policy refuses, so the script places it by hand: the stylesheet into
one <style> carrying the nonce, each style attribute through the element's
CSSOM a declaration at a time, which the policy permits. Firefox drops the
value of a style attribute the policy refuses as it is set, so while Mermaid
draws, the script has its style attributes written under another name and
reads them back from there. A source that does not parse keeps its code
block and the page prints nothing. The Palette toggle draws every Diagram again,
and a Diagram's copy button copies its source. With scripts blocked, the Diagram
is the code block it always was.
Drawing leaves style-src reports in the browser's console: Mermaid measures its picture in a scratch element with inline styles, and parsing its output does the same in an inert document, and the policy blocks both. Each report is the boundary holding. The picture is right because placement carries the styles.
A page with an Image or a Diagram also gets the Full View, about a hundred and
ninety lines more: a button on each picture that opens it alone in a <dialog>,
from which it can be saved. The saves ask for nothing the policy governs.
An Image is saved from its own address, the one its <img> already loaded, through an <a download> the script clicks. A Diagram is saved as a
Blob made from the page's own content, either the SVG on the screen or the
source text the page already holds, and the Blob URL is revoked as soon as
the click has it. A download is not a fetch the policy governs. img-src is
still 'self', and the saved SVG never goes back into the page: it is a copy
outside the document, serialised with its stylesheet as a plain <style>, and
the nonce is taken off it, because the nonce belongs to this response and
nobody else should have it. Opening a Diagram moves the drawing into the
dialog and back again rather than copying it, so the styles placement set
through the CSSOM go with it, and the Full View adds no console report of its
own.
A saved SVG is a file, and a file is opened under no policy. What the page's
policy refused the drawing would be asked for the moment the file was opened:
a picture a label names on another host, a url() in the CSS the Diagram
carries. So the copy is written without it. The elements that run or load
something are removed — script, iframe, object, embed, video,
audio, form, an animation and the like — with every on… attribute and
every attribute that names an address, except a link's http, https or
mailto Href. The stylesheet and each element's own declarations are read
back from the browser's parser, and a declaration or a presentation attribute
whose value holds a url() that is not a #… reference into the drawing, or
an image-set(), is dropped. What is
left draws the picture the reader was looking at, since the page never loaded
any of the rest.
The copy is Mermaid 11.17.2, the npm package's own dist/mermaid.min.js
with one line added at the top that names the version, the licence beside it in
shared/mermaid.LICENSE, and the SHA-256 of everything below that line.
bin/test checks that the hash is the file's, and that the version named here
is the one the file names — so upgrading the library is replacing one file,
running php bin/build, and correcting this sentence. It is the one file on
the origin with a CVE feed to watch; the reviews in
security-audit.md record what was open at each release.
A page with neither gets the script byte for byte as it was: nothing new is
sent, nothing is fetched, and bin/test holds the shell of a page without a
picture to a fixed hash.
index.php sends:
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policy: default-src 'none'; img-src 'self';
style-src 'self' 'nonce-<per request>';
script-src 'nonce-<per request>'; base-uri 'none';
form-action 'none'; frame-ancestors 'none'
A page has one inline point, the enhancement script, and it names a nonce
instead of the policy naming unsafe-inline. Sixteen random bytes per request,
so the value a page carries is no use to the next one. Nothing else on the page
may run: no inline handler, no style attribute, and no <script src> but the
one the enhancement script adds on a page with a Diagram, which carries the
nonce. The page as served has no <style> element at all; the nonce on
style-src is for the one stylesheet a Diagram places, which the script makes
with the nonce on it.
No config value reaches the page as CSS. The Theme and the Palette arrive as a
name and a word, each one of a fixed set or not used: a Theme is a name with a
stylesheet in site/public/themes/, linked like the others, and a Palette is
light or dark.
The policy is written once, in site/src/Policy.php. The header above and the
<meta> a built page carries are both read from it, and bin/test fails if
the directives are spelled anywhere else in the code.
To go further: delete enhancement() in site/src/Page.php and send
script-src 'none'.
HSTS and TLS belong in the vhost, not here.
A Page Build (ADR-0017)
is served by a host that runs nothing and sends no header the site chooses.
Every built page therefore names its policy itself, in a <meta> that is the
first element of its <head>, ahead of everything it governs:
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; img-src 'self';
style-src 'self' 'nonce-<per build>';
script-src 'nonce-<per build>'; base-uri 'none';
form-action 'none'">
It is the header's policy, directive for directive, but for one. A browser
ignores frame-ancestors in a <meta>, so the build leaves it out, and a
built page can be framed by another site. It has no form, no login and nothing
to click that acts on a reader's behalf, so a framing page has nothing to trick
a reader into doing. A host that can send headers can send frame-ancestors 'none' itself. The three other headers above are the host's to send or not:
the build cannot carry them.
The nonce is sixteen random bytes, made once per build. Every page of one build carries the same value, and every reader gets the same value until the next build. Anyone who reads a page can see it. That is enough there. A nonce stops markup that someone else put into a page from running. The per-request value matters where a response can reflect what an attacker sent. A built page reflects nothing: it is a file, written before any request. The only thing that puts markup into it is the build, from the content, and the Renderer cannot express raw HTML. Someone who can change what the build reads can already change the page. The nonce does not stand between them.
A build leaves out what is not Published and writes each offered Bundle beside its page, the same bytes the PHP site answers at that address.
The inline script, the Mermaid script and a Diagram's placed stylesheet carry
the build's nonce exactly as they carry a request's. bin/test builds the
sample content and checks three things. Every page's first <head> element is
the policy. The policy's nonce is the one on the page's script. Every built page is the page the PHP site serves for
the same address, but for the Base Path, the trailing slash and where the
policy is named.
PHP and the web server. There is no application dependency to update but one: the vendored Mermaid, whose advisories are the one feed to watch, and whose upgrade is the file replacement The one script on the page describes.
Two PHP settings belong in the pool or the vhost: display_errors off, so a
warning is never reconnaissance, and expose_php off, so the version is not in
a header.
- Giving the editor a save endpoint — authentication, CSRF and path validation come back with it, which is exactly what ADR-0002 rejected.
- Making any directory writable by the web user.
- Serving
content/directly, or moving the document root abovepublic/. - Adding a markdown library to "support more syntax" — it would become the single largest piece of untrusted code on the origin.
security-audit.md records each review: its scope, its
findings, and what changed. A reviewed surface is not a guaranteed one. The
requirements above — patched PHP, site/public as the document root, no
writable directory, TLS in the vhost — are part of this model, not additions to
it.
Open an issue on the repository. There is no separate security contact and no embargo process.