Skip to content

Latest commit

 

History

History
469 lines (399 loc) · 25.7 KB

File metadata and controls

469 lines (399 loc) · 25.7 KB

Security model

What a public deployment exposes, what it does not, and where the boundaries are.

What is reachable from the internet

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).

What does not exist

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 through innerHTML, 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-src allows any http:, https: or data: 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-attr violation. 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).

The renderer

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
&#47;&#47;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 &amp;#47; stays the text &#47;.

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 router

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.

Published

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.

The Bundle

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.

The one program that writes

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.

The one script on the page

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.

Headers

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.

The built site

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.

What to keep patched

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.

What would reintroduce risk

  • 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 above public/.
  • Adding a markdown library to "support more syntax" — it would become the single largest piece of untrusted code on the origin.

What has been reviewed

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.

Reporting

Open an issue on the repository. There is no separate security contact and no embargo process.