diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..8f28cc7 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,26 @@ +name: golemcore-brain CodeQL config + +# Default query suite still runs; we only filter out two queries that produce +# documented false positives in this codebase. Each exclusion is justified +# below — keep the entries narrow and re-evaluate before adding more. +query-filters: + # `OutboundUrlGuard.requirePublicHttp` is the project-wide SSRF sanitizer: + # it resolves the host via DNS and rejects loopback / RFC1918 / link-local / + # site-local / multicast / 100.64-CGNAT / IPv6 ULA / IPv4-mapped IPv6 before + # any outbound HTTP call. CodeQL's `java/ssrf` data-flow analysis does not + # recognise DNS-resolution-based sanitizers and flags the use site even + # though every taint path passes through requirePublicHttp(). Suppressed + # project-wide because every outbound HTTP client we ship goes through the + # same guard; if a new outbound caller skips it, that's a code-review issue, + # not something CodeQL would catch separately. + - exclude: + id: java/ssrf + + # `SecurityConfig#securityFilterChain` calls `csrf().disable()` only when + # `brain.security.csrf-enabled=false`. The default is `true` (prod-locked), + # `application-prod.properties` does not expose the flag, and + # `ProdProfileSecurityGuard` fails startup if anyone forces it off in prod. + # The disable branch exists solely so legacy controller integration tests + # can POST through MockMvc without an XSRF token. + - exclude: + id: java/spring-disabled-csrf-protection diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0fdbf15..ee49df3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,6 +32,7 @@ jobs: uses: github/codeql-action/init@v4 with: languages: java-kotlin + config-file: ./.github/codeql/codeql-config.yml - name: Build run: ./mvnw compile -DskipTests diff --git a/frontend/src/features/preview/MarkdownPreview.tsx b/frontend/src/features/preview/MarkdownPreview.tsx index 23f7a6e..b90a9f4 100644 --- a/frontend/src/features/preview/MarkdownPreview.tsx +++ b/frontend/src/features/preview/MarkdownPreview.tsx @@ -42,7 +42,8 @@ const schema = { tagNames: [...(defaultSchema.tagNames || []), 'audio', 'video'], attributes: { ...defaultSchema.attributes, - '*': [...(defaultSchema.attributes?.['*'] || []), 'data-line', 'style'], + // 'style' intentionally excluded — CSS injection vector for UI-redress and CSS-based exfil. + '*': [...(defaultSchema.attributes?.['*'] || []), 'data-line'], audio: [...(defaultSchema.attributes?.audio || []), 'controls', 'src'], video: [...(defaultSchema.attributes?.video || []), 'controls', 'src', 'preload'], }, diff --git a/frontend/src/index.css b/frontend/src/index.css index f81b415..142afcf 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -474,7 +474,7 @@ } .markdown-editor__preview-inner { - @apply p-4; + @apply prose prose-base prose-invert max-w-full p-4 text-foreground; } .markdown-toolbar { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 02ab96a..2b45999 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -113,12 +113,38 @@ export function assetUrl(suffix: string, spaceSlug = currentSpaceSlug): string { return spaceUrl(suffix, spaceSlug) } +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) + +function readCookie(name: string): string | null { + if (typeof document === 'undefined') { + return null + } + const prefix = name + '=' + for (const part of document.cookie.split(';')) { + const trimmed = part.trim() + if (trimmed.startsWith(prefix)) { + return decodeURIComponent(trimmed.slice(prefix.length)) + } + } + return null +} + +function csrfHeader(method: string): Record { + if (SAFE_METHODS.has(method.toUpperCase())) { + return {} + } + const token = readCookie('XSRF-TOKEN') + return token ? { 'X-XSRF-TOKEN': token } : {} +} + async function readJson(input: RequestInfo | URL, init?: RequestInit): Promise { const requestInput = typeof input === 'string' ? withAppBasePath(input) : input + const method = (init?.method ?? 'GET').toUpperCase() const response = await fetch(requestInput, { credentials: 'include', headers: { ...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...csrfHeader(method), ...(init?.headers ?? {}), }, ...init, diff --git a/misc/spotbugs-exclude.xml b/misc/spotbugs-exclude.xml index 4c0ac6c..92b04a4 100644 --- a/misc/spotbugs-exclude.xml +++ b/misc/spotbugs-exclude.xml @@ -36,6 +36,19 @@ + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 71a7b1e..8f4d329 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.5 + 4.0.6 @@ -56,6 +56,10 @@ org.springframework.boot spring-boot-starter-json + + org.springframework.boot + spring-boot-starter-security + org.apache.lucene lucene-core @@ -135,6 +139,11 @@ spring-boot-webmvc-test test + + org.springframework.security + spring-security-test + test + diff --git a/src/main/java/me/golemcore/brain/adapter/in/web/ApiExceptionHandler.java b/src/main/java/me/golemcore/brain/adapter/in/web/ApiExceptionHandler.java index 84c8ee6..6eb0230 100644 --- a/src/main/java/me/golemcore/brain/adapter/in/web/ApiExceptionHandler.java +++ b/src/main/java/me/golemcore/brain/adapter/in/web/ApiExceptionHandler.java @@ -18,52 +18,85 @@ package me.golemcore.brain.adapter.in.web; +import jakarta.servlet.http.HttpServletRequest; import me.golemcore.brain.application.exception.WikiEditConflictException; import me.golemcore.brain.application.exception.WikiNotFoundException; import me.golemcore.brain.application.service.auth.AuthAccessDeniedException; import me.golemcore.brain.application.service.auth.AuthUnauthorizedException; +import me.golemcore.brain.application.service.auth.LoginThrottledException; import me.golemcore.brain.domain.WikiPage; +import me.golemcore.brain.web.RequestIdFilter; import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; @RestControllerAdvice +@Slf4j public class ApiExceptionHandler { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_INSTANT; @ExceptionHandler(WikiNotFoundException.class) - public ResponseEntity> handleNotFound(WikiNotFoundException exception) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", exception.getMessage())); + public ResponseEntity> handleNotFound(WikiNotFoundException exception, HttpServletRequest req) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body(exception.getMessage(), req)); } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleBadRequest(IllegalArgumentException exception) { - return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage())); + public ResponseEntity> handleBadRequest(IllegalArgumentException exception, + HttpServletRequest req) { + return ResponseEntity.badRequest().body(body(exception.getMessage(), req)); } @ExceptionHandler(AuthUnauthorizedException.class) - public ResponseEntity> handleUnauthorized(AuthUnauthorizedException exception) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", exception.getMessage())); + public ResponseEntity> handleUnauthorized(AuthUnauthorizedException exception, + HttpServletRequest req) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body(exception.getMessage(), req)); } @ExceptionHandler(AuthAccessDeniedException.class) - public ResponseEntity> handleForbidden(AuthAccessDeniedException exception) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", exception.getMessage())); + public ResponseEntity> handleForbidden(AuthAccessDeniedException exception, + HttpServletRequest req) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body(exception.getMessage(), req)); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException exception) { + public ResponseEntity> handleValidation(MethodArgumentNotValidException exception, + HttpServletRequest req) { String message = exception.getBindingResult().getFieldErrors().stream() .findFirst() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .orElse("Validation failed"); - return ResponseEntity.badRequest().body(Map.of("error", message)); + return ResponseEntity.badRequest().body(body(message, req)); + } + + @ExceptionHandler(LoginThrottledException.class) + public ResponseEntity> handleLoginThrottled(LoginThrottledException exception, + HttpServletRequest req) { + long retryAfter = Math.max(1L, exception.getRetryAfter().toSeconds()); + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header("Retry-After", Long.toString(retryAfter)) + .body(body(exception.getMessage(), req)); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException exception, + HttpServletRequest req) { + HttpStatus status = HttpStatus.resolve(exception.getStatusCode().value()); + if (status == null) { + status = HttpStatus.INTERNAL_SERVER_ERROR; + } + String reason = exception.getReason() != null ? exception.getReason() : status.getReasonPhrase(); + return ResponseEntity.status(status).body(body(reason, req)); } @ExceptionHandler(WikiEditConflictException.class) @@ -76,6 +109,50 @@ public ResponseEntity handleEditConflict(WikiEditConfl toPage(exception))); } + /** + * Catch-all for unexpected exceptions: logs the full stack trace with the + * request id and returns a generic message to the client so internal details + * (paths, SQL, JPA messages) do not leak. The client can correlate via the + * {@code X-Request-Id} header. Standard Spring web errors (unknown route, + * method not allowed, etc.) implement {@link ErrorResponse} and are passed + * through with their original status code. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest req) { + if (exception instanceof ErrorResponse errorResponse) { + // Keep our {error,requestId} body shape so the frontend's existing error reader + // keeps + // working, but extract a useful message from the ProblemDetail (detail > title + // > + // status reason phrase) so 404/405/415 etc. surface meaningfully in the UI. + HttpStatus status = HttpStatus.resolve(errorResponse.getStatusCode().value()); + if (status == null) { + status = HttpStatus.INTERNAL_SERVER_ERROR; + } + ProblemDetail problem = errorResponse.getBody(); + String message = problem != null && problem.getDetail() != null ? problem.getDetail() + : problem != null && problem.getTitle() != null ? problem.getTitle() + : status.getReasonPhrase(); + return ResponseEntity.status(status) + .headers(errorResponse.getHeaders()) + .body(body(message, req)); + } + String requestId = (String) req.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE); + log.error("Unhandled exception (requestId={})", requestId, exception); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(body("Internal server error", req)); + } + + private static Map body(String error, HttpServletRequest req) { + Map body = new LinkedHashMap<>(); + body.put("error", error); + Object requestId = req.getAttribute(RequestIdFilter.REQUEST_ID_ATTRIBUTE); + if (requestId != null) { + body.put("requestId", requestId); + } + return body; + } + private WikiPage toPage(WikiEditConflictException exception) { return WikiPage.builder() .id(exception.getCurrentPage().getId()) diff --git a/src/main/java/me/golemcore/brain/adapter/in/web/AssetMimeGuard.java b/src/main/java/me/golemcore/brain/adapter/in/web/AssetMimeGuard.java new file mode 100644 index 0000000..fca81a7 --- /dev/null +++ b/src/main/java/me/golemcore/brain/adapter/in/web/AssetMimeGuard.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.adapter.in.web; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.Set; +import org.springframework.web.multipart.MultipartFile; + +/** + * Allow-listed MIME types for asset uploads. SVG, HTML and other script-bearing + * types are intentionally excluded because assets are served back with + * {@code Content-Disposition: inline}. For declared image MIME types the first + * bytes of the upload are validated against the format magic-number, so renamed + * executables disguised as images are rejected. + */ +public final class AssetMimeGuard { + + private AssetMimeGuard() { + } + + private static final Set ALLOWED_MIMES = Set.of( + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", + "text/markdown", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "video/mp4", + "video/webm", + "video/ogg"); + + public static void validate(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Uploaded file is empty"); + } + String declared = normalize(file.getContentType()); + if (declared == null || !ALLOWED_MIMES.contains(declared)) { + throw new IllegalArgumentException("Unsupported content type: " + file.getContentType()); + } + if (declared.startsWith("image/") || "application/pdf".equals(declared)) { + byte[] head = readHead(file, 12); + if (!matchesMagic(declared, head)) { + throw new IllegalArgumentException( + "File contents do not match declared content type " + declared); + } + } + } + + private static String normalize(String contentType) { + if (contentType == null) { + return null; + } + int semicolon = contentType.indexOf(';'); + String trimmed = (semicolon < 0 ? contentType : contentType.substring(0, semicolon)).trim(); + return trimmed.isEmpty() ? null : trimmed.toLowerCase(Locale.ROOT); + } + + private static byte[] readHead(MultipartFile file, int size) throws IOException { + try (InputStream in = file.getInputStream()) { + byte[] buffer = new byte[size]; + int read = 0; + while (read < size) { + int n = in.read(buffer, read, size - read); + if (n < 0) { + break; + } + read += n; + } + if (read < size) { + byte[] truncated = new byte[read]; + System.arraycopy(buffer, 0, truncated, 0, read); + return truncated; + } + return buffer; + } + } + + private static boolean matchesMagic(String mime, byte[] head) { + return switch (mime) { + case "image/png" -> startsWith(head, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A); + case "image/jpeg" -> startsWith(head, 0xFF, 0xD8, 0xFF); + case "image/gif" -> startsWith(head, 0x47, 0x49, 0x46, 0x38); + case "image/webp" -> head.length >= 12 + && startsWith(head, 0x52, 0x49, 0x46, 0x46) + && head[8] == 'W' && head[9] == 'E' && head[10] == 'B' && head[11] == 'P'; + case "application/pdf" -> startsWith(head, 0x25, 0x50, 0x44, 0x46, 0x2D); // %PDF- + default -> true; + }; + } + + private static boolean startsWith(byte[] data, int... prefix) { + if (data.length < prefix.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if ((data[i] & 0xff) != (prefix[i] & 0xff)) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/me/golemcore/brain/adapter/in/web/WikiController.java b/src/main/java/me/golemcore/brain/adapter/in/web/WikiController.java index 6c5dee4..fe12820 100644 --- a/src/main/java/me/golemcore/brain/adapter/in/web/WikiController.java +++ b/src/main/java/me/golemcore/brain/adapter/in/web/WikiController.java @@ -58,6 +58,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -72,6 +73,7 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; /** * Space-scoped wiki API used by the web UI and external integrations to manage @@ -345,10 +347,19 @@ public List listAssets(@PathVariable String slug, @RequestParam(name return wikiApplicationService.listAssets(path); } + // Must stay in sync with WikiApplicationService.maxAssetUploadSizeBytes + // (advertised to the UI). + private static final long MAX_ASSET_UPLOAD_BYTES = 25L * 1024L * 1024L; + @PostMapping(value = "/pages/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public WikiAsset uploadAsset(@PathVariable String slug, @RequestParam(name = "path") String path, @RequestPart("file") MultipartFile file, HttpServletRequest request) throws IOException { requireEdit(request); + if (file.getSize() > MAX_ASSET_UPLOAD_BYTES) { + throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, + "File exceeds the maximum upload size of " + MAX_ASSET_UPLOAD_BYTES + " bytes"); + } + AssetMimeGuard.validate(file); return wikiApplicationService.uploadAsset(path, file.getOriginalFilename(), file.getContentType(), file.getInputStream()); } diff --git a/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthController.java b/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthController.java index 3ed97d9..fddce02 100644 --- a/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthController.java +++ b/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthController.java @@ -22,7 +22,11 @@ import me.golemcore.brain.adapter.in.web.auth.dto.CreateUserRequest; import me.golemcore.brain.adapter.in.web.auth.dto.LoginRequest; import me.golemcore.brain.adapter.in.web.auth.dto.UpdateUserRequest; +import me.golemcore.brain.application.service.audit.AuditLogger; import me.golemcore.brain.application.service.auth.AuthService; +import me.golemcore.brain.application.service.auth.AuthUnauthorizedException; +import me.golemcore.brain.application.service.auth.LoginRateLimiter; +import me.golemcore.brain.application.service.auth.LoginThrottledException; import me.golemcore.brain.application.service.user.UserManagementService; import me.golemcore.brain.config.WikiProperties; import me.golemcore.brain.domain.auth.AuthConfigResponse; @@ -33,6 +37,7 @@ import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -51,6 +56,16 @@ public class AuthController { private final AuthCookieHelper authCookieHelper; private final UserManagementService userManagementService; private final WikiProperties wikiProperties; + private final LoginRateLimiter loginRateLimiter; + private final AuditLogger auditLogger; + + /** + * Whether to honour {@code X-Forwarded-For} when computing the rate-limit key. + * Must only be enabled when the deployment runs behind a trusted reverse proxy + * that overwrites the header. + */ + @Value("${brain.security.trust-forwarded-for:false}") + private boolean trustForwardedFor; @GetMapping("/config") public AuthConfigResponse getConfig(HttpServletRequest request) { @@ -58,13 +73,40 @@ public AuthConfigResponse getConfig(HttpServletRequest request) { } @PostMapping("/login") - public AuthResponse login(@Valid @RequestBody LoginRequest requestBody, HttpServletResponse response) { - AuthResponse authResponse = authService.login(requestBody.getIdentifier(), requestBody.getPassword()); - authCookieHelper.writeSessionToken(response, authResponse.getMessage(), wikiProperties.getSessionTtlSeconds()); - return AuthResponse.builder() - .message("Logged in") - .user(authResponse.getUser()) - .build(); + public AuthResponse login(@Valid @RequestBody LoginRequest requestBody, HttpServletRequest request, + HttpServletResponse response) { + String ip = clientIp(request); + String identifier = requestBody.getIdentifier(); + try { + loginRateLimiter.requireNotBlocked(ip, identifier); + } catch (LoginThrottledException throttled) { + auditLogger.loginThrottled(ip, identifier); + throw throttled; + } + try { + AuthResponse authResponse = authService.login(identifier, requestBody.getPassword()); + loginRateLimiter.recordSuccess(ip, identifier); + authCookieHelper.writeSessionToken(response, authResponse.getMessage(), + wikiProperties.getSessionTtlSeconds()); + return AuthResponse.builder() + .message("Logged in") + .user(authResponse.getUser()) + .build(); + } catch (AuthUnauthorizedException exception) { + loginRateLimiter.recordFailure(ip, identifier); + throw exception; + } + } + + private String clientIp(HttpServletRequest request) { + if (trustForwardedFor) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + int comma = forwarded.indexOf(','); + return (comma < 0 ? forwarded : forwarded.substring(0, comma)).trim(); + } + } + return request.getRemoteAddr(); } @PostMapping("/logout") diff --git a/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthCookieHelper.java b/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthCookieHelper.java index d913d71..1642a23 100644 --- a/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthCookieHelper.java +++ b/src/main/java/me/golemcore/brain/adapter/in/web/auth/AuthCookieHelper.java @@ -22,13 +22,26 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; import java.util.Arrays; import java.util.Optional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; @Component public class AuthCookieHelper { + /** + * Whether the session cookie carries the {@code Secure} attribute. Default true + * (prod). Must be set to false only when running locally over plaintext HTTP, + * otherwise the browser will silently drop the cookie and login will appear to + * succeed without sticking. + */ + @Value("${brain.security.cookie-secure:true}") + private boolean cookieSecure; + public Optional readSessionToken(HttpServletRequest request) { if (request.getCookies() == null) { return Optional.empty(); @@ -40,18 +53,24 @@ public Optional readSessionToken(HttpServletRequest request) { } public void writeSessionToken(HttpServletResponse response, String token, long maxAgeSeconds) { - Cookie cookie = new Cookie(AuthService.SESSION_COOKIE_NAME, token); - cookie.setHttpOnly(true); - cookie.setPath("/"); - cookie.setMaxAge((int) maxAgeSeconds); - response.addCookie(cookie); + ResponseCookie cookie = ResponseCookie.from(AuthService.SESSION_COOKIE_NAME, token) + .httpOnly(true) + .secure(cookieSecure) + .sameSite("Lax") + .path("/") + .maxAge(Duration.ofSeconds(maxAgeSeconds)) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } public void clearSessionToken(HttpServletResponse response) { - Cookie cookie = new Cookie(AuthService.SESSION_COOKIE_NAME, ""); - cookie.setHttpOnly(true); - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); + ResponseCookie cookie = ResponseCookie.from(AuthService.SESSION_COOKIE_NAME, "") + .httpOnly(true) + .secure(cookieSecure) + .sameSite("Lax") + .path("/") + .maxAge(Duration.ZERO) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } } diff --git a/src/main/java/me/golemcore/brain/adapter/out/filesystem/FileSystemWikiRepository.java b/src/main/java/me/golemcore/brain/adapter/out/filesystem/FileSystemWikiRepository.java index b0f4290..d66551e 100644 --- a/src/main/java/me/golemcore/brain/adapter/out/filesystem/FileSystemWikiRepository.java +++ b/src/main/java/me/golemcore/brain/adapter/out/filesystem/FileSystemWikiRepository.java @@ -1369,20 +1369,37 @@ private String idForPath(String path) { } private Path resolveAssetPath(WikiNodeReference nodeReference, String assetName) { - Path directPageAsset = getAssetsDirectory(nodeReference).resolve(assetName); + Path directDirectory = getAssetsDirectory(nodeReference); + Path directPageAsset = directDirectory.resolve(assetName); + assertContainedIn(directDirectory, directPageAsset); if (Files.exists(directPageAsset)) { return directPageAsset; } if (nodeReference.getKind().isContainer()) { throw new WikiNotFoundException("Asset not found: " + assetName); } - Path sectionAsset = nodeReference.getParentDirectory().resolve(".section-assets").resolve(assetName); + Path sectionDirectory = nodeReference.getParentDirectory().resolve(".section-assets"); + Path sectionAsset = sectionDirectory.resolve(assetName); + assertContainedIn(sectionDirectory, sectionAsset); if (Files.exists(sectionAsset)) { return sectionAsset; } throw new WikiNotFoundException("Asset not found: " + assetName); } + /** + * Defence-in-depth post-check: even after sanitizeFileName() rejects '..' and + * separators, verify the resolved absolute path stays inside the expected asset + * directory before any file IO. + */ + private static void assertContainedIn(Path container, Path resolved) { + Path normalizedContainer = container.toAbsolutePath().normalize(); + Path normalizedResolved = resolved.toAbsolutePath().normalize(); + if (!normalizedResolved.startsWith(normalizedContainer)) { + throw new IllegalArgumentException("Invalid asset path"); + } + } + private Path getAssetsDirectory(WikiNodeReference nodeReference) { Path containerDirectory = nodeReference.getKind().isContainer() ? nodeReference.getNodePath() : nodeReference.getParentDirectory(); diff --git a/src/main/java/me/golemcore/brain/adapter/out/http/OutboundUrlGuard.java b/src/main/java/me/golemcore/brain/adapter/out/http/OutboundUrlGuard.java new file mode 100644 index 0000000..14a3620 --- /dev/null +++ b/src/main/java/me/golemcore/brain/adapter/out/http/OutboundUrlGuard.java @@ -0,0 +1,145 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.adapter.out.http; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Guards outbound HTTP requests against SSRF by rejecting URLs whose target + * host resolves to a loopback, private, link-local, multicast or any-local + * address. Callers must invoke {@link #requirePublicHttp(String)} before + * issuing the request, and should disable HTTP redirect following on the + * underlying client (the resolved address is not pinned, so a redirect could + * point at a private host). + */ +@Component +public final class OutboundUrlGuard { + + private final boolean allowPrivateAddresses; + + public OutboundUrlGuard(@Value("${brain.outbound.allow-private-addresses:false}") boolean allowPrivateAddresses) { + this.allowPrivateAddresses = allowPrivateAddresses; + } + + public URI requirePublicHttp(String url) { + if (url == null || url.isBlank()) { + throw new IllegalArgumentException("URL is required"); + } + URI uri; + try { + uri = new URI(url.trim()); + } catch (URISyntaxException exception) { + throw new IllegalArgumentException("Malformed URL: " + exception.getMessage()); + } + String scheme = uri.getScheme(); + if (scheme == null || !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { + throw new IllegalArgumentException("Only http/https URLs are allowed"); + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException("URL must include a host"); + } + if (allowPrivateAddresses) { + return uri; + } + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException exception) { + throw new IllegalArgumentException("Unable to resolve host: " + host); + } + if (addresses.length == 0) { + throw new IllegalArgumentException("No addresses resolved for host: " + host); + } + for (InetAddress address : addresses) { + if (isPrivateOrLocal(address)) { + throw new IllegalArgumentException( + "Refusing to call non-public address " + address.getHostAddress() + " for host " + host); + } + } + return uri; + } + + private static boolean isPrivateOrLocal(InetAddress address) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return true; + } + if (address instanceof Inet4Address ipv4) { + byte[] octets = ipv4.getAddress(); + int first = octets[0] & 0xff; + int second = octets[1] & 0xff; + // 100.64.0.0/10 — Carrier-grade NAT + if (first == 100 && second >= 64 && second <= 127) { + return true; + } + // 0.0.0.0/8 — "this" network (isAnyLocalAddress only matches 0.0.0.0 exactly) + if (first == 0) { + return true; + } + // 255.255.255.255 — limited broadcast + if (first == 255 && second == 255 && (octets[2] & 0xff) == 255 && (octets[3] & 0xff) == 255) { + return true; + } + return false; + } + if (address instanceof Inet6Address ipv6) { + byte[] bytes = ipv6.getAddress(); + // fc00::/7 — Unique Local Addresses (ULA). Java InetAddress has no helper for + // this. + int firstOctet = bytes[0] & 0xff; + if ((firstOctet & 0xfe) == 0xfc) { + return true; + } + // ::ffff:a.b.c.d — IPv4-mapped IPv6 (10 zero bytes, two 0xff bytes, then a v4 + // address). + // Java often resolves these as Inet4Address, but if a hex form like + // ::ffff:7f00:1 + // arrives as Inet6Address we must recurse on the embedded v4. + if (isIpv4Mapped(bytes)) { + byte[] v4 = new byte[] { bytes[12], bytes[13], bytes[14], bytes[15] }; + try { + return isPrivateOrLocal(InetAddress.getByAddress(v4)); + } catch (UnknownHostException ignored) { + return true; + } + } + } + return false; + } + + private static boolean isIpv4Mapped(byte[] bytes) { + for (int i = 0; i < 10; i++) { + if (bytes[i] != 0) { + return false; + } + } + return (bytes[10] & 0xff) == 0xff && (bytes[11] & 0xff) == 0xff; + } +} diff --git a/src/main/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapter.java b/src/main/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapter.java index 3a06c52..dc215c2 100644 --- a/src/main/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapter.java +++ b/src/main/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapter.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.brain.adapter.out.http.OutboundUrlGuard; import me.golemcore.brain.application.port.out.LlmProviderCheckPort; import me.golemcore.brain.domain.Secret; import me.golemcore.brain.domain.llm.LlmApiType; @@ -37,7 +38,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; -import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -47,13 +47,20 @@ public class HttpLlmProviderCheckAdapter implements LlmProviderCheckPort { private static final String USER_AGENT = "golemcore-brain-llm-settings"; private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); - private static final Pattern HTTP_URI_PATTERN = Pattern.compile("(?i)^https?://[^\\s]+$"); + // Redirects are intentionally disabled: the resolved peer is validated against + // the SSRF + // block-list before sending, but a redirect would point at an unvalidated host. private final HttpClient httpClient = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) + .followRedirects(HttpClient.Redirect.NEVER) .connectTimeout(DEFAULT_TIMEOUT) .build(); private final ObjectMapper objectMapper = new ObjectMapper(); + private final OutboundUrlGuard outboundUrlGuard; + + public HttpLlmProviderCheckAdapter(OutboundUrlGuard outboundUrlGuard) { + this.outboundUrlGuard = outboundUrlGuard; + } @Override public LlmProviderCheckResult check(String providerName, LlmProviderConfig providerConfig) { @@ -73,10 +80,8 @@ public LlmProviderCheckResult check(String providerName, LlmProviderConfig provi return new LlmProviderCheckResult(true, modelListingMessage(modelIds), 200, modelIds); } - if (!HTTP_URI_PATTERN.matcher(uri).matches()) { - throw new IllegalArgumentException("LLM endpoint must use HTTP or HTTPS"); - } - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(java.net.URI.create(uri)) + java.net.URI validatedUri = outboundUrlGuard.requirePublicHttp(uri); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(validatedUri) .GET() .timeout(timeout) .header("Accept", "application/json") @@ -108,9 +113,7 @@ public LlmProviderCheckResult check(String providerName, LlmProviderConfig provi private List listOpenAiModels(LlmProviderConfig providerConfig, String apiKey, Duration timeout) { String baseUrl = LlmEndpointResolver.canonicalBaseUrl(providerConfig.getBaseUrl(), "https://api.openai.com/v1"); - if (!HTTP_URI_PATTERN.matcher(baseUrl).matches()) { - throw new IllegalArgumentException("LLM endpoint must use HTTP or HTTPS"); - } + outboundUrlGuard.requirePublicHttp(baseUrl); return OpenAiModelCatalog.builder() .apiKey(apiKey) .baseUrl(baseUrl) diff --git a/src/main/java/me/golemcore/brain/adapter/out/jwt/JwtApiKeyTokenAdapter.java b/src/main/java/me/golemcore/brain/adapter/out/jwt/JwtApiKeyTokenAdapter.java index c05d757..0e18152 100644 --- a/src/main/java/me/golemcore/brain/adapter/out/jwt/JwtApiKeyTokenAdapter.java +++ b/src/main/java/me/golemcore/brain/adapter/out/jwt/JwtApiKeyTokenAdapter.java @@ -66,6 +66,7 @@ public ApiKeyTokenPort.ParsedApiKeyToken parse(String token) { Claims claims = Jwts.parser() .verifyWith(signingKey()) .requireIssuer(wikiProperties.getJwt().getIssuer()) + .clockSkewSeconds(60) .build() .parseSignedClaims(token) .getPayload(); diff --git a/src/main/java/me/golemcore/brain/adapter/out/security/BcryptPasswordEncoderAdapter.java b/src/main/java/me/golemcore/brain/adapter/out/security/BcryptPasswordEncoderAdapter.java new file mode 100644 index 0000000..71ff94f --- /dev/null +++ b/src/main/java/me/golemcore/brain/adapter/out/security/BcryptPasswordEncoderAdapter.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.adapter.out.security; + +import me.golemcore.brain.application.port.out.auth.PasswordEncoderPort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * Bridges {@link PasswordEncoderPort} to a Spring Security + * {@link PasswordEncoder}. + */ +@Component +public class BcryptPasswordEncoderAdapter implements PasswordEncoderPort { + + private final PasswordEncoder delegate; + + public BcryptPasswordEncoderAdapter(PasswordEncoder delegate) { + this.delegate = delegate; + } + + @Override + public String encode(String rawPassword) { + return delegate.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return delegate.matches(rawPassword, encodedPassword); + } +} diff --git a/src/main/java/me/golemcore/brain/application/port/out/auth/PasswordEncoderPort.java b/src/main/java/me/golemcore/brain/application/port/out/auth/PasswordEncoderPort.java new file mode 100644 index 0000000..f405fbb --- /dev/null +++ b/src/main/java/me/golemcore/brain/application/port/out/auth/PasswordEncoderPort.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.application.port.out.auth; + +/** + * Cryptographic password encoder port. Implementations must produce a + * self-describing salted hash (the chosen format is verified via + * {@link #encode(String)}'s return value), and verify a raw password against a + * previously-encoded hash with constant-time comparison. + */ +public interface PasswordEncoderPort { + + String encode(String rawPassword); + + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/src/main/java/me/golemcore/brain/application/service/apikey/ApiKeyService.java b/src/main/java/me/golemcore/brain/application/service/apikey/ApiKeyService.java index b1c3e00..72e7474 100644 --- a/src/main/java/me/golemcore/brain/application/service/apikey/ApiKeyService.java +++ b/src/main/java/me/golemcore/brain/application/service/apikey/ApiKeyService.java @@ -22,6 +22,7 @@ import me.golemcore.brain.application.port.out.ApiKeyRepository; import me.golemcore.brain.application.port.out.ApiKeyTokenPort; import me.golemcore.brain.application.port.out.SpaceRepository; +import me.golemcore.brain.application.service.audit.AuditLogger; import me.golemcore.brain.application.service.auth.AuthAccessDeniedException; import me.golemcore.brain.domain.apikey.ApiKey; import me.golemcore.brain.domain.auth.AuthContext; @@ -41,6 +42,7 @@ public class ApiKeyService { private final ApiKeyRepository apiKeyRepository; private final SpaceRepository spaceRepository; private final ApiKeyTokenPort apiKeyTokenPort; + private final AuditLogger auditLogger; public IssuedApiKey issueGlobal(AuthContext authContext, String name, Set roles, Instant expiresAt) { requireGlobalAdmin(authContext); @@ -54,7 +56,17 @@ public IssuedApiKey issueForSpace(AuthContext authContext, String spaceSlug, Str if (!authContext.canAccessSpace(space.getId(), UserRole.ADMIN)) { throw new AuthAccessDeniedException("Admin access to space '" + spaceSlug + "' required"); } - return issue(authContext, name, space.getId(), normalizeRoles(roles), expiresAt); + Set normalized = normalizeRoles(roles); + // requestedRoles ⊆ requesterRoles: strictly verify that the caller can grant + // every role + // they're putting on the new API key. + for (UserRole requested : normalized) { + if (!authContext.canAccessSpace(space.getId(), requested)) { + throw new AuthAccessDeniedException( + "Cannot issue key with role " + requested + " (insufficient permissions)"); + } + } + return issue(authContext, name, space.getId(), normalized, expiresAt); } public List listGlobal(AuthContext authContext) { @@ -89,6 +101,7 @@ public void revoke(AuthContext authContext, String keyId) { .expiresAt(key.getExpiresAt()) .revoked(true) .build()); + auditLogger.apiKeyRevoked(authContext, key.getId()); } public ApiKey findActive(String jti) { @@ -119,6 +132,9 @@ private IssuedApiKey issue(AuthContext authContext, String name, String spaceId, .build(); apiKeyRepository.save(apiKey); String token = apiKeyTokenPort.issue(apiKey); + auditLogger.apiKeyIssued(authContext, apiKey.getId(), + spaceId == null ? "global" : spaceId, + roles.toString()); return new IssuedApiKey(apiKey, token); } diff --git a/src/main/java/me/golemcore/brain/application/service/audit/AuditLogger.java b/src/main/java/me/golemcore/brain/application/service/audit/AuditLogger.java new file mode 100644 index 0000000..efbd504 --- /dev/null +++ b/src/main/java/me/golemcore/brain/application/service/audit/AuditLogger.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.application.service.audit; + +import me.golemcore.brain.domain.auth.AuthContext; +import me.golemcore.brain.domain.auth.PublicUserView; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Append-only audit trail for security-sensitive admin actions. Writes + * structured one-line records to the {@code audit} SLF4J logger; route that + * logger to its own appender via logback config when forensic separation is + * required. Lines are intentionally simple (no JSON deps) so they survive + * grepping in any log shipper. + */ +public class AuditLogger { + + private static final Logger AUDIT = LoggerFactory.getLogger("audit"); + + public void userCreated(AuthContext actor, String targetUserId, String targetUsername) { + emit(actor, "user.create", "userId=" + targetUserId + " username=" + targetUsername); + } + + public void userUpdated(AuthContext actor, String targetUserId, String fieldsChanged) { + emit(actor, "user.update", "userId=" + targetUserId + " fields=" + fieldsChanged); + } + + public void userDeleted(AuthContext actor, String targetUserId) { + emit(actor, "user.delete", "userId=" + targetUserId); + } + + public void apiKeyIssued(AuthContext actor, String keyId, String spaceId, String roles) { + emit(actor, "apikey.issue", "keyId=" + keyId + " spaceId=" + spaceId + " roles=" + roles); + } + + public void apiKeyRevoked(AuthContext actor, String keyId) { + emit(actor, "apikey.revoke", "keyId=" + keyId); + } + + public void loginThrottled(String ip, String identifier) { + AUDIT.warn("event=login.throttled ip={} identifier={}", sanitize(ip), sanitize(identifier)); + } + + private void emit(AuthContext actor, String event, String details) { + AUDIT.info("event={} actor={} apiKey={} {}", + event, + actorIdentity(actor), + actor != null && actor.isApiKey(), + sanitize(details)); + } + + private static String actorIdentity(AuthContext actor) { + if (actor == null) { + return "anonymous"; + } + PublicUserView user = actor.getUser(); + if (user == null) { + return "anonymous"; + } + return user.getUsername() == null ? user.getId() : user.getUsername(); + } + + /** + * Strip CR/LF from any field that flows from user input to prevent log + * injection. + */ + private static String sanitize(String value) { + if (value == null) { + return ""; + } + return value.replace('\r', '_').replace('\n', '_'); + } +} diff --git a/src/main/java/me/golemcore/brain/application/service/auth/AuthService.java b/src/main/java/me/golemcore/brain/application/service/auth/AuthService.java index 9fbb681..71b19c0 100644 --- a/src/main/java/me/golemcore/brain/application/service/auth/AuthService.java +++ b/src/main/java/me/golemcore/brain/application/service/auth/AuthService.java @@ -70,15 +70,24 @@ public AuthResponse login(String identifier, String password) { if (!passwordHasher.matches(password, user.getPasswordHash())) { throw new AuthUnauthorizedException("Invalid credentials"); } - sessionRepository.deleteByUserId(user.getId()); - UserSession session = createSession(user.getId()); + WikiUser activeUser = upgradeHashIfNeeded(user, password); + sessionRepository.deleteByUserId(activeUser.getId()); + UserSession session = createSession(activeUser.getId()); sessionRepository.save(session); return AuthResponse.builder() .message(session.getToken()) - .user(toPublicView(user)) + .user(toPublicView(activeUser)) .build(); } + private WikiUser upgradeHashIfNeeded(WikiUser user, String rawPassword) { + if (!passwordHasher.needsRehash(user.getPasswordHash())) { + return user; + } + WikiUser upgraded = user.toBuilder().passwordHash(passwordHasher.hash(rawPassword)).build(); + return userRepository.save(upgraded); + } + public AuthResponse changePassword(Optional sessionToken, String currentPassword, String newPassword) { AuthContext authContext = requireAuthenticated(sessionToken); PublicUserView currentUser = authContext.getUser(); diff --git a/src/main/java/me/golemcore/brain/application/service/auth/LoginRateLimiter.java b/src/main/java/me/golemcore/brain/application/service/auth/LoginRateLimiter.java new file mode 100644 index 0000000..7900359 --- /dev/null +++ b/src/main/java/me/golemcore/brain/application/service/auth/LoginRateLimiter.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.application.service.auth; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * LRU-bounded in-memory rate limiter for login attempts. Two counters are + * maintained: + *
    + *
  • (IP, username): 5 failures / 15min — fast lockout against + * credential stuffing for a specific account.
  • + *
  • IP-only: 20 failures / 15min — protects honest users against an + * attacker who sprays distinct usernames from one IP to evict their entries + * from the (IP, username) LRU.
  • + *
