Security and trust model¶
A configured source is third-party data, not a trusted input. The build reads it, and the published site then serves it same-origin, to every visitor's browser. Every control in this section exists because of that second step: a vulnerability in how a source is read or displayed is not contained to the build machine — it ships.
https-only url sources¶
loadConfig (src/config/load.ts) rejects any sources[].url whose
protocol isn't https: — not "http or https". Two files a url source
fetches, /config.json and /c/index.json, are never digest-verified
against anything (there is nothing to verify them against; they are the
starting point of trust, not a leaf under it). If either travelled over
plain http:, a network attacker could rewrite the enumeration a build
reads — and every downstream digest check would then verify each fetched
package root against the attacker's own digests, and pass cleanly.
Transport integrity is the only integrity those two files have.
No redirects, ever¶
Every fetch src/sources/walker.ts makes sets redirect: "manual" and
treats any 3xx response as a hard failure, naming the refused hop, rather
than following it. This is an SSRF guard: a hostile configured url source
could otherwise bounce the builder at a host it can reach but the operator
never intended to expose — and those bytes would land in published,
undigested config.json/c/index.json content. A redirect is a
deterministic policy refusal here, not a transient failure, so it is never
retried either.
Digest verification and realpath re-checks¶
Every byte a url source fetches beyond config.json/c/index.json is
verified against its own digest before being cached or returned —
SourceError("DIGEST_MISMATCH", …) on any mismatch, naming the file and
both digests, never a silent pass-through.
path and git sources get the filesystem equivalent. loadConfig's own
containment check on a path/git source's directory is lexical only
(string resolve/relative math) — it never follows symlinks. The actual
enforcement point is src/sources/path.ts's assertContained, called
per file, not just once at the source root: every file this subsystem
opens has its realpath re-verified against the source root's own
realpath immediately before the read. A symlinked file or subdirectory can
appear at any depth during a recursive walk, and only a per-file check
catches both — a config having already passed loadConfig proves nothing
about a later read being safe.
Resource caps: the denial-of-service boundary¶
A hostile or merely careless source can otherwise exhaust memory, disk, or
wall-clock time before any digest check gets a chance to reject it. The
caps, read directly from src/sources/walker.ts and src/sources/mirror.ts:
| Cap | Value | What it bounds |
|---|---|---|
| Response body size | 8 MiB | One url-fetched file's decompressed byte count |
| Fetch/write concurrency | 16 in flight | The combined root+CAS fetch queue, and the mirror write queue |
| Fetch attempts | 4 (1 + 3 retries) | Exponential backoff per URL; a 3xx is never retried |
/c/index.json entries |
50,000 packages | How large one source's enumeration may declare itself |
| CAS content asset size | 1 MiB | A mirrored README/logo blob from a path/git source (oversized ones are skipped and warned, never silently included) |
Git: argument-injection defences¶
src/sources/git.ts passes entry.git/entry.ref/entry.dir as array
arguments to execFile — never a shell string, which already defeats shell
injection. Git itself still parses a leading-- positional as an option
(--upload-pack=<cmd> is git's own documented argument-injection vector,
unrelated to shell quoting), so two independent layers close that separately:
every invocation inserts -- (end-of-options) immediately before the first
such positional, and assertNotOptionLike rejects a leading-- value
outright before it ever reaches runGit.
A --depth 1 clone never fetches LFS objects, so an LFS-tracked file
resolves to its pointer text, not the real bytes — git.ts detects the LFS
pointer prefix in every sourced blob and throws
SourceError("LFS_POINTER", …) rather than silently serving pointer text as
a package's logo or readme. .gitmodules presence triggers a named warning
through options.warn; submodules are never cloned, but their presence must
never manifest as silent empty data.
Label allowlist¶
A source's display label becomes both a filesystem path segment
(dist/index/<label>/) and a line in the shared _headers file. When a
source declares no explicit label, src/sources/labels.ts derives one from
the source's own package roots — which makes a derived label hostile
data, sourced from whatever a third-party index author put in root.name.
assertLabelPathSafe checks it against an allowlist
(^[A-Za-z0-9._-]+$), deliberately not a blocklist: a blocklist naming only
/, \, ., .. would still let control characters through, and a label
containing a raw newline could inject a new line — or, with two newlines, a
whole new block — into _headers, a line-oriented format shared by every
mirrored source.
How a README is rendered¶
A package's README is Markdown from a third party, rendered in the visitor's browser with raw HTML disabled: an HTML tag in a README is shown as escaped text, never parsed, and the result passes through a sanitizer before it reaches the page. Two conveniences work without enabling raw HTML:
- HTML comments are dropped.
<!-- … -->(single- or multi-line, block or inline) is removed silently, as GitHub does, instead of showing up as visible text. A comment inside a code fence or inline code stays verbatim. - Emoji shortcodes render.
:rocket:becomes 🚀, using the GitHub shortcode set. Unknown shortcodes and anything inside code stay literal, and text smileys such as:)are not converted.
The _headers sandbox¶
renderHeaders() (src/sources/mirror.ts) writes one Cloudflare
Pages/Netlify _headers rule per mirrored source, applying
Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff to
every /p/* and /index/<label>/p/* prefix — the untrusted, same-origin
content a configured source handed this renderer (a README, a logo, a
package root), never authored by it.
This is a hard deployment precondition, not a nice-to-have, and it is
honoured only by Cloudflare Pages and Netlify. On a host that doesn't read
_headers the file still ships but is inert, and ocx-catalog dev never
reads it at all. See Hosting and headers
for what that means for every other host and what a deployer has to do
about it themselves.