/** * The note column: Markdown, sanitisation and wikilinks * (weave-workspace §1.2, §5.2, §10, P2.4). * * "Notes are the product" (§1.1), so this is the column the workspace is * actually for. It is also the only place in the client that turns content off * the disk into DOM, which makes it the only place with a genuine threat * model — so most of the length below is about that. * * ## The threat model, stated plainly * * The server is loopback-only and cookie-authenticated (§5.1), so this is not * about a remote attacker reaching the page. It is about *content*: a vault * note written by an agent, a `.okf` summary generated from a repository, or a * README in a repo someone cloned to look at. Any of those can contain * ``, and a workspace whose whole premise is "point it at * an unfamiliar repository and read the knowledge" cannot treat that as * hypothetical. Rendering untrusted-ish local content into the DOM is exactly * the case §0 V5 chose DOMPurify for. * * ## Three independent layers, in order * * Each would be sufficient against the obvious attacks; they are stacked * because "sufficient against the attacks I thought of" is the assumption that * fails. * * 1. **Raw HTML never becomes HTML.** {@link markdownRenderer} overrides * marked's `html` renderer to escape its input, so `` * in a note body reaches the DOM as the *text* ``. * marked removed its own `sanitize` option in v5 precisely in favour of * this shape — the renderer, not the parser, is where the decision belongs. * 2. **Unsafe URL schemes are dropped at render.** {@link safeUrl} allows a * short scheme allowlist and nothing else, so a `[a](javascript:alert(1))` * link renders with no `href` at all rather than with a stripped one. * 3. **DOMPurify is the final gate.** {@link SANITIZE_CONFIG} is an explicit * tag/attribute *allowlist* — not a denylist of known-bad — so anything * layers 1 and 2 failed to anticipate still has to be on a list somebody * wrote down. No `on*` handler is on it, and `script`, `iframe`, `object`, * `embed`, `form` and `style` are not either. * * ## What the tests can and cannot prove * * §10 forbids a DOM test environment, and DOMPurify needs one — it is a * wrapper around `DOMParser` and refuses to run without it * (`DOMPurify.isSupported === false` under bare Node). So layer 3 cannot be * *executed* in this repository's test suite, and no amount of wanting it to * changes that. * * What the suite does instead is prove the two layers that are pure string * transforms, against hostile fixtures, and prove that layer 3 is wired * correctly: {@link renderNote} takes its sanitiser as a parameter, so a test * hands it a spy and asserts the HTML is passed through it with * {@link SANITIZE_CONFIG}, and asserts the config's contents directly. The * honest summary is: *the input to DOMPurify is already safe by construction * and that is tested; DOMPurify is configured strictly and wired as the last * step, and that is tested; DOMPurify's own behaviour is upstream's tested * property, not ours.* * * ## Tier rules (§2) * * `src/web/client/**`. `marked` and `dompurify` are the two npm dependencies * this phase adds, both listed in `tests/web/tiers.test.ts`' client allowlist * and both already licence-cleared in `scripts/build-web.mjs` (§0.1). The * view-models come through `../../shared/view`; core is never imported * directly. No DOM *type* is named here either — {@link Purifier} and * {@link ClosestElement} are structural ports — which is what lets the root * `tsconfig.json` project compile the tests. */ import { Marked } from "marked"; import type { TokenizerAndRendererExtension, Tokens } from "marked"; import { relTime } from "../../shared/view"; import type { GraphPayload, ViewNote, WireGraphNode, WireNoteSource } from "../../shared/wire"; import { provenanceGlyph, provenanceTitle } from "../tree/tree.model"; // --- escaping --------------------------------------------------------------------- /** The five HTML-significant characters. */ const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", } as const; type HtmlSpecial = keyof typeof HTML_ESCAPES; /** * The character class matching exactly {@link HTML_ESCAPES}' keys. * * Built from the keys rather than written out a second time, which is what * justifies the cast in {@link escapeHtml}: the regex and the table cannot * disagree about which characters are special, because one is derived from the * other. All five are literal inside a character class, so no escaping is * needed to assemble it. */ const HTML_SPECIAL_RE = new RegExp(`[${Object.keys(HTML_ESCAPES).join("")}]`, "g"); /** * Escape a string for interpolation into HTML text or a quoted attribute. * * `src/web/server/page.ts` has a near-identical function and this is not * shared with it, deliberately: that one is server-tier and the client may not * import it, and promoting five characters into `src/web/shared/` to avoid * five lines would be a module whose only content is a table everyone already * knows. The duplication is bounded (it cannot grow — HTML will not acquire a * sixth significant character) and both copies are tested. * * Both quote styles are escaped even though every attribute below is * double-quoted, because the cost is nothing and the invariant "an escaped * string is safe in *any* attribute position" is much easier to hold in your * head than "safe in the positions I checked". * * The cast is sound and the usual `?? ch` fallback is deliberately absent: * {@link HTML_SPECIAL_RE} is generated from {@link HTML_ESCAPES}' own keys, so * a matched character is a key by construction. A fallback here would be a * branch that cannot be taken and therefore cannot be covered — the same * consideration as `tree.model.ts`' `idAt`, resolved by making the two * definitions share a source instead of by adding dead defensive code. */ export function escapeHtml(value: string): string { return value.replace(HTML_SPECIAL_RE, (ch) => HTML_ESCAPES[ch as HtmlSpecial]); } // --- URL safety ----------------------------------------------------------------------- /** * URL schemes a link or image in a note may use. * * An allowlist, and a short one. `javascript:` is the attack everyone knows; * `data:` is the one that gets forgotten, and `data:text/html,