+ * + *

+ * A successful login clears the (IP, username) entry only — the IP-only counter + * survives so that a successful guess midway through a spray does not erase the + * per-IP attempt history. + *

+ * + *

+ * This is a single-process limiter — do not rely on it as the only defence in a + * multi-instance deployment, but for a single replica it cuts off + * password-spray and credential-stuffing. + *

+ */ +public class LoginRateLimiter { + + public static final int MAX_FAILURES_PER_USER = 5; + public static final int MAX_FAILURES_PER_IP = 20; + public static final Duration WINDOW = Duration.ofMinutes(15); + + private static final int MAX_USER_ENTRIES = 10_000; + private static final int MAX_IP_ENTRIES = 1_000; + + private final Clock clock; + private final Map userAttempts; + private final Map ipAttempts; + + public LoginRateLimiter(Clock clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.userAttempts = boundedLru(MAX_USER_ENTRIES); + this.ipAttempts = boundedLru(MAX_IP_ENTRIES); + } + + public synchronized void requireNotBlocked(String ip, String username) { + Instant now = clock.instant(); + Duration retryUser = checkBlocked(userAttempts, userKey(ip, username), MAX_FAILURES_PER_USER, now); + Duration retryIp = checkBlocked(ipAttempts, ipKey(ip), MAX_FAILURES_PER_IP, now); + Duration retryAfter = longerOf(retryUser, retryIp); + if (retryAfter != null) { + throw new LoginThrottledException(retryAfter); + } + } + + public synchronized void recordFailure(String ip, String username) { + Instant now = clock.instant(); + bump(userAttempts, userKey(ip, username), now); + bump(ipAttempts, ipKey(ip), now); + } + + /** + * Clears the (IP, username) entry. The IP-only counter is intentionally + * retained — if the caller succeeded, that single account is no longer being + * attacked, but the same IP may still be spraying other usernames, and we want + * that to remain visible. + */ + public synchronized void recordSuccess(String ip, String username) { + userAttempts.remove(userKey(ip, username)); + } + + private Duration checkBlocked(Map map, String key, int maxFailures, Instant now) { + Attempt attempt = map.get(key); + if (attempt == null) { + return null; + } + if (now.isAfter(attempt.firstFailureAt.plus(WINDOW))) { + map.remove(key); + return null; + } + if (attempt.count >= maxFailures) { + Duration retryAfter = Duration.between(now, attempt.firstFailureAt.plus(WINDOW)); + return retryAfter.isNegative() ? Duration.ZERO : retryAfter; + } + return null; + } + + private void bump(Map map, String key, Instant now) { + Attempt existing = map.get(key); + if (existing == null || now.isAfter(existing.firstFailureAt.plus(WINDOW))) { + map.put(key, new Attempt(1, now)); + return; + } + map.put(key, new Attempt(existing.count + 1, existing.firstFailureAt)); + } + + private static Duration longerOf(Duration a, Duration b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return a.compareTo(b) >= 0 ? a : b; + } + + private static String userKey(String ip, String username) { + String safeIp = ip == null ? "?" : ip; + String safeUser = username == null ? "?" : username.trim().toLowerCase(Locale.ROOT); + return safeIp + "|" + safeUser; + } + + private static String ipKey(String ip) { + return ip == null ? "?" : ip; + } + + private static Map boundedLru(int maxEntries) { + return new LinkedHashMap<>(256, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxEntries; + } + }; + } + + private record Attempt(int count, Instant firstFailureAt) { + } +} diff --git a/src/main/java/me/golemcore/brain/application/service/auth/LoginThrottledException.java b/src/main/java/me/golemcore/brain/application/service/auth/LoginThrottledException.java new file mode 100644 index 0000000..15a0be4 --- /dev/null +++ b/src/main/java/me/golemcore/brain/application/service/auth/LoginThrottledException.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.application.service.auth; + +import java.time.Duration; +import lombok.Getter; + +/** + * Thrown when {@link LoginRateLimiter} detects too many recent failed login + * attempts for the caller. {@link #retryAfter} indicates how long the client + * should wait before retrying. + */ +@Getter +public class LoginThrottledException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final Duration retryAfter; + + public LoginThrottledException(Duration retryAfter) { + super("Too many failed login attempts. Try again in " + + Math.max(1L, retryAfter.toSeconds()) + " seconds."); + this.retryAfter = retryAfter; + } +} diff --git a/src/main/java/me/golemcore/brain/application/service/auth/PasswordHasher.java b/src/main/java/me/golemcore/brain/application/service/auth/PasswordHasher.java index ae1ee54..650240f 100644 --- a/src/main/java/me/golemcore/brain/application/service/auth/PasswordHasher.java +++ b/src/main/java/me/golemcore/brain/application/service/auth/PasswordHasher.java @@ -18,14 +18,61 @@ package me.golemcore.brain.application.service.auth; +import me.golemcore.brain.application.port.out.auth.PasswordEncoderPort; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; +import lombok.RequiredArgsConstructor; +/** + * Password hashing with seamless migration from legacy unsalted SHA-256 hashes + * to BCrypt. Stored hashes prefixed with {@code $2} are treated as BCrypt; + * everything else is verified against the legacy SHA-256 hex format (64 + * lowercase hex chars). Callers should re-hash and persist whenever + * {@link #needsRehash(String)} returns true after a successful match. + */ +@RequiredArgsConstructor public class PasswordHasher { + private static final String LEGACY_SHA256_REGEX = "^[0-9a-f]{64}$"; + + private final PasswordEncoderPort passwordEncoder; + public String hash(String password) { + return passwordEncoder.encode(password); + } + + public boolean matches(String rawPassword, String storedHash) { + if (rawPassword == null || storedHash == null) { + return false; + } + if (isBcrypt(storedHash)) { + return passwordEncoder.matches(rawPassword, storedHash); + } + if (isLegacySha256(storedHash)) { + return constantTimeEquals(legacySha256Hex(rawPassword), storedHash); + } + return false; + } + + /** + * Returns true when the stored hash is in the legacy SHA-256 format and should + * be upgraded to BCrypt on the caller's next persistence write. + */ + public boolean needsRehash(String storedHash) { + return storedHash == null || !isBcrypt(storedHash); + } + + private static boolean isBcrypt(String hash) { + return hash.startsWith("$2"); + } + + private static boolean isLegacySha256(String hash) { + return hash.matches(LEGACY_SHA256_REGEX); + } + + private static String legacySha256Hex(String password) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] result = digest.digest(password.getBytes(StandardCharsets.UTF_8)); @@ -35,7 +82,7 @@ public String hash(String password) { } } - public boolean matches(String rawPassword, String storedHash) { - return hash(rawPassword).equals(storedHash); + private static boolean constantTimeEquals(String a, String b) { + return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); } } diff --git a/src/main/java/me/golemcore/brain/application/service/user/UserManagementService.java b/src/main/java/me/golemcore/brain/application/service/user/UserManagementService.java index e56c9e5..0d8c79a 100644 --- a/src/main/java/me/golemcore/brain/application/service/user/UserManagementService.java +++ b/src/main/java/me/golemcore/brain/application/service/user/UserManagementService.java @@ -20,11 +20,16 @@ import me.golemcore.brain.application.port.out.auth.SessionRepository; import me.golemcore.brain.application.port.out.auth.UserRepository; +import me.golemcore.brain.application.service.audit.AuditLogger; import me.golemcore.brain.application.service.auth.AuthService; import me.golemcore.brain.application.service.auth.PasswordHasher; +import me.golemcore.brain.domain.auth.AuthContext; import me.golemcore.brain.domain.auth.PublicUserView; import me.golemcore.brain.domain.auth.UserRole; import me.golemcore.brain.domain.auth.WikiUser; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -36,13 +41,15 @@ public class UserManagementService { private final PasswordHasher passwordHasher; private final AuthService authService; private final SessionRepository sessionRepository; + private final AuditLogger auditLogger; - public java.util.List listUsers(Optional sessionToken) { + public List listUsers(Optional sessionToken) { return authService.listUsers(sessionToken); } public PublicUserView createUser(Optional sessionToken, String username, String email, String password, UserRole role) { + AuthContext actor = authService.requireAuthenticated(sessionToken); authService.requireUserManagement(sessionToken); assertUniqueIdentity(username, email, null); WikiUser user = WikiUser.builder() @@ -53,6 +60,7 @@ public PublicUserView createUser(Optional sessionToken, String username, .role(role) .build(); userRepository.save(user); + auditLogger.userCreated(actor, user.getId(), user.getUsername()); return toPublicView(user); } @@ -66,8 +74,8 @@ public PublicUserView updateUser( authService.requireUserManagement(sessionToken); WikiUser existingUser = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); - PublicUserView actingUser = authService.requireAuthenticated(sessionToken).getUser(); - validateSelfAdminChange(existingUser, actingUser, role); + AuthContext actor = authService.requireAuthenticated(sessionToken); + validateSelfAdminChange(existingUser, actor.getUser(), role); assertUniqueIdentity(username, email, userId); String nextPasswordHash = password == null || password.isBlank() ? existingUser.getPasswordHash() @@ -80,15 +88,38 @@ public PublicUserView updateUser( .role(role) .build(); userRepository.save(updatedUser); - if (!nextPasswordHash.equals(existingUser.getPasswordHash())) { + boolean passwordChanged = !nextPasswordHash.equals(existingUser.getPasswordHash()); + if (passwordChanged) { sessionRepository.deleteByUserId(userId); } + String changes = describeChanges(existingUser, updatedUser, passwordChanged); + if (!changes.isEmpty()) { + auditLogger.userUpdated(actor, userId, changes); + } return toPublicView(updatedUser); } + private static String describeChanges(WikiUser before, WikiUser after, boolean passwordChanged) { + List changed = new ArrayList<>(); + if (!Objects.equals(before.getUsername(), after.getUsername())) { + changed.add("username"); + } + if (!Objects.equals(before.getEmail(), after.getEmail())) { + changed.add("email"); + } + if (!Objects.equals(before.getRole(), after.getRole())) { + changed.add("role"); + } + if (passwordChanged) { + changed.add("password"); + } + return String.join(",", changed); + } + public void deleteUser(Optional sessionToken, String userId) { authService.requireUserManagement(sessionToken); - PublicUserView actingUser = authService.requireAuthenticated(sessionToken).getUser(); + AuthContext actor = authService.requireAuthenticated(sessionToken); + PublicUserView actingUser = actor.getUser(); if (actingUser != null && actingUser.getId().equals(userId)) { throw new IllegalArgumentException("You cannot delete your own user"); } @@ -96,6 +127,7 @@ public void deleteUser(Optional sessionToken, String userId) { .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); userRepository.delete(userId); sessionRepository.deleteByUserId(userId); + auditLogger.userDeleted(actor, userId); } private void assertUniqueIdentity(String username, String email, String excludedUserId) { diff --git a/src/main/java/me/golemcore/brain/config/BrainApplicationConfiguration.java b/src/main/java/me/golemcore/brain/config/BrainApplicationConfiguration.java index d3d4ca7..99332c7 100644 --- a/src/main/java/me/golemcore/brain/config/BrainApplicationConfiguration.java +++ b/src/main/java/me/golemcore/brain/config/BrainApplicationConfiguration.java @@ -33,6 +33,7 @@ import me.golemcore.brain.application.port.out.ModelRegistryRemotePort; import me.golemcore.brain.application.port.out.SpaceRepository; import me.golemcore.brain.application.port.out.WikiAccessStatsPort; +import me.golemcore.brain.application.port.out.auth.PasswordEncoderPort; import me.golemcore.brain.application.port.out.WikiEmbeddingIndexPort; import me.golemcore.brain.application.port.out.WikiFullTextIndexPort; import me.golemcore.brain.application.port.out.WikiDocumentCatalogPort; @@ -41,8 +42,10 @@ import me.golemcore.brain.application.port.out.auth.UserRepository; import me.golemcore.brain.application.service.WikiApplicationService; import me.golemcore.brain.application.service.apikey.ApiKeyService; +import me.golemcore.brain.application.service.audit.AuditLogger; import me.golemcore.brain.application.service.chat.SpaceChatService; import me.golemcore.brain.application.service.auth.AuthService; +import me.golemcore.brain.application.service.auth.LoginRateLimiter; import me.golemcore.brain.application.service.auth.PasswordHasher; import me.golemcore.brain.application.service.dynamicapi.DynamicSpaceApiService; import me.golemcore.brain.application.service.index.WikiIndexReconciliationScheduler; @@ -77,8 +80,18 @@ public AuthService authService( } @Bean - public PasswordHasher passwordHasher() { - return new PasswordHasher(); + public PasswordHasher passwordHasher(PasswordEncoderPort passwordEncoderPort) { + return new PasswordHasher(passwordEncoderPort); + } + + @Bean + public AuditLogger auditLogger() { + return new AuditLogger(); + } + + @Bean + public LoginRateLimiter loginRateLimiter(Clock clock) { + return new LoginRateLimiter(clock); } @Bean(initMethod = "initialize") @@ -129,8 +142,9 @@ public WikiIndexReconciliationScheduler wikiIndexReconciliationScheduler( public ApiKeyService apiKeyService( ApiKeyRepository apiKeyRepository, SpaceRepository spaceRepository, - ApiKeyTokenPort apiKeyTokenPort) { - return new ApiKeyService(apiKeyRepository, spaceRepository, apiKeyTokenPort); + ApiKeyTokenPort apiKeyTokenPort, + AuditLogger auditLogger) { + return new ApiKeyService(apiKeyRepository, spaceRepository, apiKeyTokenPort, auditLogger); } @Bean @@ -200,7 +214,8 @@ public UserManagementService userManagementService( UserRepository userRepository, PasswordHasher passwordHasher, AuthService authService, - SessionRepository sessionRepository) { - return new UserManagementService(userRepository, passwordHasher, authService, sessionRepository); + SessionRepository sessionRepository, + AuditLogger auditLogger) { + return new UserManagementService(userRepository, passwordHasher, authService, sessionRepository, auditLogger); } } diff --git a/src/main/java/me/golemcore/brain/config/CsrfCookieFilter.java b/src/main/java/me/golemcore/brain/config/CsrfCookieFilter.java new file mode 100644 index 0000000..6b1349d --- /dev/null +++ b/src/main/java/me/golemcore/brain/config/CsrfCookieFilter.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Forces the deferred CSRF token to be loaded so that + * {@code CookieCsrfTokenRepository} writes the {@code XSRF-TOKEN} cookie even + * on idempotent GET requests. Required for SPAs that need the cookie available + * before issuing the first mutating request. + */ +public class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (token != null) { + token.getToken(); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/me/golemcore/brain/config/ProdProfileSecurityGuard.java b/src/main/java/me/golemcore/brain/config/ProdProfileSecurityGuard.java new file mode 100644 index 0000000..fbba547 --- /dev/null +++ b/src/main/java/me/golemcore/brain/config/ProdProfileSecurityGuard.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.config; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * Refuses to start the application on the {@code prod} profile when + * security-relevant settings are misconfigured. This is a defence-in-depth + * measure: even though {@code application-prod.properties} hard-codes the + * flags, an operator could still try to override them via JVM args, + * environment, or an additional config import. + */ +@Component +@Profile("prod") +@RequiredArgsConstructor +public class ProdProfileSecurityGuard { + + private final WikiProperties wikiProperties; + + /** + * Well-known placeholder shipped in the default profile. Any deployment whose + * secret matches exactly is treated as misconfigured even though it satisfies + * the length check. + */ + static final String PLACEHOLDER_JWT_SECRET = "change-me-change-me-change-me-change-me-change-me"; + + @PostConstruct + public void verify() { + if (wikiProperties.isAuthDisabled()) { + throw new IllegalStateException( + "brain.auth-disabled=true is not permitted on the 'prod' profile"); + } + String secret = wikiProperties.getJwt().getSecret(); + if (secret == null || secret.length() < 32) { + throw new IllegalStateException( + "brain.jwt.secret must be set to a value of at least 32 characters on the 'prod' profile"); + } + if (PLACEHOLDER_JWT_SECRET.equals(secret)) { + throw new IllegalStateException( + "brain.jwt.secret is set to the well-known placeholder value on the 'prod' profile"); + } + if (isBlank(wikiProperties.getAdminUsername())) { + throw new IllegalStateException( + "brain.admin-username (BRAIN_ADMIN_USERNAME) is required on the 'prod' profile"); + } + if (isBlank(wikiProperties.getAdminPassword())) { + throw new IllegalStateException( + "brain.admin-password (BRAIN_ADMIN_PASSWORD) is required on the 'prod' profile"); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/me/golemcore/brain/config/SecurityConfig.java b/src/main/java/me/golemcore/brain/config/SecurityConfig.java new file mode 100644 index 0000000..898b2e6 --- /dev/null +++ b/src/main/java/me/golemcore/brain/config/SecurityConfig.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; + +@Configuration +public class SecurityConfig { + + @Value("${brain.security.csrf-enabled:true}") + private boolean csrfEnabled; + + @Value("${brain.security.cookie-secure:true}") + private boolean cookieSecure; + + /** + * Application-wide CSP. Frontend is bundled and served from the same origin; no + * inline scripts are produced by the React build, but Tailwind preflight may + * emit inline style attributes — keep 'unsafe-inline' for style-src and tighten + * as the markdown sanitizer matures. + */ + private static final String CSP = "default-src 'self'; " + + "img-src 'self' data: blob:; " + + "media-src 'self' blob:; " + + "style-src 'self' 'unsafe-inline'; " + + "script-src 'self'; " + + "connect-src 'self'; " + + "font-src 'self' data:; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'"; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + CookieCsrfTokenRepository csrfRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfRepo.setCookieCustomizer(c -> c.secure(cookieSecure).sameSite("Lax").path("/")); + // Plain CsrfTokenRequestAttributeHandler (instead of + // XorCsrfTokenRequestAttributeHandler) + // is what allows the SPA to echo the XSRF-TOKEN cookie back as X-XSRF-TOKEN + // unchanged. + // setCsrfRequestAttributeName(null) keeps the resolver looking up "_csrf" only. + CsrfTokenRequestAttributeHandler csrfHandler = new CsrfTokenRequestAttributeHandler(); + csrfHandler.setCsrfRequestAttributeName(null); + + http + .csrf(csrf -> { + if (!csrfEnabled) { + csrf.disable(); + return; + } + csrf.csrfTokenRepository(csrfRepo) + .csrfTokenRequestHandler(csrfHandler) + .ignoringRequestMatchers(SecurityConfig::isBearerRequest); + }) + .headers(headers -> headers + .frameOptions(FrameOptionsConfig::deny) + .contentTypeOptions(opts -> { + }) + .httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31_536_000L)) + .referrerPolicy(ref -> ref + .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + .contentSecurityPolicy(csp -> csp.policyDirectives(CSP))) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(f -> f.disable()) + .httpBasic(h -> h.disable()) + .logout(l -> l.disable()) + .anonymous(a -> a.disable()) + .requestCache(rc -> rc.disable()) + // Authorization is enforced by the application's own AuthContextResolver — + // Spring + // Security only provides CSRF + headers + filter ordering here. + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + if (csrfEnabled) { + http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class); + } + + return http.build(); + } + + private static boolean isBearerRequest(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + return header != null && header.startsWith("Bearer "); + } +} diff --git a/src/main/java/me/golemcore/brain/web/RequestIdFilter.java b/src/main/java/me/golemcore/brain/web/RequestIdFilter.java new file mode 100644 index 0000000..678c4fe --- /dev/null +++ b/src/main/java/me/golemcore/brain/web/RequestIdFilter.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.UUID; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Assigns a request id (read from {@code X-Request-Id} or freshly generated), + * exposes it as a response header and a request attribute, and binds it to + * SLF4J MDC for correlation in logs. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestIdFilter extends OncePerRequestFilter { + + public static final String REQUEST_ID_HEADER = "X-Request-Id"; + public static final String REQUEST_ID_ATTRIBUTE = "brain.requestId"; + public static final String MDC_KEY = "requestId"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String header = request.getHeader(REQUEST_ID_HEADER); + String requestId = isValid(header) ? header : UUID.randomUUID().toString(); + request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); + MDC.put(MDC_KEY, requestId); + try { + chain.doFilter(request, response); + } finally { + MDC.remove(MDC_KEY); + } + } + + /** + * Accept short, opaque ids only. Anything that looks like log injection + * (newlines), is too long, or contains characters that confuse log/JSON parsers + * is rejected and replaced with a fresh id. + */ + private static boolean isValid(String value) { + if (value == null || value.isBlank() || value.length() > 64) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!(Character.isLetterOrDigit(c) || c == '-' || c == '_' || c == '.')) { + return false; + } + } + return true; + } +} diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 092124f..35ab938 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -1,7 +1,9 @@ brain.storage-root=${BRAIN_STORAGE_ROOT:/data/wiki} brain.site-title=${BRAIN_SITE_TITLE:GolemCore Brain} brain.seed-demo-content=${BRAIN_SEED_DEMO_CONTENT:false} -brain.auth-disabled=${BRAIN_AUTH_DISABLED:false} +# brain.auth-disabled is intentionally fixed to false in prod and not overridable by env var. +# Any attempt to enable it on the prod profile is enforced by ProdProfileSecurityGuard. +brain.auth-disabled=false brain.public-access=${BRAIN_PUBLIC_ACCESS:false} brain.admin-username=${BRAIN_ADMIN_USERNAME} brain.admin-email=${BRAIN_ADMIN_EMAIL:admin@example.com} @@ -11,3 +13,7 @@ brain.jwt.secret=${BRAIN_JWT_SECRET} brain.jwt.issuer=${BRAIN_JWT_ISSUER:golemcore-brain} brain.default-space-slug=${BRAIN_DEFAULT_SPACE_SLUG:default} brain.default-space-name=${BRAIN_DEFAULT_SPACE_NAME:Default} + +# Multipart upload caps. Must match WikiApplicationService.maxAssetUploadSizeBytes (25MB). +spring.servlet.multipart.max-file-size=25MB +spring.servlet.multipart.max-request-size=26MB diff --git a/src/test/java/me/golemcore/brain/adapter/in/web/AssetMimeGuardTest.java b/src/test/java/me/golemcore/brain/adapter/in/web/AssetMimeGuardTest.java new file mode 100644 index 0000000..6f2c6d3 --- /dev/null +++ b/src/test/java/me/golemcore/brain/adapter/in/web/AssetMimeGuardTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.adapter.in.web; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AssetMimeGuardTest { + + private static final byte[] PNG_HEADER = { (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, + 0, 0, 0, 0, 0, 0, 0, 0 }; + private static final byte[] JPEG_HEADER = { (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 }; + private static final byte[] GIF_HEADER = { 'G', 'I', 'F', '8', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + private static final byte[] WEBP_HEADER = { 'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P', + 0, 0, 0, 0 }; + private static final byte[] PDF_HEADER = { '%', 'P', 'D', 'F', '-', 0, 0, 0, 0, 0, 0, 0 }; + private static final byte[] HTML_HEADER = "".getBytes(); + + @Test + void shouldAcceptPngWithCorrectMagic() { + MockMultipartFile file = new MockMultipartFile("file", "x.png", "image/png", PNG_HEADER); + assertDoesNotThrow(() -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldAcceptJpegWithCorrectMagic() { + MockMultipartFile file = new MockMultipartFile("file", "x.jpg", "image/jpeg", JPEG_HEADER); + assertDoesNotThrow(() -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldAcceptGifWithCorrectMagic() { + MockMultipartFile file = new MockMultipartFile("file", "x.gif", "image/gif", GIF_HEADER); + assertDoesNotThrow(() -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldAcceptWebpWithCorrectMagic() { + MockMultipartFile file = new MockMultipartFile("file", "x.webp", "image/webp", WEBP_HEADER); + assertDoesNotThrow(() -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldAcceptPdfWithCorrectMagic() { + MockMultipartFile file = new MockMultipartFile("file", "x.pdf", "application/pdf", PDF_HEADER); + assertDoesNotThrow(() -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldRejectPngDeclaredButHtmlContent() { + MockMultipartFile file = new MockMultipartFile("file", "x.png", "image/png", HTML_HEADER); + assertThrows(IllegalArgumentException.class, () -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldRejectSvgWhichIsNotAllowListed() { + byte[] svg = "".getBytes(); + MockMultipartFile file = new MockMultipartFile("file", "x.svg", "image/svg+xml", svg); + assertThrows(IllegalArgumentException.class, () -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldRejectExecutableMime() { + MockMultipartFile file = new MockMultipartFile("file", "x.sh", "application/x-sh", "echo".getBytes()); + assertThrows(IllegalArgumentException.class, () -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldRejectMissingContentType() { + MockMultipartFile file = new MockMultipartFile("file", "x.png", null, PNG_HEADER); + assertThrows(IllegalArgumentException.class, () -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldRejectEmptyFile() { + MockMultipartFile file = new MockMultipartFile("file", "x.png", "image/png", new byte[0]); + assertThrows(IllegalArgumentException.class, () -> AssetMimeGuard.validate(file)); + } + + @Test + void shouldAcceptTextPlainWithoutMagicCheck() { + MockMultipartFile file = new MockMultipartFile("file", "notes.txt", "text/plain", "hello".getBytes()); + assertDoesNotThrow(() -> AssetMimeGuard.validate(file)); + } +} diff --git a/src/test/java/me/golemcore/brain/adapter/out/http/OutboundUrlGuardTest.java b/src/test/java/me/golemcore/brain/adapter/out/http/OutboundUrlGuardTest.java new file mode 100644 index 0000000..1da59f0 --- /dev/null +++ b/src/test/java/me/golemcore/brain/adapter/out/http/OutboundUrlGuardTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.adapter.out.http; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OutboundUrlGuardTest { + + private final OutboundUrlGuard guard = new OutboundUrlGuard(false); + + @ParameterizedTest + @ValueSource(strings = { + "http://127.0.0.1/x", // IPv4 loopback + "http://10.0.0.1/x", // private (RFC 1918) + "http://172.16.0.1/x", // private (RFC 1918) + "http://192.168.1.1/x", // private (RFC 1918) + "http://169.254.169.254/latest/meta", // AWS IMDS link-local + "http://0.0.0.0/x", // any-local + "http://0.1.2.3/x", // 0.0.0.0/8 + "http://255.255.255.255/x", // broadcast + "http://100.64.0.1/x", // CGNAT + "http://[::1]/x", // IPv6 loopback + "http://[fc00::1]/x", // IPv6 ULA + "http://[fd00::1]/x", // IPv6 ULA + "http://[fe80::1]/x", // IPv6 link-local + "http://[::ffff:127.0.0.1]/x", // IPv4-mapped loopback (Java often returns Inet4Address) + "http://[::ffff:10.0.0.1]/x", // IPv4-mapped private + }) + void shouldRejectPrivateOrLoopbackTargets(String url) { + assertThrows(IllegalArgumentException.class, () -> guard.requirePublicHttp(url)); + } + + @ParameterizedTest + @ValueSource(strings = { + "ftp://example.com/", + "file:///etc/passwd", + "javascript:alert(1)", + "gopher://example.com/", + "data:text/plain,hi", + }) + void shouldRejectNonHttpSchemes(String url) { + assertThrows(IllegalArgumentException.class, () -> guard.requirePublicHttp(url)); + } + + @ParameterizedTest + @ValueSource(strings = { "", " ", "https://" }) + void shouldRejectEmptyOrHostlessUrls(String url) { + assertThrows(IllegalArgumentException.class, () -> guard.requirePublicHttp(url)); + } + + @ParameterizedTest + @ValueSource(strings = { "http://1.1.1.1/x", "https://8.8.8.8/", "https://example.com/x" }) + void shouldAllowPublicTargets(String url) { + // example.com lookups need the network in CI; if DNS fails the test will throw + // "Unable to + // resolve host". Filter such transient failures by catching + // IllegalArgumentException whose + // message starts with "Unable to resolve host". + try { + guard.requirePublicHttp(url); + } catch (IllegalArgumentException e) { + if (e.getMessage() == null || !e.getMessage().startsWith("Unable to resolve host")) { + throw e; + } + // DNS not available in the sandbox — accept the test as not applicable. + } + } + + @org.junit.jupiter.api.Test + void shouldBypassChecksWhenAllowPrivateAddressesIsTrue() { + OutboundUrlGuard permissive = new OutboundUrlGuard(true); + assertDoesNotThrow(() -> permissive.requirePublicHttp("http://127.0.0.1/x")); + assertDoesNotThrow(() -> permissive.requirePublicHttp("http://10.0.0.1/x")); + } +} diff --git a/src/test/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapterTest.java b/src/test/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapterTest.java index e336724..1767784 100644 --- a/src/test/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapterTest.java +++ b/src/test/java/me/golemcore/brain/adapter/out/http/llm/HttpLlmProviderCheckAdapterTest.java @@ -20,6 +20,7 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; +import me.golemcore.brain.adapter.out.http.OutboundUrlGuard; import me.golemcore.brain.domain.Secret; import me.golemcore.brain.domain.llm.LlmApiType; import me.golemcore.brain.domain.llm.LlmProviderCheckResult; @@ -66,7 +67,7 @@ void shouldDetectOpenAiModelsWithLangchain4jCatalog() throws IOException { server.createContext("/v1/models", this::respondWithOpenAiModels); server.start(); try { - HttpLlmProviderCheckAdapter adapter = new HttpLlmProviderCheckAdapter(); + HttpLlmProviderCheckAdapter adapter = new HttpLlmProviderCheckAdapter(new OutboundUrlGuard(true)); LlmProviderCheckResult result = adapter.check("openai", LlmProviderConfig.builder() .apiKey(Secret.of("sk-test")) diff --git a/src/test/java/me/golemcore/brain/application/service/auth/AuthServiceTest.java b/src/test/java/me/golemcore/brain/application/service/auth/AuthServiceTest.java index 662d811..b96a76f 100644 --- a/src/test/java/me/golemcore/brain/application/service/auth/AuthServiceTest.java +++ b/src/test/java/me/golemcore/brain/application/service/auth/AuthServiceTest.java @@ -20,6 +20,7 @@ import me.golemcore.brain.adapter.out.filesystem.auth.FileSessionRepository; import me.golemcore.brain.adapter.out.filesystem.auth.FileUserRepository; +import me.golemcore.brain.adapter.out.security.BcryptPasswordEncoderAdapter; import me.golemcore.brain.config.WikiProperties; import me.golemcore.brain.domain.auth.AuthConfigResponse; import me.golemcore.brain.domain.auth.AuthResponse; @@ -29,6 +30,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -51,7 +53,9 @@ void shouldLoginResolveSessionAndEnforceRoles() { properties.setAdminEmail("admin@example.com"); properties.setAdminPassword("admin"); - PasswordHasher passwordHasher = new PasswordHasher(); + // Cost-4 BCrypt keeps the test under a second; production uses cost 12. + PasswordHasher passwordHasher = new PasswordHasher( + new BcryptPasswordEncoderAdapter(new BCryptPasswordEncoder(4))); FileUserRepository userRepository = new FileUserRepository(properties); FileSessionRepository sessionRepository = new FileSessionRepository(properties); AuthService authService = new AuthService(properties, userRepository, sessionRepository, passwordHasher); diff --git a/src/test/java/me/golemcore/brain/application/service/auth/LoginRateLimiterTest.java b/src/test/java/me/golemcore/brain/application/service/auth/LoginRateLimiterTest.java new file mode 100644 index 0000000..52e12e9 --- /dev/null +++ b/src/test/java/me/golemcore/brain/application/service/auth/LoginRateLimiterTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.application.service.auth; + +import me.golemcore.brain.testsupport.MutableClock; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LoginRateLimiterTest { + + private MutableClock clock; + private LoginRateLimiter limiter; + + @BeforeEach + void setUp() { + clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z")); + limiter = new LoginRateLimiter(clock); + } + + @Test + void shouldAllowAttemptsBelowPerUserThreshold() { + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_USER - 1; i++) { + limiter.recordFailure("1.1.1.1", "alice"); + } + assertDoesNotThrow(() -> limiter.requireNotBlocked("1.1.1.1", "alice")); + } + + @Test + void shouldBlockSpecificUserAfterMaxFailures() { + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_USER; i++) { + limiter.recordFailure("1.1.1.1", "alice"); + } + assertThrows(LoginThrottledException.class, + () -> limiter.requireNotBlocked("1.1.1.1", "alice")); + } + + @Test + void shouldNotBlockOtherUserOnSameIpUntilPerIpCapHit() { + // Five fails on "alice" lock alice but not bob (per-IP cap is 20). + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_USER; i++) { + limiter.recordFailure("1.1.1.1", "alice"); + } + assertDoesNotThrow(() -> limiter.requireNotBlocked("1.1.1.1", "bob")); + } + + @Test + void shouldBlockEntireIpAfterPerIpCapEvenForFreshUsername() { + // Spray 20 distinct usernames from one IP — should trip the per-IP guard + // and protect any honest user (e.g. "victim") on that IP from further + // attempts coming from that source. + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_IP; i++) { + limiter.recordFailure("1.1.1.1", "user-" + i); + } + assertThrows(LoginThrottledException.class, + () -> limiter.requireNotBlocked("1.1.1.1", "victim")); + } + + @Test + void shouldUnblockAfterWindowElapses() { + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_USER; i++) { + limiter.recordFailure("1.1.1.1", "alice"); + } + clock.advance(LoginRateLimiter.WINDOW.plusSeconds(1)); + assertDoesNotThrow(() -> limiter.requireNotBlocked("1.1.1.1", "alice")); + } + + @Test + void shouldClearPerUserOnSuccessButRetainPerIp() { + // Spray 20 failed usernames to trip the per-IP counter. + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_IP; i++) { + limiter.recordFailure("1.1.1.1", "user-" + i); + } + // A success on user-0 must not unblock the per-IP counter. + limiter.recordSuccess("1.1.1.1", "user-0"); + assertThrows(LoginThrottledException.class, + () -> limiter.requireNotBlocked("1.1.1.1", "victim")); + } + + @Test + void shouldExposeRetryAfterDuration() { + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_USER; i++) { + limiter.recordFailure("1.1.1.1", "alice"); + } + clock.advance(Duration.ofMinutes(1)); + LoginThrottledException thrown = assertThrows(LoginThrottledException.class, + () -> limiter.requireNotBlocked("1.1.1.1", "alice")); + // First failure was at t=0; window ends at +15min; we're at +1min, so + // retryAfter is 14min. + Duration expectedAtLeast = Duration.ofMinutes(13); + Duration expectedAtMost = Duration.ofMinutes(15); + Duration actual = thrown.getRetryAfter(); + org.junit.jupiter.api.Assertions.assertTrue(actual.compareTo(expectedAtLeast) >= 0 + && actual.compareTo(expectedAtMost) <= 0, + () -> "retryAfter " + actual + " not in [" + expectedAtLeast + "," + expectedAtMost + "]"); + } + + @Test + void shouldNotPersistFailuresAcrossWindowBoundary() { + limiter.recordFailure("1.1.1.1", "alice"); + clock.advance(LoginRateLimiter.WINDOW.plusSeconds(1)); + // Previous failure has expired — counter starts fresh. + for (int i = 0; i < LoginRateLimiter.MAX_FAILURES_PER_USER - 1; i++) { + limiter.recordFailure("1.1.1.1", "alice"); + } + assertDoesNotThrow(() -> limiter.requireNotBlocked("1.1.1.1", "alice")); + } +} diff --git a/src/test/java/me/golemcore/brain/application/service/auth/PasswordHasherTest.java b/src/test/java/me/golemcore/brain/application/service/auth/PasswordHasherTest.java new file mode 100644 index 0000000..cdd9e48 --- /dev/null +++ b/src/test/java/me/golemcore/brain/application/service/auth/PasswordHasherTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.application.service.auth; + +import me.golemcore.brain.adapter.out.security.BcryptPasswordEncoderAdapter; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PasswordHasherTest { + + private PasswordHasher hasher; + + @BeforeEach + void setUp() { + // Cost-4 BCrypt keeps the test under a second; production uses cost 12. + hasher = new PasswordHasher(new BcryptPasswordEncoderAdapter(new BCryptPasswordEncoder(4))); + } + + @Test + void shouldHashWithBcryptByDefault() { + String hash = hasher.hash("hunter2"); + assertTrue(hash.startsWith("$2"), () -> "expected BCrypt prefix, got: " + hash); + assertNotEquals("hunter2", hash); + } + + @Test + void shouldMatchOwnBcryptHash() { + String hash = hasher.hash("hunter2"); + assertTrue(hasher.matches("hunter2", hash)); + assertFalse(hasher.matches("wrong", hash)); + } + + @Test + void shouldVerifyLegacyUnsaltedSha256() { + String legacyHash = legacySha256("hunter2"); + assertTrue(hasher.matches("hunter2", legacyHash)); + assertFalse(hasher.matches("wrong", legacyHash)); + } + + @Test + void shouldNotAcceptUnknownHashFormats() { + assertFalse(hasher.matches("hunter2", "plaintext-not-a-hash")); + assertFalse(hasher.matches("hunter2", "")); + assertFalse(hasher.matches("hunter2", null)); + assertFalse(hasher.matches(null, "anything")); + } + + @Test + void shouldFlagLegacyHashAsNeedingRehash() { + assertTrue(hasher.needsRehash(legacySha256("hunter2"))); + assertTrue(hasher.needsRehash(null)); + assertTrue(hasher.needsRehash("not-a-bcrypt-hash")); + } + + @Test + void shouldNotFlagBcryptHashAsNeedingRehash() { + String bcrypt = hasher.hash("hunter2"); + assertFalse(hasher.needsRehash(bcrypt)); + } + + private static String legacySha256(String password) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] result = digest.digest(password.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(result); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/me/golemcore/brain/config/ProdProfileSecurityGuardTest.java b/src/test/java/me/golemcore/brain/config/ProdProfileSecurityGuardTest.java new file mode 100644 index 0000000..d33efe1 --- /dev/null +++ b/src/test/java/me/golemcore/brain/config/ProdProfileSecurityGuardTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProdProfileSecurityGuardTest { + + private static final String VALID_SECRET = "0123456789abcdef0123456789abcdef-real-prod-secret"; + + @Test + void shouldAcceptFullyConfiguredProperties() { + WikiProperties properties = validProperties(); + assertDoesNotThrow(new ProdProfileSecurityGuard(properties)::verify); + } + + @Test + void shouldRejectAuthDisabled() { + WikiProperties properties = validProperties(); + properties.setAuthDisabled(true); + IllegalStateException error = assertThrows(IllegalStateException.class, + new ProdProfileSecurityGuard(properties)::verify); + assertTrue(error.getMessage().contains("auth-disabled")); + } + + @Test + void shouldRejectShortSecret() { + WikiProperties properties = validProperties(); + properties.getJwt().setSecret("too-short"); + IllegalStateException error = assertThrows(IllegalStateException.class, + new ProdProfileSecurityGuard(properties)::verify); + assertTrue(error.getMessage().contains("32 characters")); + } + + @Test + void shouldRejectMissingSecret() { + WikiProperties properties = validProperties(); + properties.getJwt().setSecret(null); + assertThrows(IllegalStateException.class, + new ProdProfileSecurityGuard(properties)::verify); + } + + @Test + void shouldRejectWellKnownPlaceholderSecret() { + WikiProperties properties = validProperties(); + properties.getJwt().setSecret(ProdProfileSecurityGuard.PLACEHOLDER_JWT_SECRET); + IllegalStateException error = assertThrows(IllegalStateException.class, + new ProdProfileSecurityGuard(properties)::verify); + assertTrue(error.getMessage().contains("placeholder")); + } + + @Test + void shouldRejectMissingAdminUsername() { + WikiProperties properties = validProperties(); + properties.setAdminUsername(null); + IllegalStateException error = assertThrows(IllegalStateException.class, + new ProdProfileSecurityGuard(properties)::verify); + assertTrue(error.getMessage().contains("admin-username")); + } + + @Test + void shouldRejectBlankAdminPassword() { + WikiProperties properties = validProperties(); + properties.setAdminPassword(" "); + IllegalStateException error = assertThrows(IllegalStateException.class, + new ProdProfileSecurityGuard(properties)::verify); + assertTrue(error.getMessage().contains("admin-password")); + } + + private static WikiProperties validProperties() { + WikiProperties properties = new WikiProperties(); + properties.setAuthDisabled(false); + properties.getJwt().setSecret(VALID_SECRET); + properties.setAdminUsername("admin"); + properties.setAdminPassword("strong-password"); + return properties; + } +} diff --git a/src/test/java/me/golemcore/brain/testsupport/MutableClock.java b/src/test/java/me/golemcore/brain/testsupport/MutableClock.java new file mode 100644 index 0000000..2cd4620 --- /dev/null +++ b/src/test/java/me/golemcore/brain/testsupport/MutableClock.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.testsupport; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +/** + * Shared test helper. Drive time deterministically; never use Thread.sleep in + * tests. + */ +public final class MutableClock extends Clock { + + private Instant now; + private final ZoneId zone; + + public MutableClock(Instant start) { + this(start, ZoneId.of("UTC")); + } + + public MutableClock(Instant start, ZoneId zone) { + this.now = start; + this.zone = zone; + } + + public void advance(Duration delta) { + this.now = this.now.plus(delta); + } + + public void setNow(Instant instant) { + this.now = instant; + } + + @Override + public Instant instant() { + return now; + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId overrideZone) { + return new MutableClock(now, overrideZone); + } +} diff --git a/src/test/java/me/golemcore/brain/web/SecurityHeadersAndCsrfIntegrationTest.java b/src/test/java/me/golemcore/brain/web/SecurityHeadersAndCsrfIntegrationTest.java new file mode 100644 index 0000000..9180d0c --- /dev/null +++ b/src/test/java/me/golemcore/brain/web/SecurityHeadersAndCsrfIntegrationTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2026 Aleksei Kuleshov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contact: alex@kuleshov.tech + */ + +package me.golemcore.brain.web; + +import jakarta.servlet.http.Cookie; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Verifies the production security posture: Spring Security headers are emitted + * on every response, mutating session-cookie requests without an X-XSRF-TOKEN + * are blocked with 403, and Bearer-token requests bypass CSRF (the JWT filter + * authenticates them out-of-band). + * + *

+ * This test deliberately re-enables CSRF (the project-wide + * {@code src/test/resources/application.properties} disables it for the rest of + * the suite) so we exercise the prod-equivalent filter chain. Because the + * global override sets {@code brain.security.csrf-enabled=false}, we override + * it again here and rebuild the MockMvc with + * {@link org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers#springSecurity()}. + *

+ */ +@SpringBootTest +@TestPropertySource(properties = { + "brain.security.csrf-enabled=true", + "brain.security.cookie-secure=false" +}) +class SecurityHeadersAndCsrfIntegrationTest { + + @TempDir + static Path tempDir; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("brain.storage-root", () -> tempDir.resolve("security-headers-test").toString()); + registry.add("brain.seed-demo-content", () -> "false"); + registry.add("brain.auth-disabled", () -> "false"); + registry.add("brain.public-access", () -> "false"); + registry.add("brain.admin-username", () -> "admin"); + registry.add("brain.admin-email", () -> "admin@example.com"); + registry.add("brain.admin-password", () -> "admin"); + } + + @Autowired + private WebApplicationContext context; + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + private MockMvc mockMvc; + + @org.junit.jupiter.api.BeforeEach + void setUp() { + // Rebuild MockMvc with the full security filter chain so CSRF/headers behave as + // in prod. + // Add the JWT filter explicitly — apply(springSecurity()) only registers the + // Spring + // Security chain, and our JwtAuthenticationFilter is a separate @Order(...) + // servlet + // filter that MockMvc would otherwise skip. + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .addFilters(jwtAuthenticationFilter) + .apply(springSecurity()) + .build(); + } + + @Test + void shouldEmitSecurityHeadersOnEveryResponse() throws Exception { + // Use secure(true) so Spring Security's HSTS writer emits + // Strict-Transport-Security + // (the default requestMatcher only adds it for HTTPS requests). + mockMvc.perform(get("/api/auth/config").secure(true)) + .andExpect(header().string("X-Frame-Options", "DENY")) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect(header().exists("Strict-Transport-Security")) + .andExpect(header().exists("Referrer-Policy")) + .andExpect(header().exists("Content-Security-Policy")); + } + + @Test + void shouldRejectMutatingPostWithoutCsrfToken() throws Exception { + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"identifier\":\"admin\",\"password\":\"admin\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void shouldAcceptPostWithMatchingXsrfTokenCookieAndHeader() throws Exception { + // Pull the CSRF cookie from any GET first, then echo it as a header on POST. + MvcResult getResult = mockMvc.perform(get("/api/auth/config")).andReturn(); + Cookie xsrf = getResult.getResponse().getCookie("XSRF-TOKEN"); + assertNotNull(xsrf, "XSRF-TOKEN cookie must be issued on idempotent GET"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .cookie(xsrf) + .header("X-XSRF-TOKEN", xsrf.getValue()) + .content("{\"identifier\":\"admin\",\"password\":\"admin\"}")) + .andExpect(status().isOk()); + } + + @Test + void shouldBypassCsrfForBearerAuthenticatedRequests() throws Exception { + // A garbage Bearer token must hit JwtAuthenticationFilter and produce 401 — + // proving the + // CSRF check was bypassed for this request class. Anything else + // (200/403/405/500) means + // the bypass is broken. + int status = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer not-a-real-token") + .content("{\"identifier\":\"admin\",\"password\":\"admin\"}")) + .andReturn() + .getResponse() + .getStatus(); + assertEquals(401, status, + "Bearer POST with invalid token must be rejected by JWT filter as 401, not by CSRF"); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..b531fc2 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,5 @@ +# Test-only overrides. CSRF is disabled because legacy controller tests POST through MockMvc +# without an XSRF token. The outbound guard is relaxed because some adapter tests hit local +# mock servers on 127.0.0.1. +brain.security.csrf-enabled=false +brain.outbound.allow-private-addresses=true