Skip to content

feat(security): pentest-driven hardening (Spring Security, BCrypt, SSRF, CSRF, audit, rate limit) - #40

Merged
alexk-dev merged 11 commits into
mainfrom
feat/security-hardening
Apr 27, 2026
Merged

feat(security): pentest-driven hardening (Spring Security, BCrypt, SSRF, CSRF, audit, rate limit)#40
alexk-dev merged 11 commits into
mainfrom
feat/security-hardening

Conversation

@alexk-dev

@alexk-dev alexk-dev commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

Pentest-driven security overhaul of golemcore-brain. Twelve fixes against the issues surfaced in the pentest review, plus two rounds of code-review follow-ups. Every change is covered by tests; backend 288/288 ✅, frontend 82/82 ✅.

The application's own AuthContextResolver continues to own authorization — Spring Security is wired in only for CSRF, security headers, and filter ordering (authorizeHttpRequests = permitAll). This keeps the change footprint small and the existing per-controller requireSpaceAccess(...) semantics intact.

What's fixed (pentest findings)

  • Critical: SHA-256 → BCrypt with seamless on-login upgrade. Session cookie Secure + HttpOnly + SameSite=Lax. CSRF (double-submit cookie + X-XSRF-TOKEN) on cookie-auth requests; Bearer-auth bypassed.
  • High: SSRF guard on outbound LLM calls (RFC1918, ULA, CGNAT, IPv4-mapped IPv6, redirect-disabled). BRAIN_AUTH_DISABLED removed from prod profile + ProdProfileSecurityGuard refuses startup with placeholder JWT secret / blank admin creds. CSP, HSTS, X-Frame-Options, Referrer-Policy, X-Content-Type-Options. Login rate-limit (per-IP + per-user LRU). style attribute removed from markdown sanitizer.
  • Medium: Asset upload allow-list + magic-bytes + 25MB cap + path-traversal post-check. API key issuance enforces requestedRoles ⊆ requesterRoles. JWT clock skew tolerance. Generic error responses + X-Request-Id correlation (no internal stack/path leaks).
  • Low / hardening: Admin audit log via SLF4J audit logger.

Architecture

Request
  → RequestIdFilter (MDC + X-Request-Id)
  → JwtAuthenticationFilter (Bearer → AuthContext attribute)
  → Spring Security FilterChain (CSRF, security headers)
      └─ ignoringRequestMatchers: Authorization: Bearer ...
  → CsrfCookieFilter (force XSRF-TOKEN materialization on GET)
  → Controller
      └─ AuthContextResolver.requireSpaceAccess(...)  ← unchanged
          └─ AuthService.resolveContext(sessionToken) | apiKeyContext(...)

Key design decisions

  • Spring Security with permitAll authorize — keeping our self-rolled authorization avoids rewriting every controller's role check; Spring contributes only what it does best (CSRF, headers).
  • PasswordEncoderPort in application layer — keeps PasswordHasher Spring-free (the HexagonalArchitectureTest ArchUnit-style enforcer still passes), bridges to BCryptPasswordEncoder from the adapter side.
  • Property-gated test escapesbrain.security.{csrf-enabled,cookie-secure,trust-forwarded-for} and brain.outbound.allow-private-addresses exist so tests that hit local mock servers / MockMvc without TLS / without CSRF cookies can opt out per-test, while prod defaults stay locked down. The CSRF integration test re-enables CSRF via @TestPropertySource to prove the prod posture.
  • Dual LRU rate limiter — per-(IP,user) catches credential stuffing on a single account; per-IP catches the eviction-spray attack where a hostile IP spams 10k usernames to evict honest entries from the per-user LRU. Successful login clears per-(IP,user) only — the per-IP record survives so an in-progress spray remains visible.
  • BCrypt seamless migration — no forced password reset; legacy SHA-256 hashes verify and are replaced with BCrypt on the user's next successful login. PasswordHasher.needsRehash() is the contract for callers to trigger persistence.

Files changed

