Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/features/preview/MarkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
if (SAFE_METHODS.has(method.toUpperCase())) {
return {}
}
const token = readCookie('XSRF-TOKEN')
return token ? { 'X-XSRF-TOKEN': token } : {}
}

async function readJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
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,
Expand Down
13 changes: 13 additions & 0 deletions misc/spotbugs-exclude.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@
</Or>
</Match>

<!-- Spring Security's SecurityFilterChain @Bean signature mandates `throws Exception`. -->
<Match>
<Bug pattern="THROWS_METHOD_THROWS_CLAUSE_BASIC_EXCEPTION" />
<Class name="me.golemcore.brain.config.SecurityConfig" />
</Match>

<!-- Header value is validated against [A-Za-z0-9._-]{0,64} before being echoed back; the
regex eliminates the CRLF / control-char injection vector this rule guards against. -->
<Match>
<Bug pattern="HRS_REQUEST_PARAMETER_TO_HTTP_HEADER" />
<Class name="me.golemcore.brain.web.RequestIdFilter" />
</Match>

<!-- Scheduler dispatch must restore coalescing state before propagating executor rejection. -->
<Match>
<Bug pattern="THROWS_METHOD_THROWS_RUNTIMEEXCEPTION" />
Expand Down
11 changes: 10 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.5</version>
<version>4.0.6</version>
<relativePath/>
</parent>

Expand Down Expand Up @@ -56,6 +56,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
Expand Down Expand Up @@ -135,6 +139,11 @@
<artifactId>spring-boot-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, String>> handleNotFound(WikiNotFoundException exception) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", exception.getMessage()));
public ResponseEntity<Map<String, Object>> handleNotFound(WikiNotFoundException exception, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body(exception.getMessage(), req));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException exception) {
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException exception,
HttpServletRequest req) {
return ResponseEntity.badRequest().body(body(exception.getMessage(), req));
}

@ExceptionHandler(AuthUnauthorizedException.class)
public ResponseEntity<Map<String, String>> handleUnauthorized(AuthUnauthorizedException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", exception.getMessage()));
public ResponseEntity<Map<String, Object>> handleUnauthorized(AuthUnauthorizedException exception,
HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body(exception.getMessage(), req));
}

@ExceptionHandler(AuthAccessDeniedException.class)
public ResponseEntity<Map<String, String>> handleForbidden(AuthAccessDeniedException exception) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", exception.getMessage()));
public ResponseEntity<Map<String, Object>> handleForbidden(AuthAccessDeniedException exception,
HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body(exception.getMessage(), req));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException exception) {
public ResponseEntity<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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)
Expand All @@ -76,6 +109,50 @@ public ResponseEntity<PageEditConflictResponse> 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<Map<String, Object>> 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<String, Object> body(String error, HttpServletRequest req) {
Map<String, Object> 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())
Expand Down
Loading
Loading