Skip to content

Security: BasileChretien/sigmacv

Security

SECURITY.md

Security

SigmaCV handles personal data (names, emails, ORCID iDs, CV contents) under GDPR and Japan APPI, and doubles as an IRB-governed research vehicle, so security and privacy are first-class concerns.

Reporting a vulnerability

Please report suspected vulnerabilities privately to the maintainer (chretien.basile.jean.bernard.u4@s.mail.nagoya-u.ac.jp) rather than opening a public issue. We aim to acknowledge within a few days.

Security posture

The codebase has been through four independent multi-lens security audits (authentication/session, access control/IDOR, injection, XSS, secrets/config, SSRF, CSRF/cookies, infra/DoS, PII/data-leak, dependencies) — the most recent a multi-agent automated hardening pass (2026-06-08, see below) — each with adversarial verification of every finding. Across all passes the core threat model has been clean: no confirmed account takeover, cross-user data leak, remote code execution, IDOR, SQL injection, or stored XSS. All findings from CRITICAL down to INFO have been remediated.

What is enforced

  • Authentication — Auth.js v5, database-backed sessions (no forgeable JWT), no dangerous OAuth account-linking, provider tokens dropped before persistence, AUTH_SECRET ≥ 32 chars + AUTH_URL required in production.

  • Authorization / tenant isolation — every user-data query is scoped to the server-derived session.user.id; no endpoint accepts a client-supplied object id. The public CV page is gated by an unguessable 80-bit capability slug + a published flag + a PII-stripping projection. Frozen CV versions (snapshots) are gated the same way: each public snapshot is reached only by an unguessable 144-bit capability token (/p/<slug>/v/<token>, 18 random bytes base64url, @unique), served only while its owner marked it public AND the parent page is published, through the same public projection, and always noindex. Owner operations are scoped to the session's own Cv row (never a client-supplied cv id). The frozen copy stores none of the owner-only fields (private notes, presets, personal data, internal review signals). DOI minting is dormant without DATACITE_* credentials, which never reach the client or the logs.

  • Injection — zero raw SQL (parameterised Prisma only); no shell-out / no server-side LaTeX compilation; DOI/path inputs are allow-listed before use.

  • XSS — citeproc output is entity-encoded; all owner free-text is HTML-escaped; hrefs are scheme-allow-listed; the photo data-URL is a strict, end-anchored base64 boundary (no SVG); JSON-LD is <-escaped via a shared helper; the public page ships a strict default-src 'none' CSP as an HTTP header.

  • App-shell Content-Security-Policy — every page outside /api and /p/ is served a policy that never allows 'unsafe-inline' or 'unsafe-eval' for scripts, with object-src 'none', base-uri 'self', form-action 'self' and frame-ancestors 'none' (src/proxy.ts, src/lib/security/csp.ts). script-src has two shapes, because a nonce can only be put on a page that is rendered per request:

    • A page rendered per request (the home page, the editor, the preview, the name lookup, the institution pages, the 404 page) gets a per-request 128-bit nonce and 'strict-dynamic'.
    • A page prerendered at build time (About, FAQ, the legal pages, the guides, the glossary, the examples and the landing pages, in every locale) has no nonce in its HTML. It gets 'self' and the sha256 of each inline script it was built with, read off the build output when the page is served, plus, when analytics is configured, the analytics script's exact URL and the hash of its init stub. This is an allow-list, not a nonce policy: any script file served from the site's own origin may load. A policy belongs to the document, so it also stays in force when a visitor moves on by client-side navigation: the name lookup, the preview and the editor, reached from a guide without a full page load, run under this allow-list too. What it relies on, there as on the prerendered pages themselves, is that the site's own origin serves no JavaScript-typed response carrying user-influenced content (none does today; only Next's own build files are scripts), and that every response the app serves carries X-Content-Type-Options: nosniff, so no JSON, page or export can be run as a script. A new route that answers with a JavaScript type and includes user data would break that, and so would a rewrite or a proxy that makes the site's origin serve another server's script (none exists today: the app rewrites no request and passes on no other server's response, and Caddy hands the site's requests to the app alone). tests/no-javascript-responses.test.ts fails when a source file names a JavaScript content type, when a script file appears under public/, when nosniff leaves the site-wide headers, or when a rewrite appears in next.config.ts or in the app's source. It reads the app's source and no more: it does not see a type computed at runtime, nor a route handler that passes on another server's response with its type, nor a path that the Caddyfile would proxy to another server. In one respect this shape is the stricter of the two: without 'strict-dynamic', a script that is already running cannot add an inline script whose hash is not listed.

    If the build output cannot be read, a prerendered page is sent the nonce shape: its scripts stop running, and the policy does not loosen. npm run e2e:prod, run in CI on every pull request, opens a production build in Chromium and checks that each kind of page runs its own scripts, that an injected script is refused, and that a same-origin page, JSON file or text file named as a script is refused too (the nosniff assumption above).

    Until 2026-10 prerendered pages were sent the nonce shape, which their HTML could not satisfy: the browser refused every script on them except the theme bootstrap, which is listed by hash. The policy was never looser than described here; those pages did not respond to input and were not counted by analytics. The 404 page was in the same state. The proxy cannot tell that a path will be a 404, so it cannot send a prerendered 404 the hashes it would need: the page is rendered per request instead (src/app/not-found.tsx) and gets the nonce. It shows fixed text and takes nothing from the address but its language. An unknown path under /api or /p/, which the proxy does not cover, is answered with the same page and no app-shell policy, as before.

    One more page is built once without a nonce: the framework's own page for a server failure (/_global-error, which the build also copies to pages/500.html). It was suspected of being served for a failing request, under the nonce shape, with every script refused. Checked in Chromium on a production build (Next 16.3.6, 2026-10-07), it is not:

    • A page whose render fails (the database cannot be reached, say) is answered with an error page rendered for that request. Every script carries the nonce, the browser runs them, and the page shows "This page couldn't load" with a "Reload" button that reloads. npm run e2e:prod opens one.
    • A failure outside the render (a module that throws when it is loaded, as with an invalid environment) is answered with 21 bytes of plain text, Internal Server Error. The server looks the built page up twice. The first lookup, as an App Router page, does find pages/500.html. The second, as a Pages Router page, is made whatever the first returned and replaces its result. It needs that router's own _document and _app, which this app does not have, so it finds nothing, and the page found a step earlier is discarded. That ordering inside Next, not anything in this app, is what keeps the built page from being served here, and it is what an upgrade could change. (The lookup order was re-read in the source of Next 16.3.8, the version installed since: renderErrorToResponseImpl in server/base-server.js.) A route handler that fails answers with an empty body. Neither has a script.
    • The built page is served at one address, /_global-error itself. That path is a prerendered route like any other, so it is sent its own hashes and its scripts run.

    Should a later release of the framework serve the built page for a failing request, the nonce shape would refuse its scripts. The page would still show and still reload: its "Reload" button is the submit button of a form and needs no script (npm run e2e:prod checks that with JavaScript turned off).

  • SSRF — outbound fetches (claim-by-DOI, custom-CSL, OEP) use fixed hosts / host allow-lists with manual redirect re-validation and timeouts; private / link-local / metadata targets are blocked.

  • CSRF — every state-changing route enforces SameSite=Lax and an isSameOrigin check that fails closed in production.

  • DoS — request bodies are size-capped by streaming (not the spoofable content-length); expensive routes are per-user rate-limited; the public page is per-IP + globally rate-limited on the real peer IP, render-cached, and negative-caches unknown slugs.

  • Privacy — public projection strips owner.personal and gates contact fields behind per-field opt-in (default off); the data export omits tokens; the structured logger redacts sensitive keys recursively and reduces Errors to {name, message}.