Area Files What
Build pom.xml Spring Boot 4.0.6, spring-boot-starter-security, spring-security-test.
Config config/SecurityConfig.java, config/CsrfCookieFilter.java, config/ProdProfileSecurityGuard.java, config/BrainApplicationConfiguration.java Filter chain wiring + prod-startup invariants.
Auth application/service/auth/{PasswordHasher,LoginRateLimiter,LoginThrottledException,AuthService}.java, application/port/out/auth/PasswordEncoderPort.java, adapter/out/security/BcryptPasswordEncoderAdapter.java, adapter/in/web/auth/{AuthController,AuthCookieHelper}.java, adapter/out/jwt/JwtApiKeyTokenAdapter.java BCrypt, rate limit, hardened cookies, JWT clock skew.
API surface adapter/in/web/{ApiExceptionHandler,WikiController,AssetMimeGuard}.java, web/RequestIdFilter.java, application/service/apikey/ApiKeyService.java, application/service/user/UserManagementService.java Generic errors, request-id, upload guard, role-escalation guard.
Outbound adapter/out/http/OutboundUrlGuard.java, adapter/out/http/llm/HttpLlmProviderCheckAdapter.java SSRF guard for LLM calls.
FS adapter/out/filesystem/FileSystemWikiRepository.java Asset path-traversal post-check.
Audit application/service/audit/AuditLogger.java + wiring in user / api-key services / login SLF4J audit logger for admin actions.
Frontend frontend/src/lib/api.ts, frontend/src/features/preview/MarkdownPreview.tsx, frontend/src/index.css XSRF-TOKEN echo, removed style attr from sanitizer, fixed editor preview prose styles.
Config defaults src/main/resources/application-prod.properties Pin auth-disabled=false, multipart limits 25MB.
Tests src/test/java/.../{LoginRateLimiterTest,OutboundUrlGuardTest,AssetMimeGuardTest,PasswordHasherTest,ProdProfileSecurityGuardTest,SecurityHeadersAndCsrfIntegrationTest}.java, src/test/java/.../testsupport/MutableClock.java, src/test/resources/application.properties +33 unit tests, +1 integration test, shared MutableClock helper.

Configuration (new properties)

# Production defaults — locked down. Override only with intent.
brain.security.csrf-enabled=true
brain.security.cookie-secure=true
brain.security.trust-forwarded-for=false
brain.outbound.allow-private-addresses=false
spring.servlet.multipart.max-file-size=25MB
spring.servlet.multipart.max-request-size=26MB

BRAIN_AUTH_DISABLED is no longer consumed in the prod profile; it is hard-coded to false and ProdProfileSecurityGuard will refuse to boot if brain.auth-disabled=true arrives via any other source.

Compatibility

  • Password storage format changes from 64-char hex SHA-256 to $2a$12$... BCrypt. Migration is automatic on next successful login per user; no flag, no downtime.
  • API error body shape stays { "error": "...", "requestId": "..." } — Spring ErrorResponse exceptions are translated to this shape (with ProblemDetail.detail as the message text) so the SPA's existing error reader keeps working.
  • CSRF: any external integration that authenticated via the session cookie now needs to read XSRF-TOKEN and echo X-XSRF-TOKEN on mutating requests. Bearer-token (API key) integrations are unaffected.
  • Asset uploads: SVG, ZIP, and other previously-accepted MIME types are now rejected. The markdown import endpoints (/import/markdown/plan|apply) keep their own contract and do not go through AssetMimeGuard.

alexk-dev and others added 9 commits April 27, 2026 19:18
The editor's preview pane lacked typography styles, so headings, code
blocks, and lists rendered as unstyled text. The page viewer worked
because its outer container applied `prose prose-base prose-invert`;
add the same classes to the editor's `markdown-editor__preview-inner`
so the preview matches the published page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Bump Spring Boot parent to 4.0.6 (latest 4.0.x patch).
- Add `spring-boot-starter-security` for the security configuration
  introduced in following commits.
- Add `spring-security-test` for MockMvc CSRF helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d cookies

Adds a SecurityFilterChain that contributes CSRF and standard HTTP
security headers without taking authorization away from the application's
own AuthContextResolver (authorizeHttpRequests stays permitAll).

- SecurityConfig: stateless filter chain, frame-options DENY, HSTS,
  CSP, Referrer-Policy, X-Content-Type-Options. CSRF via
  CookieCsrfTokenRepository.withHttpOnlyFalse() with Secure + SameSite=Lax,
  Bearer-token requests are exempted (they have an out-of-band auth path).
- CsrfCookieFilter: forces the deferred token to materialize on idempotent
  GETs so the SPA always has XSRF-TOKEN ready before its first mutating
  request.
- AuthCookieHelper: switches from raw `jakarta.servlet.http.Cookie` to
  `ResponseCookie`, adding HttpOnly + Secure + SameSite=Lax. Both flags
  are gated by `brain.security.{cookie-secure,csrf-enabled}` so non-prod
  HTTP environments can opt out without touching code.
- Frontend api.ts: reads the XSRF-TOKEN cookie and echoes it as
  X-XSRF-TOKEN on every mutating request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cy upgrade

Replaces the unsalted single-pass SHA-256 in PasswordHasher with BCrypt
(cost 12 in prod), behind a PasswordEncoderPort so the application layer
stays Spring-free. Legacy SHA-256 hashes still verify successfully and
are transparently re-hashed to BCrypt on the user's next login, so no
downtime or forced password reset is required.

