Skip to content
Open
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
7 changes: 5 additions & 2 deletions docs/auth-feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ js/src/features/auth/
`{ id, name, email, isAdmin }`; 401 in the envelope when signed out. The frontend `RequireAuth`
guard sends signed-out visitors to `/login`.

Why CSRF protection is off, and why that is safe here, is documented on `SecurityConfig` — keep that
javadoc current if the cookie or CORS posture ever changes.
**CSRF.** Double-submit protection (the Spring-documented SPA pattern): every response sets a
JS-readable `XSRF-TOKEN` cookie, and `apiFetch` echoes it back as an `X-XSRF-TOKEN` header on
state-changing requests. The two pre-auth endpoints (`request-link`, `verify`) are exempt — their
only credential travels in the body, and a first-time visitor has no CSRF cookie yet. Details and
rationale live in `SecurityConfig`'s javadoc.

## Manual test walkthrough (dev)

Expand Down
27 changes: 26 additions & 1 deletion js/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
* so hooks receive typed payloads and errors carry the server's message.
* Auth rides on an httpOnly session cookie, hence `credentials: "same-origin"`
* (the SPA and API share an origin — the Vite proxy provides that in dev).
*
* CSRF: the backend sets a JS-readable `XSRF-TOKEN` cookie on every response;
* state-changing requests must echo it back as an `X-XSRF-TOKEN` header
* (double-submit pattern). The pre-auth endpoints (request-link, verify) are
* exempt server-side, so a missing cookie on first visit is fine.
*/

export class ApiError extends Error {
Expand All @@ -24,14 +29,34 @@ interface ApiEnvelope<T> {
payload?: T;
}

function readCookie(name: string): string | undefined {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.slice(name.length + 1);
}

function csrfHeader(method: string): Record<string, string> {
if (method === "GET" || method === "HEAD") {
return {};
}
const token = readCookie("XSRF-TOKEN");
return token ? { "X-XSRF-TOKEN": decodeURIComponent(token) } : {};
}

export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
const method = (init?.method ?? "GET").toUpperCase();
const response = await fetch(`/api${path}`, {
credentials: "same-origin",
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
headers: {
"Content-Type": "application/json",
...csrfHeader(method),
...init?.headers,
},
});

const envelope = (await response.json().catch(() => undefined)) as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.session.web.http.DefaultCookieSerializer;

/**
Expand All @@ -26,12 +28,14 @@
* client-supplied session id, so session fixation is prevented by construction (verify additionally rotates any
* pre-existing session id).
*
* <p><b>Why CSRF protection is disabled.</b> Three layers make the classic attack a no-op here: the session cookie is
* {@code SameSite=Lax}, so browsers do not attach it to cross-site POSTs; every state-changing endpoint only consumes
* {@code application/json} request bodies, which cross-site forms cannot produce and cross-origin {@code fetch} cannot
* send without a CORS preflight (and no CORS mappings exist — the SPA is served same-origin, via the Vite proxy in
* dev); and the one anonymous cookie-consuming POST, logout, is idempotent and harmless. Revisit if the cookie ever
* needs {@code SameSite=None}.
* <p><b>CSRF.</b> Double-submit protection via the Spring-documented SPA pattern: {@code CookieCsrfTokenRepository}
* writes a JS-readable {@code XSRF-TOKEN} cookie on every response (see {@link SpaCsrfTokenRequestHandler}), and the
* frontend echoes it back as an {@code X-XSRF-TOKEN} header on state-changing requests. This is defense-in-depth on top
* of the {@code SameSite=Lax} session cookie and the JSON-only request bodies. Three anonymous endpoints are exempt —
* {@code POST} of {@code auth/request-link}, {@code auth/verify}, and {@code members} (sign-up). None of them ride a
* session (their only credential, if any, is in the body), and a first-time visitor has no {@code XSRF-TOKEN} cookie
* yet, so requiring the token would block real sign-ins and sign-ups while protecting nothing. Each exemption is pinned
* to {@code POST} so future authenticated verbs on those paths keep protection.
*/
@Configuration
@EnableConfigurationProperties({AuthProperties.class, SessionProperties.class})
Expand Down Expand Up @@ -96,7 +100,17 @@ SecurityFilterChain devSecurityFilterChain(final HttpSecurity http) throws Excep
}