2026-06-08 hardening pass

A fourth audit run by parallel specialist reviewers (security, type-safety, correctness, database, performance), each finding adversarially re-verified before it was applied, drove a further round of defence-in-depth fixes. Every change shipped with unit tests; the full suite and the src/lib coverage gate were kept green throughout.

  • Abuse resistance — the Postgres rate limiter now does an atomic check-and-increment (the cap is enforced inside the UPDATE … WHERE count < max), closing a lost-update race where concurrent requests for one key could both pass the cap. The Auth.js Account/Session foreign keys are indexed; the sitemap and GDPR-export queries are bounded, and the export reports a truncation flag + true total so it is never silently incomplete.
  • Export injection — the Markdown renderer escapes user free-text section titles + name/honorific and collapses control characters in the YAML frontmatter (a newline-bearing display name can no longer break out of the frontmatter block); safeHref strips user:pass@ userinfo so a pasted credential can't leak into an href / LaTeX \url{}; previously-unbounded canonical-document string fields are length-capped.
  • Self-name highlighting matches only at Unicode word boundaries, so a short surname ("Li") can't false-highlight inside a longer word ("Library") while accented / CJK names still match.
  • Data integrity (re-sync) — the source merge no longer (a) double-lists a claimed preprint a source later publishes, (b) drops a "not mine" / hidden decision when one DOI appears in two sections, or (c) collides curation between two same-named review venues. Schema migrations are now fully immutable (they never mutate the caller's stored document).
  • Input validation — locale is constrained to a BCP-47 shape with a safe fallback, neutralising injection-shaped values before they reach Intl / JSON-LD.

The same pass also ran three adversarial penetration rounds — independent attackers tasked to take over an account, exfiltrate data, and take the app down, each required to produce a working exploit or name the exact control that blocked it. The offensive result was clean on the core threat model: no account takeover, IDOR, CSRF bypass, stored XSS, SSRF, LaTeX/CSL RCE, prototype pollution, or PII leak — every attempt hit a specific defence (session-scoped queries + server-side identity reconciliation; fail-closed CSRF; the 80-bit slug + published gate; the SSRF host allowlist with per-hop re-validation; output-context escaping; projectCvForPublic as the sole public gate; CslItem .strict() + Zod-strip before any spread). The remaining findings were resource/abuse and consent-scope issues, now fixed:

  • DoS / resource exhaustion — bounded the previously-unbounded customStyleCache (LRU) and hard-enforced the in-memory rate-limiter's bucket cap; reworked the public-page cache to a total-byte budget so expensive renders are cached (repeat hits O(1)) without unbounded heap; capped total CV items on every write path (save + sync/resync) and in the live-preview render so a many-item document can't pin the event loop; single-flighted public-page and OG renders; added a slug-keyed, bounded OG-image cache; and fixed a PDF concurrency-slot leak (a render error could permanently wedge PDF export).
  • Public-export minimisation — the machine formats (/p/<slug>.json etc.) now drop hidden / "not mine" items (and their disambiguation reason/timestamp), gate metrics + per-year chart data behind the owner's display opt-ins, and strip saved editor presets — so a downloadable public file never exposes what the owner disavowed or chose not to display.

Accepted operational residuals (documented, low-risk): concurrent same-user saves are last-write-wins (a UX concern — identity fields are still server-reconciled, so not a security crossing); a coordinated 3-account PDF-slot monopoly is bounded by the per-user export limit and the 30 s render timeout; the machine formats are serialisation-only (DB-I/O-bound, absorbed by the rate limiter) and intentionally uncached.

Operator requirements (production)

Set these before exposing the app publicly (see .env.production.example):

  • POSTGRES_PASSWORD — required; deployment fails fast if unset.
  • AUTH_SECRET — ≥ 32 chars (openssl rand -base64 33). Rotate freely: the GDPR Art. 21 objection list for the no-login preview is NOT keyed by it.
  • PREVIEW_SUPPRESSION_KEY — the dedicated key PREVIEW_SUPPRESSED_ORCID_HMACS is computed with (openssl rand -base64 24; required whenever the list is set — env.ts refuses to start with a keyless list, since it would match nothing and silently re-expose every objector). Rotating THIS key means recomputing every entry with npm run preview:suppress-hash under the new key and deploying both together. Keep objectors' plaintext iDs in the privacy mailbox only, never in the repo or the env; the hash script prompts for the iD rather than taking it as an argument, so it stays out of shell history. The same key hashes the PreviewSuppression table rows written by the self-service /object route (ORCID-verified through the orcid-object provider id — Auth.js's signIn callback records the row and aborts the sign-in, so no user or session is created) and by the signed-in account toggle. Rotating the key voids those rows too.
  • AUTH_URL — the canonical HTTPS origin (anchors OAuth callbacks + the CSRF origin check).
  • RATE_LIMIT_PERSIST=true — durable, cross-instance rate limiting.
  • Keep Postgres unpublished to the internet (the compose files expose only).
  • Caddy sets X-Forwarded-For to the real peer and request_body max_size; keep /api/internal/* blocked at the edge, and keep the method allow-list (anything outside GET HEAD POST PUT PATCH DELETE OPTIONS gets a 405 + Allow header, so odd methods never reach Node).

Accepted residual risks

These are documented, low-severity, and not code-fixable today:

  1. ORCID OIDC has no PKCE/nonce. ORCID's discovery advertises neither, and Auth.js refuses an unadvertised check. Mitigated by a confidential client (server-side code exchange with the secret), the state check, and no open redirect. We will switch to ["state","pkce"] the moment ORCID advertises it.
  2. Database session token stored at rest. Inherent to the Auth.js database session strategy. Mitigated by managed-Postgres encryption-at-rest, keeping DATABASE_URL secret, and not publishing Postgres.
  3. Research-export DOI re-identification. A publication DOI is quasi-identifying; the offline export now HMAC-pseudonymises DOIs/source-ids, and the whole programme ships disabled behind an IRB env-flag triad with a separate re-identification key table (対照表) held only by the personal-information manager. To be finalised against the IRB pre-registration before enabling.

Research data handling

Research logging is off by default and double-gated (a master switch + explicit per-user consent). Logged events are minimised structured signals (no names/emails/ORCIDs). The researcher export is offline only (no network route), triple-env/IRB-gated, and pseudonymised. See src/lib/research/CLAUDE.md.

Co-author linking (public CV)

The public living page (/p/[slug]) can link a researcher's co-authors to their own SigmaCV CV — as schema.org knows links in the page JSON-LD, and (opt-in) as a visible "Co-authors on SigmaCV" list. The consent model is deliberately conservative:

  • Identifier-only. A co-author is matched by ORCID (src/lib/cv/coauthorLinks.ts), never by name string — the same invariant as self-name highlighting. A co-author with no ORCID, or whose ORCID does not resolve to a SigmaCV account, is never linked.
  • Indexability-gated (load-bearing). A link is only ever made to a co-author CV that is published AND publicIndexable — one whose owner already consented to public search discovery. Linking a published-but-unindexed page would leak its unguessable capability slug, so it is never done.
  • Target opt-out. A published+indexable owner can still decline to be listed as anyone's on-SigmaCV co-author via display.coauthorLinkable (default on); the resolver honours it per render.
  • Owner opt-in for the visible block. display.showCoauthorLinks is default off — the visible list never appears unless the owner enables it.
  • No enumeration oracle. Resolution is server-side only (no public ORCID→slug endpoint), fail-soft (a DB hiccup yields no links, never an error), and ephemeral: resolved slugs are never written into the stored document, so an unpublish / index-off / opt-out / account deletion delinks on the next render.
  • Data minimisation. Raw co-author ORCIDs captured at build (meta.coauthorOrcids) are stripped in projectCvForPublic, so they never reach the public machine downloads; only the resolved links to consenting, indexable co-authors are surfaced.

Residual: the dedicated control is modelled as an opt-out (default linkable), since a link is only ever made to an already-search-indexable page; a stricter opt-in default is a one-line change if the threat model tightens.

There aren't any published security advisories