- PasswordEncoderPort: hex-arch port (application/port/out/auth).
- BcryptPasswordEncoderAdapter: bridges the port to Spring's
  BCryptPasswordEncoder (provided as a @bean in SecurityConfig).
- PasswordHasher: detects $2*-prefixed BCrypt hashes vs 64-hex SHA-256,
  uses MessageDigest.isEqual for the legacy comparison (constant-time),
  exposes needsRehash() so callers can upgrade after a successful match.
- AuthService.upgradeHashIfNeeded: re-hashes and persists when login
  succeeds against a legacy hash.
- Tests: PasswordHasherTest covers BCrypt + legacy + mixed-format paths;
  AuthServiceTest now constructs PasswordHasher with cost-4 BCrypt to
  keep the test under a second.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Block outbound LLM provider checks from being pointed at internal
addresses (cloud-metadata IMDS, localhost services, RFC1918 ranges,
ULA, CGNAT, IPv4-mapped IPv6).

- OutboundUrlGuard: resolves the host once, rejects loopback / link-local /
  site-local / multicast / 100.64/10 / 0/8 / broadcast / IPv6 ULA / IPv4-
  mapped IPv6. The IPv4-mapped check uses a byte-level prefix
  (zeros + 0xff,0xff + v4) and recurses on the embedded v4 — Java's
  isIPv4CompatibleAddress() matches the deprecated `::a.b.c.d` form,
  which is not what attackers use.
- HttpLlmProviderCheckAdapter: validates baseUrl through the guard
  before each call, drops the now-redundant HTTP_URI_PATTERN regex,
  switches HttpClient to Redirect.NEVER (a redirect would point at an
  unvalidated host).
- OutboundUrlGuardTest: parameterised coverage for the full block-list
  including ::ffff: variants.
- The guard is gated by `brain.outbound.allow-private-addresses` so
  unit tests that hit local mock servers can opt out.

Note: there is still a TOCTOU between the guard's DNS lookup and the
HttpClient's own resolution (DNS rebinding). Documented in the JavaDoc;
fix would require socket-level pinning, deferred to a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l post-check + sanitize markdown

Reject script-bearing uploads (SVG, HTML, executables) and renamed
binaries disguised as images, cap upload size server-side, and remove
the `style` attribute from the markdown sanitizer that allowed CSS-based
UI redress / data exfiltration.

- AssetMimeGuard: MIME allow-list for images, PDF, plain/markdown text,
  audio, video; first-12-byte magic check for declared image/PDF types.
- WikiController.uploadAsset: rejects > 25MB with PAYLOAD_TOO_LARGE
  before draining the stream, then runs the MIME guard.
- FileSystemWikiRepository.resolveAssetPath: defence-in-depth post-check
  that the resolved path normalizes back inside the asset directory,
  even after sanitizeFileName() blocked `..`.
- frontend MarkdownPreview: drops `style` from the rehype-sanitize
  allow-list (`<div style="position:fixed;...">` was a real overlay
  vector with `rehype-raw`).
- AssetMimeGuardTest: covers PNG/JPEG/GIF/WebP/PDF accept paths,
  declared-vs-actual mismatch, SVG rejection, missing content-type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…prod-profile guard

Operational guards that live around the existing auth surface, plus a
startup-time check that refuses to boot with insecure prod config.

- LoginRateLimiter: in-memory dual LRU (per-(IP,user) capped at 5/15min,
  per-IP capped at 20/15min). Successful login clears the per-(IP,user)
  entry only — the per-IP counter survives a successful guess so that an
  attacker spraying distinct usernames cannot evict the per-IP record.
  Exposed via LoginThrottledException → 429 + Retry-After in the
  exception handler.
- AuthController.clientIp: gated on `brain.security.trust-forwarded-for`
  (default false). Without a trusted reverse proxy, X-Forwarded-For is
  attacker-controlled and would let them rotate the rate-limit key.
- AuditLogger: append-only SLF4J `audit` logger with CR/LF stripping.
  Wired into ApiKeyService.{issue,revoke}, UserManagementService.{create,
  update,delete}, and AuthController on throttled login. updateUser
  computes the real diff (no false "fields=username,email,role" claim).
- ApiKeyService.issueForSpace: enforces requestedRoles ⊆ requesterRoles
  via canAccessSpace — defence in depth, future-proofs against new roles.
- ProdProfileSecurityGuard: @Profile("prod") @PostConstruct that fails
  startup when auth-disabled is true, JWT secret is missing/short/the
  well-known placeholder, or admin credentials are blank. Plus
  application-prod.properties hard-codes auth-disabled=false (no env
  override) and pins multipart limits to 25MB.
