# Security

This document explains what's actually implemented, why, and — just as
important — what is explicitly **out of scope** for this project and left
to your hosting/infrastructure layer. Read the whole thing before deploying
this anywhere a stranger can reach it.

## Threat model

This is a public marketing site with a small authenticated admin surface.
The realistic threats are: automated scanning/bots hitting public
endpoints, credential-stuffing or brute-force against the admin login,
spam through the contact form and newsletter signup, and the standard web
app risks (XSS, injection, broken auth, misconfiguration) that OWASP's Top
10 tracks. It is not a target-hardened against a resourced, targeted
attacker with zero-days — no small project is — but every OWASP Top 10
(2021) category below has a concrete, named mitigation in this codebase.

## OWASP Top 10 (2021) mapping

| # | Category | What this project does about it |
|---|---|---|
| A01 | Broken Access Control | Every `/api/admin/**` route requires a valid `ROLE_ADMIN`/`ROLE_EDITOR` JWT (`SecurityConfig`); public routes are explicitly allow-listed, everything else is `denyAll()` by default — a new route is unreachable until someone deliberately opens it. |
| A02 | Cryptographic Failures | Passwords hashed with BCrypt (cost 12, `SecurityConfig#passwordEncoder`); JWTs signed HS256 with a required 256-bit secret in prod (`StartupSecretsValidator`); HTTPS enforced via HSTS headers and documented as a deploy-time requirement (TLS itself is terminated outside this app — see "What's out of scope"). |
| A03 | Injection | 100% JPA/Hibernate parameterized queries — no string-concatenated SQL anywhere in the codebase. Every request DTO is validated with Jakarta Bean Validation before it touches a service. |
| A04 | Insecure Design | Rate limiting on login/contact/newsletter (`RateLimitingService`), account lockout after repeated failed logins (`LoginAttemptService`), honeypot fields on public forms, generic error messages that never confirm/deny whether a username exists. |
| A05 | Security Misconfiguration | `GlobalExceptionHandler` never leaks stack traces or exception class names; `server.error.include-*=never`; Actuator only exposes `health`/`info`; `StartupSecretsValidator` refuses to boot in `prod` with a missing/weak secret; explicit CORS allow-list, never a wildcard. |
| A06 | Vulnerable & Outdated Components | Dependency versions pinned in `pom.xml`/`package.json`; see "Dependency hygiene" below for what was checked and what's known-outstanding. |
| A07 | Identification & Authentication Failures | Short-lived (15 min default) JWT access tokens + longer-lived refresh tokens, BCrypt hashing, account lockout, timing-safe login (a dummy hash is compared even for unknown usernames so response time can't be used to enumerate accounts). |
| A08 | Software & Data Integrity Failures | No `eval`/dynamic code loading anywhere; frontend build pipeline has no postinstall scripts beyond the standard toolchain; blog/service HTML content is sanitized client-side with DOMPurify before rendering (see "Stored content is still sanitized on output" below). |
| A09 | Security Logging & Monitoring Failures | Every unhandled exception is logged server-side with a correlation ID that's also returned to the client, so a report from a user ("error ref: xxxx") is greppable without exposing internals; failed logins increment a persisted counter you can alert on. |
| A10 | Server-Side Request Forgery | The only outbound server-initiated HTTP call is the optional reCAPTCHA verification to a hard-coded Google URL — no user input is ever used to construct a URL the server fetches. |

## Authentication & authorization

- **Stateless JWT bearer auth** for the admin API. No server-side session,
  no auth cookies — CSRF protection is therefore not applicable in the
  usual sense (CSRF exploits *ambient* cookie-based auth; a bearer token
  the browser doesn't automatically attach isn't vulnerable to it). If you
  ever switch the refresh token to an httpOnly cookie (see below), you
  **must** add CSRF protection back — `SecurityConfig` has a comment at
  that exact line telling you so.
- **Access tokens** are short-lived (15 minutes) and kept only in memory on
  the frontend (a module-level variable, never localStorage). **Refresh
  tokens** are longer-lived (7 days) and kept in `sessionStorage` (cleared
  when the tab closes) — a deliberate, documented trade-off for a small
  internal CMS. The fully-hardened version of this is an httpOnly, Secure,
  SameSite=Strict cookie issued by the backend for the refresh token, with
  the access token still in memory; that changes `AuthController` to set a
  `Set-Cookie` header instead of returning `refreshToken` in the JSON body,
  changes `SecurityConfig` to re-enable CSRF for that one endpoint, and
  changes `adminClient.ts` to stop persisting the refresh token itself.
  This project ships the simpler version because it's a small admin
  surface used by a handful of trusted editors, not a design limitation —
  see `frontend/src/api/adminClient.ts` for the exact trade-off comment.
- **Account lockout**: 5 failed attempts locks the account for 15 minutes
  (both configurable in `application.yml` under `app.security`). Lockout
  state lives on the `admin_users` row, not in memory, so it survives a
  restart and works correctly even if you scale the backend horizontally.
- **No self-service registration** — admin accounts are seeded via Flyway
  migration or created directly in the database. This is intentional: a
  small internal CMS has no business exposing a public "create admin
  account" endpoint.

## Stored content is still sanitized on output

Blog post and service `content` fields accept HTML, authored by
authenticated admins through the CMS. That's still user input from the
app's perspective — an admin account could be phished, or a future
lower-trust "editor" role could be added — so it is **never** trusted
blindly:

- The React frontend renders it exclusively through
  `components/common/SafeHtml.tsx`, the only place in the codebase allowed
  to use `dangerouslySetInnerHTML`, which runs every string through
  DOMPurify with a conservative tag/attribute allow-list first.
- The backend does not attempt server-side HTML sanitization of this
  field (an earlier draft included OWASP AntiSamy for this and it was
  deliberately removed — see "Dependency hygiene" for why) because the
  sanitize-on-render approach above is the actual security boundary; a
  second server-side sanitizer would be defense-in-depth, not the
  load-bearing control. If you add a public-facing WYSIWYG editor for
  non-admin users later, revisit this.

## Input validation & abuse prevention

- Every request DTO (`ContactRequest`, `NewsletterRequest`, all admin
  `*Request` records) is annotated with Jakarta Bean Validation
  (`@NotBlank`, `@Size`, `@Email`, `@Pattern`) and rejected with a 400
  before it reaches any service logic — see `GlobalExceptionHandler`.
- **Rate limiting** (`RateLimitingService`, Bucket4j token buckets, keyed
  by client IP): 5 contact-form submissions/hour, 10 newsletter
  signups/hour, 10 login attempts/15 min, all configurable in
  `application.yml`. This is in-memory and per-JVM — see the class
  Javadoc for what changes if you ever scale the backend to multiple
  instances (swap in Bucket4j's Redis/Hazelcast integration).
- **Honeypot fields** on the contact form and newsletter signup (a
  visually-hidden `website` field) silently drop submissions from bots
  that fill in every input — no user-visible error, no signal to the bot.
- **Optional Google reCAPTCHA v3** integration (`RecaptchaClient`),
  disabled by default so the project works out of the box without external
  credentials. Enable with `RECAPTCHA_ENABLED=true` +
  `RECAPTCHA_SECRET_KEY` once you've registered a site key.
- **Client IP resolution is proxy-aware but safe by default**
  (`ClientIpResolver`): `X-Forwarded-For` is attacker-controlled unless a
  trusted reverse proxy overwrites it, so it's ignored unless you
  explicitly set `TRUST_PROXY_HEADERS=true` — which you should, once this
  backend is genuinely sitting behind a proxy you control (the bundled
  nginx, an ALB, Cloudflare). Leaving it `false` behind such a proxy makes
  every visitor's rate limit collapse onto the proxy's IP; leaving it
  `true` with *no* proxy in front lets any client fake their rate-limit
  identity via the header. Match the setting to your actual topology.

## Headers & transport

- **Backend** (`SecurityHeadersFilter` + `SecurityConfig`): every response
  gets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
  `Referrer-Policy: strict-origin-when-cross-origin`, a restrictive
  `Permissions-Policy`, `Cache-Control: no-store`, and a strict CSP
  (`default-src 'none'`, since API responses are pure JSON). HSTS is set
  via Spring Security's `.httpStrictTransportSecurity(...)`.
- **Frontend** (`nginx.conf`): the equivalent set, plus a real
  `Content-Security-Policy` for an HTML/JS app (`script-src 'self'`,
  explicit font/style sources, `frame-ancestors 'none'`, no wildcard
  origins). The HSTS header is commented out by default with a note to
  enable it only once TLS is confirmed live in front of nginx — sending
  HSTS over plain HTTP has no effect but shipping it "just in case" before
  TLS exists is a footgun if you ever need to roll back to HTTP briefly.
- **CORS** (`SecurityConfig#corsConfigurationSource`): explicit origin
  allow-list from `CORS_ALLOWED_ORIGINS`, never `*`; `allowCredentials` is
  `false` because auth is bearer-token, not cookie-based.

## What's deliberately out of scope

Being direct about this matters more than padding the list above:

- **TLS termination** — this app assumes something in front of it (a load
  balancer, Cloudflare, or nginx + certbot) terminates HTTPS. Neither
  Docker image does this itself; deploying either container directly to
  the public internet without a TLS-terminating layer in front is a
  misconfiguration, not a supported mode.
- **WAF / DDoS protection** — the in-app rate limiting stops casual abuse,
  not a distributed attack. Put this behind Cloudflare, an ALB with AWS
  Shield, or equivalent for anything internet-facing at scale.
- **Secrets management** — `.env` files and environment variables are the
  baseline here. For a real production deployment, use your cloud
  provider's secrets manager (AWS Secrets Manager, GCP Secret Manager,
  HashiCorp Vault) and inject secrets at runtime instead of a checked-in
  `.env`.
- **Automated dependency scanning in CI** — no `mvn dependency-check` or
  `npm audit --audit-level` gate is wired into a CI pipeline here because
  no CI pipeline is included in this deliverable (see README — this is
  handed off as a codebase, not a running deployment). Add one before
  this goes live; see "Dependency hygiene" for the starting point.
- **Penetration testing** — nothing here is a substitute for an actual
  security review before handling real customer data at scale.

## Dependency hygiene

- **Frontend**: `npm install && npm run build && npm run typecheck && npx
  eslint .` all ran clean in the environment this project was generated
  in. `npm audit` at generation time reported two dev-dependency-only
  advisories against `vite`/`esbuild` (a dev-server CORS/path-traversal
  issue that only matters while running `npm run dev` locally and does
  **not** affect the production build these Docker images ship). Fixing
  it requires a major Vite 8 upgrade with a different, less mature plugin
  ecosystem (`@rolldown/plugin-babel`, `oxc-transform-react`); that trade
  was judged not worth the stability risk for this delivery. Run
  `npm audit` yourself periodically and take the upgrade once Vite 8 has
  matured, or immediately if you routinely run the dev server on an
  untrusted network. `react-router-dom` was deliberately pinned to
  `^7.18.2` (not the `^6.x` originally scaffolded) specifically to pick up
  a fix for an open-redirect advisory (GHSA-wrjc-x8rr-h8h6) — do not
  downgrade it.
- **Backend**: the sandbox this project was generated in has no network
  route to Maven Central, so `mvn dependency-check` could not be run and
  the backend could not be compiled at all in that environment (see
  README). Run `mvn org.owasp:dependency-check-maven:check` yourself after
  `mvn clean verify` succeeds, before deploying.
- An earlier draft of `backend/pom.xml` included OWASP AntiSamy for
  server-side HTML sanitization; it was removed because (a) the real
  sanitization boundary is DOMPurify on render, as explained above, and
  (b) AntiSamy pulls in a large, historically CVE-prone dependency tree
  for a control that would only be defense-in-depth here. If you later add
  a lower-trust content-authoring role, reconsider this trade-off.

## Data protection & privacy

- Contact form submissions store the submitter's IP address
  (`contact_submissions.ip_address`) for abuse investigation — this is
  personal data under most privacy regimes (GDPR et al.); the seeded
  Privacy Policy page discloses this. Set a retention/deletion policy
  appropriate to your jurisdiction; nothing in this codebase auto-expires
  old submissions.
- Newsletter signups use double opt-in (`newsletter_subscribers.confirmed`
  stays `false` until the emailed confirmation link is clicked) so you
  never add an address to a mailing list without proof of ownership.
- No payment or government-ID data is collected anywhere in this
  application — there was nothing to secure there, and nothing was added.

## Pre-launch checklist

- [ ] `mvn clean verify` passes locally (this repo's sandbox couldn't run it — you must)
- [ ] `mvn org.owasp:dependency-check-maven:check` run and reviewed
- [ ] `npm audit` re-run and reviewed against current advisories
- [ ] `DB_PASSWORD` and `JWT_SECRET` are strong, unique, and not the values in `.env.example`
- [ ] Default admin password (`ChangeMe!2026Admin`) has been changed
- [ ] `CORS_ALLOWED_ORIGINS` is your real domain, not `localhost`
- [ ] TLS is terminated in front of both services; HSTS header uncommented in `frontend/nginx.conf`
- [ ] `TRUST_PROXY_HEADERS` matches your actual deploy topology (see above)
- [ ] Database backups configured and tested
- [ ] A process exists for rotating `JWT_SECRET` and forcing re-login if it's ever compromised
