This document is the security spec for Collectify. It is intentionally checklist-shaped so it can be scanned during reviews.
Threat model: a self-hosted single-user (Phase 1–3) or small-multi-user (Phase 4+) app, typically reached over LAN or via the user's own reverse proxy / VPN. We assume the operator is non-hostile but the network and any embedded webview / browser are partially trusted at best.
- Every
/api/*endpoint except/api/auth/{me,setup,login}calls.RequireAuthorization(). - Every read/write of a collection row filters by
OwnerId == users.GetUserId(ctx.User). Never trust anOwnerIdsupplied in a request body — derive it server-side from the cookie. PUT /api/{type}/{id}andDELETE /api/{type}/{id}re-checkOwnerIdbefore mutation, returning404 Not Found(never403) when the row exists but belongs to another owner — avoids leaking existence.- The first-run
/api/auth/setupis one-shot: it 400s if any user already exists. - Role checks (Phase 4): use
RequireAuthorization(policy => policy.RequireRole("Admin"))for any registration / deletion endpoints. Don't roll a custom check.
- Passwords are stored via ASP.NET Core Identity's default PBKDF2 hasher. Don't switch to a custom hasher.
- Cookie auth uses ASP.NET Core Data Protection. In Docker, the keys live in
/data/keys(configure once we addservices.AddDataProtection().PersistKeysToFileSystem(...)); without persistence, cookies invalidate on container rebuild. - Outbound HTTPS: never
ServerCertificateCustomValidationCallbackshortcuts. Use the system trust store. - Secrets (
TMDB_API_KEY,IGDB_*,DISCOGS_TOKEN) come from environment variables, never committed..envis gitignored.
- All persistence is through EF Core LINQ → parameterized SQL. Never concatenate user input into raw SQL. If you ever need raw SQL, use
FromSqlInterpolated. - Never pass user input into
EF.Functions.Likepatterns without first wrapping it:var like = $"%{userQuery}%";is parameterized by EF Core, but always confirm by reading the generated SQL when reviewing. - For the Phase 2 metadata clients, build URLs with
QueryHelpers.AddQueryStringorUriBuilder— never string interpolation into URLs. - Identity username & password validation: enforce
RequiredLength, no whitespace-only, normalized comparisons viaUserManager(already does this).
- Single-user-by-default is a deliberate design choice; multi-user requires opt-in via admin-controlled registration (Phase 4).
- All multi-tenant scoping is on the row (
OwnerId), not on a connection string or schema, so cross-tenant leaks reduce to "did I forget theWHERE OwnerId == …" — easy to grep for. - Camera barcode scanning happens client-side only; the server never receives an image, only a UPC string. This avoids storing user-controlled binary blobs.
ASPNETCORE_ENVIRONMENT=Productionin the runtime image. Never ship Swagger / developer exception pages in production builds.app.UseHsts()andapp.UseHttpsRedirection()should be added once the operator runs behind TLS — flagged by config flag, not always-on, since some self-hosted setups terminate TLS at the proxy. Document this in the README.- The container runs as UID 1000, not root. The data volume is owned by 1000.
Cookie.SameSite = Lax,HttpOnly = true,Secure = true(auto whenRequireHttpsMetadatais on). SetSecureexplicitly once we know the operator's terminating proxy contract.- Disable directory browsing (default) —
UseStaticFilesonly serves the built React bundle.
dotnet list package --vulnerable --include-transitiveshould be clean before each release. CurrentlyMicrosoft.AspNetCore.Identity.EntityFrameworkCore10.0.0 pulls a transitiveSystem.Security.Cryptography.Xml9.0.0 with aNU1903warning — track upstream and pin once a patched version ships.npm audit --omit=devshould be clean. Dev dependencies (Vite, TS) are excluded because they don't ship to users.- Dependabot or
npm-check-updatesruns in a future task to keep these moving.
- Use
SignInManager.PasswordSignInAsync(..., lockoutOnFailure: false)for now; enable lockout (true) and configureIdentityOptions.Lockoutbefore any deployment is exposed beyond a private network. - No password reset email flow yet — single-user deployments reset by clearing the DB volume; document this in the README. Revisit for Phase 4 multi-user.
- Cookie expiry: 30 days sliding. Logout properly calls
SignOutAsync. - Don't expose username enumeration:
/api/auth/loginreturns identical401whether the user exists or not.
- All packages restored from
nuget.organdregistry.npmjs.org— no custom feeds. package-lock.jsonand EF Core migrations are committed. CI mustnpm ci(notnpm install) anddotnet restore --locked-modeto guarantee reproducibility (add when CI is set up).- Built React bundle is served with default
Content-Typeand a strongETag; no client-side script loaded from third-party CDNs.
- ASP.NET default logging is on (
Informationfor app,Warningfor framework). Log:- Failed logins (level: Warning)
- Setup attempts after first user exists (Warning)
- Provider configuration errors (Error)
- Don't log secrets, full request bodies, or full external API responses. Log the cache key + status only.
- Logs go to stdout (Docker captures them). No log files inside the container.
- Phase 2/3 metadata clients only call fixed, hard-coded base URLs (TMDB / MusicBrainz / IGDB / UPCitemdb). Never accept a URL from a user-supplied field and dispatch a server request to it.
- If we ever fetch a cover image by URL provided by an external API, validate it points to that provider's known image host, set a timeout (≤ 5 s), cap the response size (≤ 5 MB), and disallow HTTP redirects to private IP ranges.
- Add a basic security-headers middleware (Phase 2 or earlier) emitting:
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(self), microphone=()(camera needed for barcode scan)
- Same-origin only: don't enable CORS.
- React escapes by default. Never call
dangerouslySetInnerHTMLon user-supplied strings. The codebase contains zero usages today; keep it that way. - Don't render markdown/HTML coming from external metadata providers — display as plain text only. If we ever want rich text, add
DOMPurifyand a strict allow-list. - The CSP above forbids inline scripts and remote scripts.
- Cookie auth means we are CSRF-relevant. Mitigations:
SameSite=Laxon the auth cookie (already set).- Same-origin only — no CORS.
- All state-changing methods (
POST,PUT,DELETE) require auth, so a forged GET cannot mutate state. - For extra safety, add an
X-Requested-With: XMLHttpRequestrequirement on state-changing endpoints (Phase 2 task) —fetchfrom another origin can't set custom headers without preflight.
- Never store passwords in
localStorage/sessionStorage/ IndexedDB. Auth state is the cookie, period. - Don't put API keys in the React bundle. All keys live server-side in
appsettings/.env. - Avoid logging full server responses to the browser console in production builds.
npm audit --omit=devclean.- Pin
@zxing/browser(Phase 3) to a known-good version; review release notes before upgrading.
getUserMediarequires HTTPS. README documents how to set up TLS via reverse proxy (Caddy / Traefik / mkcert).- Stop the camera stream as soon as the scanner unmounts. Always check
track.stop()inuseEffectcleanup. - Never upload the camera frame to the server — only the decoded UPC string.
viewport-fit=coveris set; that's only layout, not security.- Don't enable an SPA service-worker until the cache strategy is intentionally designed; a misconfigured SW can serve stale code.
- New endpoint: has
.RequireAuthorization()(or explicit comment why not) - New endpoint: filters by
OwnerIdfor any DB read/write - No raw SQL string interpolation
- No new third-party JS / CSS loaded from CDN
- No new
dangerouslySetInnerHTML - No new package without locking and reviewing audit output
- No new env var read directly via
Environment.GetEnvironmentVariable; useIConfiguration - If a feature flag controls security behavior (HTTPS redirect, lockout), default is the safer value
- Tests cover the auth-failure and ownership-mismatch cases