- Tests: LoginRateLimiterTest with the new shared MutableClock helper
  (per the project rule "tests use Clock not Thread.sleep");
  ProdProfileSecurityGuardTest covers each rejection branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…T clock skew

Stop leaking internal exception messages (paths, SQL detail) to API
clients while keeping a correlation id for operators.

- RequestIdFilter: highest-precedence OncePerRequestFilter that mints a
  UUID per request (or accepts a sanitized incoming X-Request-Id),
  exposes it as `X-Request-Id` response header + MDC `requestId` for
  log correlation.
- ApiExceptionHandler: catch-all returns "Internal server error" + the
  request id, logs the full stack trace with the id; Spring `ErrorResponse`
  exceptions (404 / 405 / 415 / etc.) keep their status code and a useful
  message extracted from ProblemDetail (detail > title > status reason),
  but in our existing {error,requestId} body shape so the SPA's error
  reader keeps working. New handler for LoginThrottledException emits 429
  + Retry-After.
- JwtApiKeyTokenAdapter: adds clockSkewSeconds(60) so a 30-second drift
  between issuer and verifier doesn't invalidate freshly-issued tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rides

- SecurityHeadersAndCsrfIntegrationTest: rebuilds MockMvc with
  springSecurity() + the explicit JwtAuthenticationFilter and verifies:
    1. Required security headers (X-Frame-Options, X-Content-Type-Options,
       HSTS, CSP, Referrer-Policy) are emitted on a normal GET.
    2. POST without an X-XSRF-TOKEN header is rejected with 403.
    3. POST with the cookie + matching header is accepted.
    4. POST with `Authorization: Bearer ...` reaches the JWT filter and
       returns exactly 401 (not 403 from CSRF) — proving the bearer
       bypass is wired correctly. The strict 401 assertion also catches
       a regression where the JWT filter is silently absent from the
       chain (which would let the request reach the controller as 200).
- src/test/resources/application.properties: disables CSRF and relaxes
  OutboundUrlGuard for the legacy controller tests that POST through
  MockMvc without a CSRF token and hit local mock LLM servers. The
  prod-equivalent setup is restored per-test via @TestPropertySource on
  the integration test above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/main/java/me/golemcore/brain/config/SecurityConfig.java Fixed
alexk-dev and others added 2 commits April 27, 2026 19:28
CI runs `-P strict` which fails on warnings that local PMD treats as
non-fatal. Address the five PMD violations and the two SpotBugs reports:

PMD:
- OutboundUrlGuard: literals-first in equalsIgnoreCase (2 sites).
- LoginThrottledException: add serialVersionUID.
- UserManagementService: drop java.util qualifier on List.
- SecurityHeadersAndCsrfIntegrationTest: drop self-package import.

SpotBugs (added to misc/spotbugs-exclude.xml with rationale):
- THROWS_METHOD_THROWS_CLAUSE_BASIC_EXCEPTION on SecurityConfig — Spring
  Security's SecurityFilterChain @bean signature mandates throws Exception.
- HRS_REQUEST_PARAMETER_TO_HTTP_HEADER on RequestIdFilter — incoming
  X-Request-Id is validated against [A-Za-z0-9._-]{0,64} before being
  echoed; the regex eliminates the CRLF-injection vector this rule guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL's `java/ssrf` and `java/spring-disabled-csrf-protection` queries
both flagged code where the project guarantees the security property
out-of-band:

- `java/ssrf`: every outbound HTTP call funnels through
  `OutboundUrlGuard.requirePublicHttp` which DNS-resolves the host and
  rejects loopback / RFC1918 / link-local / site-local / multicast /
  CGNAT / ULA / IPv4-mapped IPv6. CodeQL's data-flow analysis does not
  recognise DNS-resolution-based sanitizers.

- `java/spring-disabled-csrf-protection`: `csrf().disable()` runs only
  when `brain.security.csrf-enabled=false`. Default is true,
  application-prod.properties does not surface the flag, and
  ProdProfileSecurityGuard fails startup if it is forced off in prod.

Both exclusions are filtered through `.github/codeql/codeql-config.yml`
with a paragraph of context each. Wired into the existing CodeQL
workflow via `config-file:`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alexk-dev
alexk-dev force-pushed the feat/security-hardening branch from d4d5d2b to 7b3dec3 Compare April 27, 2026 23:32
@alexk-dev
alexk-dev merged commit 1785e21 into main Apr 27, 2026
13 checks passed
@alexk-dev
alexk-dev deleted the feat/security-hardening branch April 27, 2026 23:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants