feat(security): pentest-driven hardening (Spring Security, BCrypt, SSRF, CSRF, audit, rate limit) - #40
Merged
Merged
Conversation
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>
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
force-pushed
the
feat/security-hardening
branch
from
April 27, 2026 23:32
d4d5d2b to
7b3dec3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
AuthContextResolvercontinues 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-controllerrequireSpaceAccess(...)semantics intact.What's fixed (pentest findings)
Secure + HttpOnly + SameSite=Lax. CSRF (double-submit cookie +X-XSRF-TOKEN) on cookie-auth requests; Bearer-auth bypassed.BRAIN_AUTH_DISABLEDremoved from prod profile +ProdProfileSecurityGuardrefuses 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).styleattribute removed from markdown sanitizer.requestedRoles ⊆ requesterRoles. JWT clock skew tolerance. Generic error responses +X-Request-Idcorrelation (no internal stack/path leaks).auditlogger.Architecture
Key design decisions
PasswordEncoderPortin application layer — keepsPasswordHasherSpring-free (theHexagonalArchitectureTestArchUnit-style enforcer still passes), bridges toBCryptPasswordEncoderfrom the adapter side.brain.security.{csrf-enabled,cookie-secure,trust-forwarded-for}andbrain.outbound.allow-private-addressesexist 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@TestPropertySourceto prove the prod posture.PasswordHasher.needsRehash()is the contract for callers to trigger persistence.Files changed
pom.xmlspring-boot-starter-security,spring-security-test.config/SecurityConfig.java,config/CsrfCookieFilter.java,config/ProdProfileSecurityGuard.java,config/BrainApplicationConfiguration.javaapplication/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.javaadapter/in/web/{ApiExceptionHandler,WikiController,AssetMimeGuard}.java,web/RequestIdFilter.java,application/service/apikey/ApiKeyService.java,application/service/user/UserManagementService.javaadapter/out/http/OutboundUrlGuard.java,adapter/out/http/llm/HttpLlmProviderCheckAdapter.javaadapter/out/filesystem/FileSystemWikiRepository.javaapplication/service/audit/AuditLogger.java+ wiring in user / api-key services / loginauditlogger for admin actions.frontend/src/lib/api.ts,frontend/src/features/preview/MarkdownPreview.tsx,frontend/src/index.cssstyleattr from sanitizer, fixed editor preview prose styles.src/main/resources/application-prod.propertiesauth-disabled=false, multipart limits 25MB.src/test/java/.../{LoginRateLimiterTest,OutboundUrlGuardTest,AssetMimeGuardTest,PasswordHasherTest,ProdProfileSecurityGuardTest,SecurityHeadersAndCsrfIntegrationTest}.java,src/test/java/.../testsupport/MutableClock.java,src/test/resources/application.propertiesConfiguration (new properties)
BRAIN_AUTH_DISABLEDis no longer consumed in the prod profile; it is hard-coded tofalseandProdProfileSecurityGuardwill refuse to boot ifbrain.auth-disabled=truearrives via any other source.Compatibility
$2a$12$...BCrypt. Migration is automatic on next successful login per user; no flag, no downtime.{ "error": "...", "requestId": "..." }— SpringErrorResponseexceptions are translated to this shape (withProblemDetail.detailas the message text) so the SPA's existing error reader keeps working.XSRF-TOKENand echoX-XSRF-TOKENon mutating requests. Bearer-token (API key) integrations are unaffected./import/markdown/plan|apply) keep their own contract and do not go throughAssetMimeGuard.