-
Notifications
You must be signed in to change notification settings - Fork 13
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.
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-srcexcludes'unsafe-inline'and'unsafe-eval'on purpose. Every event handler in the SPA isaddEventListener— never inlineonclick=. 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.
Two layers, one boundary each:
-
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, nofile://, no script characters. Any new URL-fetching endpoint MUST use the same validator. -
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 +
Hostheader to the original hostname so cert validation still works. - Follows max 3 redirects, re-validating each
Locationhop. - Streams the body with a hard
maxBytescap (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.
- Resolves the hostname once via
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.
-
Client:
UI.md()is the single Markdown-to-DOM render boundary for all LLM/file content. -
Server ingress:
stripDangerousMarkdown()sanitizes CV markdown onPUT /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).
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 viaLLM_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.
- Provider keys live in the parent's
.env, written only throughPOST /api/configwith aKNOWN_KEYSwhitelist;GET /api/configmasks secret values (AIza••••). -
.env,.env.local,.env.*.localare gitignored;.env.exampleholds placeholders only. - The activity log (
data/activity.jsonl) redactsSECRET_KEYSbefore appending. -
GET /api/status/providersreports readiness without ever returning key material. - The in-app bug reporter is privacy-floored: never CV/profile/URLs/keys in the snapshot.
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 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 customllmRateLimitmiddleware, 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).
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.
career-ops-ui v1.209.0 · Repository · Releases · Issues
Guides
Reference
Development
Languages (Home)