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.
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.
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.
-
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_URLrequired 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 + apublishedflag + 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 alwaysnoindex. Owner operations are scoped to the session's ownCvrow (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 withoutDATACITE_*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 strictdefault-src 'none'CSP as an HTTP header. -
App-shell Content-Security-Policy — every page outside
/apiand/p/is served a policy that never allows'unsafe-inline'or'unsafe-eval'for scripts, withobject-src 'none',base-uri 'self',form-action 'self'andframe-ancestors 'none'(src/proxy.ts,src/lib/security/csp.ts).script-srchas 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 carriesX-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.tsfails when a source file names a JavaScript content type, when a script file appears underpublic/, whennosniffleaves the site-wide headers, or when a rewrite appears innext.config.tsor 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 theCaddyfilewould 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 (thenosniffassumption 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/apior/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 topages/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:prodopens 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 findpages/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_documentand_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:renderErrorToResponseImplinserver/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-erroritself. 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:prodchecks that with JavaScript turned off). - 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
-
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=Laxand anisSameOrigincheck 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.personaland 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}.
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.jsAccount/Sessionforeign 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);
safeHrefstripsuser:pass@userinfo so a pasted credential can't leak into anhref/ 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 —
localeis constrained to a BCP-47 shape with a safe fallback, neutralising injection-shaped values before they reachIntl/ 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>.jsonetc.) now drop hidden / "not mine" items (and their disambiguation reason/timestamp), gatemetrics+ 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.
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 keyPREVIEW_SUPPRESSED_ORCID_HMACSis computed with (openssl rand -base64 24; required whenever the list is set —env.tsrefuses to start with a keyless list, since it would match nothing and silently re-expose every objector). Rotating THIS key means recomputing every entry withnpm run preview:suppress-hashunder 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 thePreviewSuppressiontable rows written by the self-service/objectroute (ORCID-verified through theorcid-objectprovider 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
exposeonly). - Caddy sets
X-Forwarded-Forto the real peer andrequest_body max_size; keep/api/internal/*blocked at the edge, and keep the method allow-list (anything outsideGET HEAD POST PUT PATCH DELETE OPTIONSgets a 405 +Allowheader, so odd methods never reach Node).
These are documented, low-severity, and not code-fixable today:
- 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
statecheck, and no open redirect. We will switch to["state","pkce"]the moment ORCID advertises it. - Database session token stored at rest. Inherent to the Auth.js database
session strategy. Mitigated by managed-Postgres encryption-at-rest, keeping
DATABASE_URLsecret, and not publishing Postgres. - 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 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.
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
publishedANDpublicIndexable— 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.showCoauthorLinksis 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 inprojectCvForPublic, 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.