Completes the design record on main: Plan B's plan and the federation spec were already here; this adds the umbrella security spec (source of truth for the remaining container/web/desktop/remediation workstreams) and Plan A's plan.
27 KiB
Security Scanning & Hardening Initiative — Design
Date: 2026-07-10 Status: Approved (design); pending implementation plan Author: Lead Developer (Backspace)
1. Motivation
A prospective self-hoster declined to run Backspace with the objection:
"Security testing: You've made a web app. I am not installing a new webapp that is expected to touch the internet without some level of security scanning."
The objection is valid. Investigation of the current state shows Backspace has solid security engineering but no security assurance infrastructure:
Already present (good):
SECURITY.mdwith a private vulnerability-disclosure policy.- Real defensive code: SSRF protection (
packages/server/src/utils/ssrf.ts— DNS resolution, private-IP blocking, per-redirect-hop re-validation), HMAC-signed federation with replay-nonce prevention, sliding-window rate limiters, JWT + bcrypt, input validation. - Good secrets hygiene:
.deploy.localuntracked; thorough.gitignore(.env*,*.pem,*.key,data/,*.db). - CI (
ci.yml) running typecheck + build + full test suite.
Absent (the gap):
- No SAST, no dependency/CVE scanning, no secret scanning, no container image scanning, no supply-chain hardening, no Dependabot.
- No visible, verifiable evidence a stranger can audit before trusting the app.
- No browser-facing hardening the objection actually cares about: no security
response headers (
@fastify/helmetabsent; bareCaddyfile), CORS reflects any origin with credentials, unsigned desktop autoupdate, no Electron fuses/asar integrity, no license compliance gate for a dual-licensed (AGPL + commercial) project.
Root cause (per the No-Band-Aids principle): the fix is not a one-off scan. It is a permanent, automated, and visible scanning pipeline wired into CI/CD, plus remediation of the browser/desktop hardening gaps that continuous scanning would be embarrassing to leave open.
2. Goals & Non-Goals
Goals
- Continuous, automated scanning on every change: SAST, dependency CVEs, secrets, container image, license compliance, supply-chain posture.
- Tiered enforcement: high-confidence, fixable issues block merge; the rest are advisory in the GitHub Security tab. Never wall off merges on unfixable upstream CVEs.
- Close the browser-facing and desktop-facing hardening gaps (headers, CORS, Electron integrity).
- Publish verifiable evidence: badges, an OpenSSF Scorecard, SBOM/provenance, and documentation a stranger can read without repo access.
- Land the whole thing without leaving CI spuriously red: scanners report-only → remediate → flip enforcement.
Non-Goals (explicitly out of scope for this initiative)
- npm provenance / package signing — every workspace is
"private": true; nothing is published to npm. N/A. - Purchasing desktop code-signing certificates — a procurement action (Apple Developer ~$99/yr, a Windows code-signing cert) that cannot be done in code. We implement the code-level Electron hardening and document the signing steps and certs to buy; we do not fake signing.
- Full fuzzing harness for federation input — valuable but multi-week; deferred.
A handful of targeted negative/property tests on
validateExternalUrland S2S JSON parsing is in scope; a standing fuzz harness is not. - TLS/cipher configuration — Caddy already auto-provisions HTTPS with modern defaults; we add security headers, not a TLS overhaul.
3. Tool Selection & Rationale
Where a "GitHub-native" option and a "committed-workflow" option overlap, we prefer
committed workflow files — a self-hoster auditing the repo can read a .yml
file; they cannot read repo settings. Settings-only toggles are documented as
required manual steps, never claimed as code.
| Scan class | Choice | Rationale |
|---|---|---|
| SAST | CodeQL (committed advanced workflow, default security suite to start) |
Free for public repos, best TS/JS coverage, none build mode sidesteps monorepo/native-module build complexity. security-extended deferred to avoid a day-one triage tax. |
| Dependency CVEs | OSV-Scanner (blocking CI gate) + Dependabot (auto-upgrade PRs) | OSV-Scanner parses pnpm-lock.yaml v9 directly and can fail the build; Dependabot alerts are advisory-only. Two distinct roles, no overlap. Trivy is NOT used for dependency CVEs (avoids double-noise). |
| Secrets | gitleaks (committed, full history + PR diff) + documented native push-protection | gitleaks is the verifiable, blocking, history-aware gate; native push-protection is the complementary pre-commit net for the future. |
| Container image | Trivy (image scan, blocking) | SARIF output, ignore-unfixed: true for tiered policy, scans the exact GHCR image users pull. |
| IaC/config | Trivy config (Dockerfile, docker-compose) | Note: Trivy does not lint the Caddyfile; the reverse-proxy hardening is done by hand (§6.3). |
| License compliance | Trivy --scanners license with an allowlist |
Dual-licensed AGPL + commercial → a copyleft-incompatible transitive dep is a legal defect. Reuses the Trivy we already run. |
| Supply chain | SHA-pinned actions + harden-runner (audit) + SBOM + SLSA provenance + OpenSSF Scorecard | Answers "can I trust the build?" and produces a public Scorecard badge. |
| Dynamic (DAST) | ZAP baseline against an ephemeral docker compose up (advisory) |
Catches missing headers + CORS reflection continuously; the one dynamic check for a "webapp exposed to the internet." |
4. Architecture — Component Layout
Each workflow file has one clear purpose (mirrors the codebase's module-boundary principle).
.github/
dependabot.yml NEW — pnpm(npm) + github-actions + docker(Dockerfile only)
workflows/
codeql.yml NEW — CodeQL SAST (PR + push main + weekly)
security.yml NEW — gitleaks + OSV-Scanner + Trivy config + Trivy license
scorecard.yml NEW — OpenSSF Scorecard (push main + weekly) → Security tab + badge
dast.yml NEW — ZAP baseline vs ephemeral compose stack (advisory)
docker-publish.yml EDIT — restructure for real image scanning + SBOM + provenance
ci.yml EDIT — harden-runner (audit), tighten permissions
release.yml EDIT — harden-runner (audit), tighten permissions
cla.yml EDIT
deploy-pages.yml EDIT
SHA-pinning applies to EVERY workflow — the four edited above, docker-publish.yml,
and all four new ones (codeql/security/scorecard/dast). Pin every uses: to a
full commit SHA with a trailing # vX.Y.Z comment. (OpenSSF Scorecard's
Pinned-Dependencies check and tag-move attack resistance both require this repo-wide.)
Dockerfile EDIT — non-root USER, slim runtime, copy pruned node_modules
Caddyfile EDIT — security response headers
packages/server/src/index.ts EDIT — @fastify/helmet + CSP; tighten CORS
packages/server/package.json EDIT — add @fastify/helmet
packages/web/index.html EDIT — CSP meta (defense in depth for static shell)
packages/desktop/src/main.ts EDIT — will-navigate deny handler
packages/desktop/electron-builder.yml EDIT — @electron/fuses / asar integrity
packages/desktop/package.json EDIT — add @electron/fuses
README.md EDIT — badges + "Security & supply chain" section
SECURITY.md EDIT — "Security testing & assurance" section
docs/systems/security-scanning.md NEW — full pipeline spec
docs/systems/desktop-security.md NEW — Electron hardening + signing procurement
CLAUDE.md EDIT — add subsystem-table rows
5. Policy Engine (tiered enforcement)
| Finding | Action |
|---|---|
| gitleaks secret hit | Block (always) |
| OSV-Scanner — fixable HIGH/CRITICAL | Block |
Trivy image — fixable HIGH/CRITICAL (ignore-unfixed: true) |
Block |
| Trivy license — disallowed license | Block |
| CodeQL — any alert | Advisory (SARIF → Security tab) |
| OSV/Trivy — unfixable, or medium/low | Advisory (SARIF → Security tab) |
| ZAP baseline (DAST) | Advisory (report artifact) |
| Scorecard | Advisory (score badge + Security tab) |
Enforcement honesty — two mechanisms, kept separate:
- Code-enforced (auditable in the
.yml): OSV-Scanner, Trivy, and gitleaks block via workflow exit codes. - Settings-enforced (documented one-time toggles, NOT claimed as code): CodeQL
merge-blocking (code-scanning merge protection), Dependabot alerts, native
secret-scanning + push protection, and branch protection "require status checks."
These live in
docs/systems/security-scanning.mdas a maintainer checklist.
6. Workstreams (bounded, independently reviewable)
Sequencing rule: WS1 scanners land report-only → WS5 remediation → flip WS1/WS2 enforcement to blocking. WS3/WS4 are otherwise independent and can land in parallel. Two cross-workstream dependencies to respect: (a) WS3 (CSP/CORS validation) and WS6's DAST job share the same two-instance + LiveKit ephemeral test rig — build it once, reuse it; (b) WS6's badges + maintainer checklist document state produced by WS1/WS2/WS5, so its final copy is written last (the workflow files can be scaffolded earlier).
WS1 — Scanning & supply-chain pipeline (report-only first)
.github/dependabot.yml:package-ecosystem: npmat/(Dependabot handles pnpm workspaces), weekly, grouped minor/patch.ignoreuiohook-napi— it is pinned by an exact-version patch (patches/uiohook-napi@1.5.5.patch); an unmatched bump breakspnpm install --frozen-lockfilein CI and both Docker stages. Also treatonlyBuiltDependencies(better-sqlite3,esbuild,electron,sharp) bumps with care (grouped, expect native-rebuild churn).package-ecosystem: github-actionsat/.package-ecosystem: dockerat/— tracks the DockerfileFROMonly. No compose entry:docker-compose.ymlsits at the same/directory (a second docker entry there would collide on ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles, notimage:refs in compose. The pinnedcaddy:2.11.1-alpine/livekit/livekit-server:v1.9.11compose images are therefore updated manually — added as a line item to the maintainer checklist indocs/systems/security-scanning.md. (Renovate, which does parse compose, is noted there as an optional future alternative.)
codeql.yml: languagesjavascript-typescript, defaultsecurityqueries, triggers PR + pushmain+ weekly cron. SARIF uploaded.security.yml:- gitleaks — full history + PR diff, SARIF, block on hit.
- OSV-Scanner — reads
pnpm-lock.yaml; report-only initially, then block on fixable HIGH/CRITICAL after WS5. - Trivy config — Dockerfile + docker-compose misconfig, SARIF, advisory.
- Trivy license —
--scanners licenseagainst the dependency tree with an allowlist (permissive + AGPL-compatible); block on disallowed.
scorecard.yml:ossf/scorecard-action, pushmain+ weekly, publish results + badge.- Harden all workflows (new and existing, incl.
docker-publish.yml): pin everyuses:to a full commit SHA (retain a# vX.Y.Zcomment); addstep-security/harden-runnerinegress-policy: audit(not block — multi-arch buildx + QEMU + gha cache make many egress calls); tighten job-levelpermissionsto least privilege.
WS2 — Container hardening & real image scanning
- Restructure
docker-publish.yml(the current single multi-archbuild-pushcannot be scanned before publish):- Build single-arch
linux/amd64withload: true. - Trivy image scan (
ignore-unfixed: true, block on fixable HIGH/CRITICAL), SARIF uploaded. - On pass, the multi-arch (
amd64,arm64)build-pushwithpush: true,sbom: true,provenance: true. (Buildx cache makes the second build cheap.)
- Trivy authenticates to GHCR with the same
GITHUB_TOKENused for login (the package may be private until manually flipped public).
- Build single-arch
- Dockerfile hardening:
- Prune mechanics (precise): the builder runs a full
pnpm install --frozen-lockfile(Dockerfile:25) whosenode_modulesis a symlinked.pnpmvirtual store — a plainCOPY --from=builder node_modulesis not self-contained. Usepnpm --filter @backspace/server deploy --prod /app/deployin the builder to produce a dereferenced/hoisted prod tree, thenCOPY --from=builder /app/deployinto the runtime stage. This replaces the runtime stage's ownpnpm install --prod, lettingpython3 make g++be dropped from runtime. Keepffmpeg(real runtime dep) and keeptsxas a prod dependency (the CMD runs TS viatsx/esm). Verifybetter-sqlite3's prebuilt binary andtsxare present in the copied tree for both target arches. - Non-root + bind-mount chown (reconciled — the two are mutually exclusive if
done naively):
docker-compose.yml:31bind-mounts host-owned./data:/app/data. Chowning it requires root, so we do not hard-set aUSERline (that would run the entrypoint as non-root and make the chown impossible). Instead: installgosu(orsu-exec), add anENTRYPOINTthat (a) idempotentlychowns/app/datato a fixed non-root UID, then (b)exec gosu <uid> "$@"to drop privileges — so the process runs non-root while the volume stays writable. TheENTRYPOINTmustexec "$@"to preserve the existingWORKDIR /app/packages/server+CMD ["node","--import","tsx/esm","src/index.ts"](Dockerfile:96-97). Ship a documented upgrade note; must not break existing self-hosters ondocker pull+ restart.
- Prune mechanics (precise): the builder runs a full
WS3 — Web/server hardening
Reality check (from review): this app renders arbitrary user-supplied content
and is federated, so a restrictive img-src/media-src/connect-src is
infeasible. A CSP here realistically constrains script-src / object-src /
base-uri / frame-ancestors / form-action (the XSS/clickjacking-relevant
directives) and stays permissive on content origins. Concretely:
img-src/media-srcmust be broad (https: data: blob:): link-embed OG images (VideoEmbed.tsx,RichEmbed.tsx) come from any linked site, and GIF previews load directly from Klipy's CDN (routes/gif.tsreturnsfile.urlunproxied — the CDN host differs fromapi.klipy.com).connect-srcmust include the LiveKitwss://origin, which is operator config (routes/livekit.tsreturnsconfig.livekit.url=LIVEKIT_URLverbatim) — so the CSP must be generated at runtime from config, not a static string. Federation (getApiForOrigininexploreStore/socialStore/spaceStore) fetches/opens WS to peers discovered at runtime →connect-srcmust also allowhttps: wss:(peers aren't enumerable at build time).frame-srcneeds an explicit provider allowlist — YouTube, Vimeo, Spotify embed origins — or the embed iframes break (defaultframe-src 'self'blocks them).
Steps:
- Add
@fastify/helmet. Build the CSP dynamically fromconfig.livekit.url+ the embed-provider list; ship it report-only first, validate against real flows (chat, cross-instance federation, embed render, upload, and a real voice join) with zero violations, then flip to enforcing. packages/web/index.html: CSP<meta>(defense-in-depth) — script/object/base directives only; do not duplicate the dynamic connect/img rules there.Caddyfile:Strict-Transport-Security,X-Content-Type-Options: nosniff,Referrer-Policy, and clickjacking protection via CSPframe-ancestors(prefer overX-Frame-Options). Ownership split (documented to avoid conflicts): Caddy owns HSTS + nosniff + Referrer-Policy; the app (helmet) owns the CSP. Don't set CSP in two places.- CORS (
packages/server/src/index.ts:46-48) — replaceorigin: truewith a dynamicorigincallback backed by the live federation-peer registry, NOT a staticDOMAIN-derived list. Two breakages a static list would cause, both must be handled:- Federated browser uploads: browsers make cross-origin tus POST/HEAD/PATCH/
DELETE to peer
/api/files/*(see the existing CORS-block comment atindex.ts:50-64); peers are DB-backed and added after boot → the callback must consult the live registry, not a boot-time snapshot. - Desktop instance picker:
packages/desktop/resources/instance-picker.htmldoes a rendererfetch('<url>/api/instance/info')from afile://document (Originnull). Keep/api/instance/infoCORS-open (or move that probe to a main-process fetch) so the picker doesn't report instances as unreachable. - Federation note: S2S endpoints authenticate by HMAC and receive no browser
Origin; verify they are unaffected by the two-instance federation integration suite.
- Federated browser uploads: browsers make cross-origin tus POST/HEAD/PATCH/
DELETE to peer
- Rollout (phased, like the scanners): CSP report-only → observe → enforce; CORS gets a "log-and-allow" observation phase (log rejected origins without blocking) before switching to reject. Test-rig dependency: validating CSP + CORS here needs the two-instance + LiveKit harness (shared with the DAST env, §WS6/G2), which is heavier than a single-instance boot — call this out when scheduling.
WS4 — Desktop/Electron hardening
- Fuses without breaking the existing hook:
electron-builder.yml:20already declaresafterPack: ./scripts/afterPack.js(it strips host-compileduiohook-napiartifacts + cross-platform prebuilds), and electron-builder allows only oneafterPack. So do not add a second hook. Prefer electron-builder's top-levelelectronFuses:config key (cleanest, no collision); if a fuse isn't expressible there, call@electron/fusesflipFuses()inside the existingscripts/afterPack.js. Fuses: disableRunAsNode+EnableNodeCliInspectArguments, enableOnlyLoadAppFromAsar. Asar-integrity caveat: it interacts with the existingasarUnpack: **/*.node(lines 17-18) and the afterPack that mutatesapp.asar.unpacked— integrity hashes must be computed after those mutations, and because builds are unsigned (release.yml:93) macOS integrity enforcement is limited; document this indesktop-security.mdrather than over-claiming. will-navigatedeny handler inmain.ts: block foreign top-level navigations while allowing the initialhttps://instance load and thefile://picker. Clarification (mechanism): the app is client-routed (history API →did-navigate-in-page), cross-instance switching uses main-processloadURL, and/join/*deep-links are handled bysetWindowOpenHandler(main.ts:454) — none of these arewill-navigate, so the deny handler is safe andsetWindowOpenHandlerstays untouched.docs/systems/desktop-security.md: document the current webPreferences posture (contextIsolation on, nodeIntegration off, sandbox on —main.ts:356-360), the fuses/asar posture and its unsigned-macOS limits, and — becauserelease.yml:93setsCSC_IDENTITY_AUTO_DISCOVERY: false(unsigned) — the exact signing + notarization steps and certificates to procure. Flag unsigned autoupdate as a known gap until signing is wired up.
WS5 — Remediation (after WS1 lands report-only)
- Run OSV-Scanner + Trivy + CodeQL; triage. Fix real HIGH/CRITICAL: direct upgrades,
pnpm.overridesfor transitive pins where no direct upgrade exists, code fixes for true-positive SAST findings. Dismiss false positives with written justification (.trivyignore/ inline). - SSRF hardening (fix, then test — not just test): the string-prefix
isPrivateIp(utils/ssrf.ts:3-16) is genuinely bypassable —::ffff:127.0.0.1matches no branch and returnsfalse(SSRF to loopback via an attacker AAAA record), and there is no100.64.0.0/10(CGNAT) or::handling. HardenisPrivateIp: normalize IPv4-mapped IPv6, reject CGNAT and::/unspecified, and normalize decimal/octal/hex hostname encodings — then add the negative/property tests forvalidateExternalUrlcovering those vectors. The residual DNS-rebind TOCTOU is already documented (ssrf.ts:58-61) and stays out of scope (noted, not fixed). - Then flip WS1/WS2 enforcement to blocking.
WS6 — Visible evidence, DAST & docs
dast.yml(ZAP baseline): stands up an ephemeral instance and runs ZAP baseline (advisory). CI env override required — the production compose won't come up unmodified: Caddy uses{$DOMAIN}+ ACME auto-HTTPS (hangs in CI without public DNS),backspacerequiresJWT_SECRET, livekit is profile-gated. Use a CI compose override that sets a testJWT_SECRET/DOMAINand points ZAP directly at thebackspacecontainer:3000, bypassing Caddy (or Caddyinternal/local TLS). This is the same two-instance-capable rig WS3 needs for CSP/CORS validation.- README: CodeQL, OpenSSF Scorecard, and security-policy badges; a "Security & supply chain" section describing what runs on every change and where results are published.
- SECURITY.md: add a "Security testing & assurance" section enumerating the pipeline.
docs/systems/security-scanning.md: full spec of every workflow, the tiered policy, and the maintainer settings checklist (§5) — including the repo-must-be- public precondition (Scorecardpublish_results+ badge and CodeQL free tier both require a public canonical repo) and the manualcaddy/livekitcompose-image update reminder (from WS1/F1).- CLAUDE.md: add subsystem-table rows for
security-scanning.mdanddesktop-security.md(required by the Documentation Rule — this is structural CI and architecture). - Finalize WS6 last: badges + the maintainer checklist document state that only exists once WS1/WS2/WS5 land, so write the final copy after those are green (the workflow files can be scaffolded earlier).
7. Testing Strategy
actionlinton every new/edited workflow.- Real PR-branch run watching each check go green (or advisory) as intended.
- Canary proof of blocking: on a throwaway branch, introduce a fake secret and a known-vulnerable dependency; confirm gitleaks and OSV-Scanner actually fail the build; revert.
- WS2:
docker buildlocally for amd64 + container boots +/api/healthresponds, before and after the Dockerfile changes; confirm the process runs non-root (via gosu step-down) yet still writes the host-owned./databind mount; confirmtsx+better-sqlite3prebuilt are present in the pruned tree and the arm64 image still builds; confirm existing self-hosters survivepull+ restart. - WS3 (needs the two-instance + LiveKit rig): security headers present
(curl/DevTools); zero CSP violations across chat, cross-instance federation,
embed render (YouTube/Vimeo/Spotify + generic OG image), GIF, upload, and a real
voice join; CORS callback permits the app origin and dynamically-registered
peers (federated upload), keeps
/api/instance/infoopen to thefile://picker (Originnull), and rejects an unknown origin; two-instance federation S2S suite still green. - WS4: desktop app boots with fuses/asar-integrity applied and the existing
afterPacknative-module cleanup intact;will-navigateblocks a foreign top-level URL while the initial instance load, thefile://picker, and/join/*deep-links (viasetWindowOpenHandler) still work. - DAST: ZAP baseline runs against the CI compose override (bypassing Caddy) and produces a report artifact.
- Full suite (
pnpm -r test) green throughout; existing federation/voice suites unaffected.
8. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Enforcement day-one paints CI permanently red (May-2024 lockfile has fixable highs) | Report-only → remediate (WS5) → flip blocking. |
| Multi-arch image "scan" is theater / arm64 unscanned | WS2 restructure: single-arch load+scan → then multi-arch push. |
Non-root USER breaks ./data bind-mount for existing self-hosters |
Run entrypoint as root → chown → exec gosu <uid> step-down (no static USER); documented upgrade note; tested before/after. |
Dependabot breaks CI via uiohook-napi patch / native rebuilds |
ignore the patched dep; group onlyBuiltDependencies. |
| CSP too strict for a federated, arbitrary-content app | CSP built dynamically from config.livekit.url + peer registry; img/media/connect permissive; constrain only script/object/base/frame-ancestors; report-only → enforce. |
CORS allowlist breaks federated uploads + desktop file:// picker |
Dynamic origin callback backed by the live peer registry; keep /api/instance/info CORS-open; "log-and-allow" phase before rejecting. |
Electron fuses overwrite the existing afterPack (native-module cleanup) |
Use top-level electronFuses: key or call flipFuses() inside the existing scripts/afterPack.js; compute asar-integrity hashes after afterPack mutations. |
pnpm symlinked .pnpm store makes a plain node_modules copy non-self-contained |
Use pnpm --filter @backspace/server deploy --prod; verify tsx + better-sqlite3 prebuilt land per-arch; keep ffmpeg; boot test. |
| DAST/compose won't come up in CI (ACME/DOMAIN/JWT_SECRET) | CI compose override with test env; point ZAP at backspace:3000, bypass Caddy. |
| harden-runner block mode false-positives the Docker build | Start in audit; graduate to block only on lightweight jobs. |
| Scorecard badge / CodeQL free tier assume a public repo | Documented as an explicit precondition in the maintainer checklist. |
9. Definition of Done
- All new workflows present,
actionlint-clean, and green on a real PR. - Blocking gates proven by canary (secret + vuln), then reverted.
- Security tab populated (CodeQL, Scorecard, advisory Trivy/OSV) with no open fixable HIGH/CRITICAL after WS5.
- helmet + CSP + Caddyfile headers live with no CSP violations in normal use; CORS allowlisted; federation suite green.
- Electron fuses +
will-navigatelive; desktop boots and deep-links work; existingafterPacknative-module cleanup intact. isPrivateIphardened (IPv4-mapped IPv6 / CGNAT /::/ alt-encodings) with passing negative tests.- Container image scanned before publish; SBOM + provenance attached; Dockerfile runs non-root (gosu step-down) with a working data volume.
- README badges + Security section; SECURITY.md expanded;
docs/systems/security-scanning.md+docs/systems/desktop-security.mdwritten; CLAUDE.md subsystem table updated. - Maintainer settings checklist documented (CodeQL merge protection, Dependabot alerts, push protection, branch protection).