Skip to content

Security

Fighter90 edited this page Jul 9, 2026 · 1 revision

Security

career-ops-ui handles a live job search's private data (CV, salary targets, application history), so the security posture is deliberately conservative. The hard rules live in the repo's CLAUDE.md; this page documents the mechanisms.

Content Security Policy (no inline scripts)

server/index.mjs sets security headers unconditionally (since v1.58.4 — previously only on public bind):

default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline';
font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self';
object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'

plus X-Content-Type-Options, X-Frame-Options, Referrer-Policy.

  • script-src excludes 'unsafe-inline' and 'unsafe-eval' on purpose. Every event handler in the SPA is addEventListener — never inline onclick=. Widgets that ship SVG use static constants, not user-influenced markup.
  • connect-src 'self' means the browser never talks to third parties — model catalogues (OpenRouter), company favicons, and provider APIs are all proxied server-side.

SSRF guard

Two layers, one boundary each:

  1. isValidJobUrl() (server/lib/security.mjs) gates every endpoint that accepts a user-supplied URL (/api/pipeline, /api/pipeline/preview, auto-pipeline, /api/cv-studio/add-entry, /api/logo, portals health): no loopback, no file://, no script characters. Any new URL-fetching endpoint MUST use the same validator.
  2. safeGet() (server/lib/safe-fetch.mjs) performs the actual fetch DNS-rebind-safely (v1.21.0, B-1):
    • Resolves the hostname once via dns.promises.lookup.
    • Rejects RFC1918, loopback, link-local 169.254/16 (AWS IMDS), CGNAT, IPv6 ULA/link-local.
    • Connects to the validated IP directly (no second lookup); sets TLS SNI + Host header to the original hostname so cert validation still works.
    • Follows max 3 redirects, re-validating each Location hop.
    • Streams the body with a hard maxBytes cap (preview: 32 KB raw / 8 KB stripped; auto-pipeline: 256 KB / 64 KB).
    • Fail-CLOSED on DNS errors. Tests inject _setTransport(fn) to avoid real DNS.

Scanner adapters add defence-in-depth host pinning: each source anchors its hostname regex (e.g. /(^|\.)nofluffjobs\.com$/i) so db.jobs.evil.com-style suffix spoofing is rejected, and http: endpoints are refused.

XSS boundaries (exactly two)

  • Client: UI.md() is the single Markdown-to-DOM render boundary for all LLM/file content.
  • Server ingress: stripDangerousMarkdown() sanitizes CV markdown on PUT /api/cv (entity-decoded before regex strip since v1.22.0), with a 1 MB cap.

Doctrine (v1.58.3 R-2): cleanLlmMarkdown() — the declutter step that strips echoed tool-call scaffolding from LLM output — is not an XSS sanitizer, and must never be treated as one. Responsibilities stay split. Sanitizers are never duplicated: one isValidJobUrl, one stripDangerousMarkdown, one sanitizeJobDescription, one sanitizePathName (path-traversal guard on every :name/:slug param).

Rate limiting

server/lib/rate-limit.mjs::llmRateLimit wraps every LLM-calling route and every shell-out route (evaluate, deep, mode, auto-pipeline, followup, patterns, lifetime, salary-gap, …):

  • No-op on loopback (HOST=127.0.0.1, the default).
  • 10 req/min/IP token bucket on public bind (HOST=0.0.0.0), configurable via LLM_RATE_LIMIT="N/Ws". Overflow → 429 + Retry-After + X-RateLimit-*.

Additional public-bind hardening: /api/health hides absolute paths and the Node version to reduce LAN fingerprinting.

Secrets hygiene

  • Provider keys live in the parent's .env, written only through POST /api/config with a KNOWN_KEYS whitelist; GET /api/config masks secret values (AIza••••).
  • .env, .env.local, .env.*.local are gitignored; .env.example holds placeholders only.
  • The activity log (data/activity.jsonl) redacts SECRET_KEYS before appending.
  • GET /api/status/providers reports readiness without ever returning key material.
  • The in-app bug reporter is privacy-floored: never CV/profile/URLs/keys in the snapshot.

Parent read-only rule

The parent career-ops project is the user's data. Reads are unrestricted; writes happen only on explicit user actions and every write path is enumerated in docs/architecture/DATA-FLOWS.md (see Architecture). No write outside PROJECT_ROOT, no symlink-following writes, no execution of arbitrary user-supplied scripts — runners invoke a hardcoded list of parent .mjs filenames only. Relayed stderr from parent scripts passes sanitizeDetail to strip absolute paths.

Concurrent write integrity: withFileLock(path, fn) serializes read-modify-write on applications.md / pipeline.md per process.

CodeQL posture

CodeQL runs in CI. Two known categorical false positives recur and are dismissed with a documented rationale rather than "fixed":

  • js/missing-rate-limiting — CodeQL doesn't credit the custom llmRateLimit middleware, so every new FS-writing/shell-out route triggers it even when the limiter is present.
  • HTTP-to-file-access findings on routes whose write path is the documented, locked, sanitized parent write.

Real hardening still lands alongside dismissals (e.g. v1.117.1 added the limiter to the three new shell-out endpoints and made the add-entry tag stripping provably complete; v1.116.0 added a durable typeof barrier in cv-import.mjs).

Ethical guardrails (inherited from the parent)

No auto-submit: the UI drafts, fills, and generates — the user always makes the final click. The email mode is draft-only. No telemetry; everything stays on local disk.

Clone this wiki locally