private HttpSecurity common(final HttpSecurity http) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
return http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
.ignoringRequestMatchers(
PathPatternRequestMatcher.withDefaults()
.matcher(HttpMethod.POST, "/api/auth/request-link"),
PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, "/api/auth/verify"),
// Sign-up is anonymous: a first-time visitor has no XSRF-TOKEN cookie yet (the SPA is
// served by Vite in dev, so nothing has hit the backend), and there is no session to
// ride, so the token would block real sign-ups while protecting nothing. Scoped to
// POST only — the member domain's authenticated PATCH/DELETE must keep CSRF.
PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, "/api/members")))
.requestCache(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.securityContext(context -> context.securityContextRepository(securityContextRepository()))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.patinanetwork.patchats.auth.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.function.Supplier;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;

/**
* The CSRF request handler for single-page apps, straight from the Spring Security reference. Two jobs:
*
* <ul>
* <li>{@link #handle} applies BREACH protection (XOR masking) to server-rendered token values and resolves the
* deferred token on every request, which makes {@code CookieCsrfTokenRepository} write the readable
* {@code XSRF-TOKEN} cookie the SPA echoes back.
* <li>{@link #resolveCsrfTokenValue} accepts the raw (unmasked) value when it arrives via the {@code X-XSRF-TOKEN}
* header — the SPA copies the cookie verbatim — while still unmasking values submitted as request parameters.
* </ul>
*/
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {

private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();

@Override
public void handle(
final HttpServletRequest request, final HttpServletResponse response, final Supplier<CsrfToken> csrfToken) {
this.xor.handle(request, response, csrfToken);
// Resolve the deferred token so the repository writes the XSRF-TOKEN cookie on this response.
csrfToken.get();
}

@Override
public String resolveCsrfTokenValue(final HttpServletRequest request, final CsrfToken csrfToken) {
final String headerValue = request.getHeader(csrfToken.getHeaderName());
return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package org.patinanetwork.patchats.auth.security;

import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import jakarta.servlet.http.HttpServletResponse;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -63,10 +66,31 @@ void requestLinkIsReachableAnonymously() throws Exception {
void emailEndpointStaysAdminOnlyAndFailsClosed() throws Exception {
mockMvc.perform(post("/api/email/send")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.content("{}")
.with(csrf()))
.andExpect(status().isUnauthorized());
}

@Test
void stateChangingPostWithoutCsrfTokenIsForbidden() throws Exception {
// Logout is not in the CSRF-exempt set: it consumes the session cookie, so it needs the token.
mockMvc.perform(post("/api/auth/logout")).andExpect(status().isForbidden());
}

@Test
void anonymousSignUpIsExemptFromCsrf() throws Exception {
// A first-time visitor POSTing the sign-up form has no XSRF-TOKEN cookie yet, so requiring the token would
// make sign-up impossible. MemberController is outside this slice, so the concrete status is incidental —
// what matters is that CSRF did not reject it. See the exemption list in SecurityConfig.
mockMvc.perform(post("/api/members")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(result -> assertNotEquals(
HttpServletResponse.SC_FORBIDDEN,
result.getResponse().getStatus(),
"anonymous sign-up must not be blocked by CSRF"));
}

@Test
void verifyEstablishesASessionThatAuthenticatesLaterRequests() throws Exception {
final Member member = Member.builder()
Expand Down Expand Up @@ -110,7 +134,7 @@ void logoutInvalidatesTheSession() throws Exception {
final MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
assertNotNull(session);

mockMvc.perform(post("/api/auth/logout").session(session)).andExpect(status().isOk());
mockMvc.perform(post("/api/auth/logout").session(session).with(csrf())).andExpect(status().isOk());
assertTrue(session.isInvalid());
}
}