An official website of the United States GovernmentUnclassified
Seal of the Department of the NavyDepartment of the NavyProject ROUNDTABLE Docs

Security and compliance

Implemented security controls, their code locations, and how to verify them

This page is the security reference for Project ROUNDTABLE. Every control below is implemented in the application and cited to the file that enforces it, so a reviewer can read the control, open the code, and reproduce the behavior. Controls are cross-referenced to the DISA Application Security and Development STIG requirement and the NIST SP 800-53 control family they satisfy.

The application is defense-in-depth by construction: authentication, authorization, rotation state, input validation, storage, and audit are enforced independently, on the server, at every layer that can observe the request. No control depends on the browser behaving correctly.

Control map

ControlSTIG / NISTImplementationBehavior
Password hashingV-220629 / IA-5src/lib/auth.ts, src/app/api/account/password/route.tsbcrypt with a work factor of 12 for every hash and comparison; plaintext is never stored or logged.
Password complexityV-220629 / IA-5src/app/api/account/password/route.ts, src/app/api/gov/admin/users/route.ts, src/app/api/portal/register/route.tsMinimum 14 characters with lowercase, uppercase, digit, and special character, enforced by one shared regular expression on every password-accepting route.
Account lockoutV-220629, V-220630 / AC-7src/lib/auth.tsFive consecutive failures lock the account for 15 minutes; the counter increments atomically and resets on success.
Credential-change session revocationV-220630 / AC-12, IA-5src/lib/auth.ts, src/app/api/account/password/route.tsUser.sessionVersion increments on password change, invalidating every live JWT for that account on its next request.
Forced rotation of issued credentialsV-220629 / IA-5src/middleware.ts, src/app/api/gov/_lib/helpers.ts, src/lib/auth.tsAn admin-issued temporary password gates the entire application (edge middleware, every API gate, and a page-level assertion) until it is rotated.
Session expiryV-220630 / AC-12src/lib/auth.tsJWT sessions carry a 15-minute maximum age.
Authorization refreshAC-2, AC-6src/lib/auth.tsRole, command, organization, and rotation state are re-read from PostgreSQL on every request, so a revocation takes effect immediately.
Audience separationAC-3 / AC-6src/lib/auth.tsrequireGovSession and requireIndustrySession split government and industry surfaces before any query runs.
Role gatesAC-3, AC-6src/app/api/gov/_lib/helpers.tsReader, writer, publisher, admin, and system-admin tiers, each returning generic 401/403 responses.
Command scopeAC-3, AC-4src/lib/command-hierarchy.tsOwn-command scope with an explicit enterprise-admin exception; URL identifiers are validated against the session, never trusted.
Login throttlingAC-7, SC-5src/lib/auth.tsPer-account and per-IP fixed windows over a 15-minute period, evaluated on every attempt and audited when tripped. The IP bucket keys on the trusted client IP, and an untrustworthy X-Forwarded-For shares one login:ip:unknown bucket rather than minting a fresh window.
Registration throttlingSC-5src/lib/rate-limit.ts, src/app/api/portal/register/route.tsFive registration attempts per client IP per hour.
Credential-change lockAC-3, IA-5src/lib/demo-mode.ts, src/app/api/account/password/route.ts, src/middleware.tsWhen DEMO_LOCK_CREDENTIALS is anything other than the string false, the password page renders a notice, the password endpoint refuses the change, and the forced-rotation gate is bypassed. Fails closed toward locked: the lock is the default, and only the literal false unlocks it.
Pageview analytics scopingAU-2, AU-3, SC-5src/app/api/analytics/pageview/route.ts, src/app/gov/admin/traffic/page.tsxOnly a validated same-origin pathname (no query or fragment) is accepted, at 60 writes per client IP per minute; the reporting page is GOV_SYSTEM_ADMIN-only and renders aggregates, never per-visit rows or IP values.
Password-change throttlingAC-7, SC-5src/app/api/account/password/route.tsFive attempts per authenticated user per 15 minutes, returning 429 and auditing password_change_rate_limited.
Search throttlingSC-5src/app/api/search/route.tsThirty type-ahead queries per user per 10 seconds.
Trusted client-IP derivationAU-3, SC-5src/lib/client-ip.tsX-Forwarded-For is read from the right, offset by TRUSTED_PROXY_COUNT trusted hops, and ignored entirely when it cannot be trusted. Login, registration, and pageview collection all derive their audited IP and rate-limit key this way, so a client-supplied leftmost entry never reaches the audit trail.
Input validationV-220631 / SI-10Zod schemas in every route.tsWhitelist validation of types, enums, lengths, formats, dates, and identifiers before any database access.
Parameterized data accessV-220632 / SI-10src/lib/prisma.ts, src/lib/search.tsPrisma query builders and parameterized $queryRaw bindings; no string-concatenated SQL.
Upload validationSI-10src/app/api/portal/submissions/route.tsExtension whitelist, 15 MB cap, and magic-byte inspection of the file content.
Request-body boundingSC-5src/app/api/portal/submissions/route.tsA Content-Length above 15 MB plus a 64 KB field allowance is refused with 413, and the body is then read as a stream with a hard byte counter that aborts the request rather than buffering an oversized multipart payload in memory.
Submission throttlingSC-5src/app/api/portal/submissions/route.tsTwenty submissions per authenticated user per hour, returning 429 and auditing submission_rate_limited.
Download hardeningSI-10, SC-18src/app/api/portal/submissions/[id]/file/route.tsOwnership check, sanitized filename, forced attachment disposition, nosniff, and no-store.
Storage key confinementAC-3, SI-10src/app/api/portal/submissions/[id]/file/route.tsA download serves only keys under the uploads/ prefix and rejects any path containing ...
Query cost boundingSC-5src/lib/search.tsRaw search transactions set a transaction-local statement_timeout, so a pathological query cannot hold a connection open.
Orphan-object cleanupSI-12src/app/api/portal/submissions/route.tsA stored object is deleted when the corresponding database write fails, so storage cannot retain unreferenced content.
Encryption at restV-220633 / SC-28src/lib/s3.ts, deploy/ TerraformUploads request server-side encryption (AES256 by default, aws:kms supported); RDS storage encryption is provisioned in Terraform.
Encryption in transitV-220634 / SC-8next.config.mjs, deployment runbooksTLS termination in front of the container, an HTTPS NEXTAUTH_URL, HSTS, and upgrade-insecure-requests.
Security response headersV-220641 / SC-18, SI-11next.config.mjsCSP, HSTS, X-Frame-Options: DENY, nosniff, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, and no framework version banner.
Export restrictionAC-3, AC-6src/app/api/export/*Whole-dataset exports are limited to GOV_ADMIN and GOV_SYSTEM_ADMIN and audited.
Spreadsheet injection defenseSI-10src/app/api/export/exporters.tsCSV cells beginning with a formula trigger (=, +, -, @, tab, carriage return) are prefixed with a quote.
Generic client errorsV-220641 / SI-11jsonError in src/app/api/gov/_lib/helpers.tsClients receive a fixed message and status; diagnostics stay in server logs.
Audit trailV-220635 / AU-2, AU-3AuditLog writes across auth, routes, and librariesAuthentication, authorization, access, publication, routing, export, and administrative events.
Search auditabilityAU-2, AU-3src/lib/search-audit.tsEvery type-ahead query is retained verbatim, aggregated per user window, and flushed on shutdown.
Operational logging hygieneAU-3, SI-11src/lib/logger.tsOne JSON line per request with method, pathname, status, and duration — never query strings, headers, bodies, or credentials.
Secret handlingIA-5, SC-12deploy/ Terraform, src/lib/s3.tsSecrets come from SSM Parameter Store and the container's IAM role; no static keys in images, code, or client bundles.

Authentication

Sign-in is deliberately uniform. Email is trimmed and lowercased, then:

  1. The per-account and per-IP throttles are both evaluated so every attempt is counted, and a tripped throttle writes authentication_rate_limited. The IP comes from the trusted position of X-Forwarded-For (see trusted client-IP derivation), never the client-supplied leftmost entry, so the IP recorded on every authentication event cannot be forged.
  2. An unknown email still performs a bcrypt comparison against a random hash and still writes an audit row, so response time and shape do not reveal whether the account exists.
  3. A locked account is compared as well, then rejected with authentication_locked, so lockout state is not an enumeration oracle either.
  4. A wrong password increments failedLoginAttempts atomically and locks the account at the fifth failure with account_locked.
  5. A successful sign-in clears the failure counter and lockout timestamp and writes authentication_success.

Every path above produces the same generic result to the client: no user object and no detail about why.

Session lifecycle

Sessions are stateless JWTs with a 15-minute maximum age, and the JWT callback re-reads authorization attributes from the database on every request. Two consequences matter for review:

  • An administrator's role, command, or organization change applies on the next request rather than at token expiry.
  • If the account is gone, or sessionVersion no longer matches the value the token was minted with, the callback strips the token's authorization claims. The session immediately stops passing role gates instead of retaining privileges for the remainder of its lifetime. getSession() treats such a token as unauthenticated, so a stripped claim can never degrade into an unscoped query.

Changing a password increments sessionVersion, which revokes every session for that account — including sessions on other devices — and the change-password page signs the caller out immediately.

Rotation of issued credentials

Accounts created by an administrator are issued a temporary password and marked mustChangePassword. That state is enforced three times over: edge middleware allows only the change-password page and API, the NextAuth endpoints, the login page, and the documentation zone; every API gate independently returns 403 Password change required and audits an authorization_failure; and page layouts call assertRotationComplete. Any one of the three is sufficient, which is the point — narrowing the middleware matcher or adding a route cannot silently open a hole.

Identity integration model

Authentication is a NextAuth provider seam, and authorization is entirely independent of it: roles, command scope, rotation state, and audit all key off the User record rather than the credential mechanism. Adding CAC/PIV certificate authentication or FlankSpeed federation is therefore a provider-level integration behind the same session contract, with no change to the role model, route gates, or audit inventory.

Authorization

The gates compose from least to most privileged, and each one fails closed:

Enterprise visibility and hierarchy authority are separate privileges. GOV_ADMIN sees enterprise records but cannot reparent commands; only GOV_SYSTEM_ADMIN reorganizes the command tree, and granting any admin-tier role — including GOV_COMMAND_LEAD, which carries user management over its command — is reserved for GOV_SYSTEM_ADMIN. The user-management route also refuses to remove the last system administrator, so the tenant cannot be locked out of its own authorization structure.

Industry requests are scoped by the session's organizationId, never by an identifier supplied in the URL or body. Whole-dataset exports carry their own enterprise-admin gate rather than inheriting ordinary government read access, and file downloads carry the same gate as the pages that link to them.

Input, upload, and output safety

Zod schemas are whitelists: they reject oversized strings, unknown enum values, malformed URLs and emails, invalid dates, weak passwords, unknown search entity types, and missing required fields, and they run before the handler touches the database. Data access goes through Prisma query builders or parameterized raw bindings, so user input is never interpolated into SQL.

Upload validation does not trust the filename:

  • PDF must begin with %PDF-.
  • DOCX and PPTX must begin with the ZIP signature PK\x03\x04.
  • TXT must contain no null bytes in its first 8,192 bytes.
  • Every accepted file is at most 15 MB.
  • If the database write fails after the object is stored, the object is deleted.

Size is enforced before the body is parsed, not only after. The handler rejects a declared Content-Length over the 15 MB cap (plus a 64 KB allowance for the text fields and multipart framing) with 413, then reads the body as a stream with a hard byte counter so a request that omits or understates its length — a chunked upload, for example — is aborted at the cap instead of being buffered into the process heap. Submissions are additionally throttled to twenty per user per hour, so repeated in-policy uploads cannot be used as a resource-exhaustion vector either.

On the way out, objects are private and stored under an uploads/ prefix keyed by server-generated paths; a download requires an authenticated government role or an industry user belonging to the owning organization, serves only keys under uploads/ and rejects traversal sequences, and the response forces Content-Disposition: attachment with a sanitized filename, X-Content-Type-Options: nosniff, and Cache-Control: no-store so a document can never be rendered as active content in the browsing context. CSV exports neutralize spreadsheet formula injection, and export queries select explicit fields so password hashes cannot leave the database.

Response headers

next.config.mjs applies the following to every route, including the proxied documentation zone:

HeaderValuePurpose
Content-Security-Policydefault-src 'self' with object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'none', upgrade-insecure-requestsConfines scripts, styles, fonts, images, connections, and workers to the application origin; blocks plugin content, base-tag hijacking, off-origin form posts, and framing.
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadPins the browser to HTTPS for two years, subdomains included, and is preload-eligible. Emitted for real deployments, not for plaintext local development.
X-Frame-OptionsDENYLegacy clickjacking defense alongside frame-ancestors.
X-Content-Type-OptionsnosniffBlocks MIME sniffing of responses.
Referrer-Policyno-referrerKeeps internal ledger URLs out of outbound referrers.
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=()Denies device APIs the application does not use.
Cross-Origin-Opener-Policysame-originIsolates the browsing context from cross-origin openers.
X-Permitted-Cross-Domain-PoliciesnoneDenies legacy cross-domain policy readers.
X-Powered-ByremovedNo framework version disclosure.

Script and style directives permit inline content because the App Router emits unnonced inline bootstrap and streaming payload scripts; every other fetch directive is origin-locked. Three items are keyed to the phase rather than the route: the dev server adds 'unsafe-eval' and websocket connect-src entries for hot reload, and drops upgrade-insecure-requests alongside HSTS because the local server is plaintext. A production build carries the upgrade and the HSTS header and none of the dev allowances.

Audit and accountability

Audit rows are written for authentication outcomes, lockouts, throttle trips, authorization failures, record access, submission creation and routing, downloads, publication, status changes, administrative CRUD, role changes, exports, and password-change outcomes. The canonical event inventory lists the current labels; AuditLog.detail carries JSON context such as counts, reasons, and identifiers.

Search deserves a note because type-ahead endpoints are a classic accountability gap. src/lib/search-audit.ts aggregates each user's queries over a 10-second window into one search_performed event instead of one row per keystroke, retains every distinct query verbatim (only exact repeats collapse), and flushes pending windows on process shutdown. Scripted enumeration through the endpoint, including prefix-chain sweeps, therefore remains fully attributable.

The operational log is separate from the audit trail: src/lib/logger.ts emits one JSON line per request with method, pathname, status, and duration. Query strings, headers, and bodies are deliberately excluded, error objects are serialized with their diagnostic fields, and 500s are logged at error so operators can alert on level. Retention, immutability at the storage layer, and log shipping are deployment responsibilities that pair with these application controls; the deployment runbooks cover encrypted storage, private subnets, and secret management.

Verification walkthrough

These checks exercise the controls above end to end. Run them against a disposable database as part of a release review.

  1. Submit a malformed title and confirm a 400 from the Zod schema.
  2. Sign in as an industry user, call a government mutation, and confirm 403.
  3. Sign in as a command lead, request an unrelated command, and confirm scope denial plus an authorization_failure row.
  4. Fail a login five times and confirm account_locked, then confirm a correct password is still refused until the 15-minute lockout expires.
  5. Sign in with an unknown email and a known email with a wrong password; confirm identical response shape and comparable timing.
  6. Change a password, then replay a session cookie captured before the change and confirm it no longer passes a role gate.
  7. Create an account through the admin route and confirm every page and API is gated until the temporary password is rotated.
  8. Upload a renamed non-PDF file and confirm magic-byte rejection; upload a file over 15 MB and confirm rejection before storage, and confirm an oversized body is refused with 413 before it is parsed.
  9. Download a submission file and confirm attachment, nosniff, no-store, and a submission_file_downloaded audit row.
  10. Request any page and confirm the security headers above are present on the response.
  11. Export as a command lead and confirm the enterprise-admin gate; export as an admin and confirm a formula-triggering value is quoted in the CSV.
  12. Issue a burst of type-ahead queries and confirm one aggregated search_performed event that retains each distinct query.

Review questions for a change

  1. Does every new route establish audience, rotation state, and role before reading the database?
  2. Does it scope URL identifiers to the session instead of trusting the browser?
  3. Are all body, query, and multipart fields validated by a whitelist schema?
  4. Are client errors generic, with detail only in server logs?
  5. Does a security-sensitive mutation write an audit event?
  6. Does an upload validate bytes as well as extension and size, and clean up on failure?
  7. Does a new notification respect settings and avoid duplicate fan-out?
  8. Does a new export carry the enterprise-admin gate and formula escaping?
  9. Does new infrastructure keep secrets in SSM and objects out of public buckets?
  10. Do the documented headers, controls, and audit labels still match the code?

Data minimization guidance

Use the smallest export and search result that answers the operational question. Keep downloaded CSV, JSON, and iCalendar files in controlled locations, avoid copying submission summaries into chat or issue descriptions, and delete temporary files according to local handling policy. Generic error messages and pathname-only request logs keep the application from disclosing record detail, but operators can still disclose data through screenshots or manually copied records — handling discipline is part of the control set.