Security
What protects the application, and where each control lives. Every control here exists because of a failure that happened, a review that found it, or a threat that applies to this deployment.
Authentication
- ASP.NET Core Identity with cookie authentication.
- Cookie:
__Host-prefix,Securealways,HttpOnly, 10 hours sliding expiration. - Passwords of at least 10 characters; lockout after 5 failures.
- A password set by an administrator is one-time: the employee changes it at first sign-in.
- The first administrator's password comes from configuration (
Seed:AdminPassword); development seeds demo accounts only in the Development environment.
Authorization
Permission-based, never by role name. See Permissions.
Rate limiting (Hardening)
- Login: a fixed window per source per minute (
Login:Limit); an IPv6 address counts by its /64. A throttled form returns to the login page with a sentence, not a bare 429. - Live connections (
/_blazor): only a signed-in employee's page opens a circuit at all; connections are capped at once (concurrency limiter) and per minute (fixed window) per source.
HTTP hardening
- Content Security Policy (no inline import map; files referenced through
@Assets),X-Content-Type-Options: nosniff,frame-ancestorsto refuse framing,Permissions-Policywithcamera=(self), HSTS in production. - Antiforgery on every form with a
__Host-cookie. An expired token redirects back to a fresh form (UseExpiredFormRedirect), and the error page is fetched, never re-posted (UseCleanReExecution). - Request body limits on forms (
UseSmallForms).
Reverse proxy and client address
- Forwarded headers are read only from proxies named in configuration (
Proxy:Known,Proxy:Hops). The app refuses to start with forwarded headers on and no known proxy, or with a hop count over too wide a range (RefuseUntrustedProxies). - Behind a CDN, the client address is read from the CDN's header (
Proxy:Cdn:Header) only when the sender is in the CDN's ranges (Proxy:Cdn:Ranges), so the login limit partitions by the real client.
Sessions
- An ended or switched circuit (another account signed in in another tab) runs read-only (
ReadOnlySession) and asks the browser to reload; it never writes in someone else's name. - Impersonation is read-only and audited.
Uploads
- Step photos post over HTTP, never over the circuit, to
FileEndpoints.PhotoUpload. - Same-origin only:
Sec-Fetch-Site, orOriginwhen absent (FileEndpoints.SameOrigin); a SameSite=Lax cookie alone would let a sibling subdomain through. - Bytes are judged as they arrive (size, JPEG marker), and a shot is kept once by its id.
- Attachments are served only to readers
RequestAccess.MayViewallows.
Data protection and secrets
- The data-protection key ring is persisted outside the release directory (
DataProtection:Path) under a fixed application name, so a deploy never signs everyone out. - Secrets (connection string, admin password, e-mail credentials) come from environment configuration, never the repository.
Audit trail
Every write records an audit row (AuditLog.Record) in the same transaction as the change, with stable English keys
(request.Approved, user.updated, permission.granted, stock.received…). /admin/logs translates them and also
shows the server log to ViewSystemLogs. Logs never contain form contents.
Guards in the test suite
SmokeTests (only a signed-in page starts a circuit; an invalid login form token signs nobody in; signing keys not
bound to the release folder), CircuitLimitTests, ReadOnlySessionTests, PhotoUploadTests,
RequestAccessTests, ProductionBootTests.