Skip to content

Commit 21a3606

Browse files
committed
fix: strip session token from bootstrap response body and add conditional secure cookie flag
- Remove sessionToken from the JSON response body of /api/auth/bootstrap to prevent XSS-based session theft. The token is still set as an httpOnly cookie, which is the intended transport for browser sessions. - Add secure flag to the session cookie when auth policy is 'remote-reachable', preventing cookie transmission over plaintext HTTP in non-loopback environments. - Update server tests to extract session tokens from Set-Cookie headers instead of the response body. - Update web client to no longer expect sessionToken in bootstrap response.
1 parent dca54c7 commit 21a3606

File tree

5 files changed

+23
-17
lines changed

5 files changed

+23
-17
lines changed

apps/server/src/auth/http.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ export const authBootstrapRouteLayer = HttpRouter.add(
4040
);
4141
const result = yield* serverAuth.exchangeBootstrapCredential(payload.credential);
4242

43-
return yield* HttpServerResponse.jsonUnsafe(result, { status: 200 }).pipe(
43+
const { sessionToken: _token, ...responseBody } = result;
44+
return yield* HttpServerResponse.jsonUnsafe(responseBody, { status: 200 }).pipe(
4445
HttpServerResponse.setCookie(descriptor.sessionCookieName, result.sessionToken, {
4546
expires: DateTime.toDate(result.expiresAt),
4647
httpOnly: true,
4748
path: "/",
4849
sameSite: "lax",
50+
secure: descriptor.policy === "remote-reachable",
4951
}),
5052
);
5153
}).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))),

apps/server/src/server.test.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,12 @@ const getHttpServerUrl = (pathname = "") =>
492492
return `http://127.0.0.1:${address.port}${pathname}`;
493493
});
494494

495+
function parseSessionTokenFromSetCookie(setCookie: string | null): string | null {
496+
if (!setCookie) return null;
497+
const match = /t3_session=([^;]+)/.exec(setCookie);
498+
return match?.[1] ?? null;
499+
}
500+
495501
const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) =>
496502
Effect.gen(function* () {
497503
const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap");
@@ -509,13 +515,15 @@ const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) =>
509515
const body = (yield* Effect.promise(() => response.json())) as {
510516
readonly authenticated: boolean;
511517
readonly sessionMethod: string;
512-
readonly sessionToken: string;
513518
readonly expiresAt: string;
514519
};
520+
const cookie = response.headers.get("set-cookie");
521+
const sessionToken = parseSessionTokenFromSetCookie(cookie);
515522
return {
516523
response,
517524
body,
518-
cookie: response.headers.get("set-cookie"),
525+
cookie,
526+
sessionToken,
519527
};
520528
});
521529

@@ -525,18 +533,18 @@ const getAuthenticatedSessionToken = (credential = defaultDesktopBootstrapToken)
525533
return cachedDefaultSessionToken;
526534
}
527535

528-
const { response, body } = yield* bootstrapBrowserSession(credential);
529-
if (!response.ok) {
536+
const { response, sessionToken } = yield* bootstrapBrowserSession(credential);
537+
if (!response.ok || !sessionToken) {
530538
return yield* Effect.fail(
531539
new Error(`Expected bootstrap session response to succeed, got ${response.status}`),
532540
);
533541
}
534542

535543
if (credential === defaultDesktopBootstrapToken) {
536-
cachedDefaultSessionToken = body.sessionToken;
544+
cachedDefaultSessionToken = sessionToken;
537545
}
538546

539-
return body.sessionToken;
547+
return sessionToken;
540548
});
541549

542550
const getWsServerUrl = (
@@ -720,13 +728,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
720728
Effect.gen(function* () {
721729
yield* buildAppUnderTest();
722730

723-
const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBrowserSession();
731+
const { response: bootstrapResponse, sessionToken: bootstrapSessionToken } =
732+
yield* bootstrapBrowserSession();
724733

725734
assert.equal(bootstrapResponse.status, 200);
735+
assert(bootstrapSessionToken, "Expected session token in Set-Cookie header");
726736

727737
const wsUrl = appendSessionTokenToUrl(
728738
yield* getWsServerUrl("/ws", { authenticated: false }),
729-
bootstrapBody.sessionToken,
739+
bootstrapSessionToken,
730740
);
731741
const response = yield* Effect.scoped(
732742
withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})),

apps/web/src/authBootstrap.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ describe("resolveInitialServerAuthGateState", () => {
6565
jsonResponse({
6666
authenticated: true,
6767
sessionMethod: "browser-session-cookie",
68-
sessionToken: "session-token",
6968
expiresAt: "2026-04-05T00:00:00.000Z",
7069
}),
7170
);
@@ -207,7 +206,6 @@ describe("resolveInitialServerAuthGateState", () => {
207206
jsonResponse({
208207
authenticated: true,
209208
sessionMethod: "browser-session-cookie",
210-
sessionToken: "session-token",
211209
expiresAt: "2026-04-05T00:00:00.000Z",
212210
}),
213211
);

apps/web/src/authBootstrap.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts";
1+
import type { AuthBootstrapInput, AuthSessionState } from "@t3tools/contracts";
22
import { resolveServerHttpUrl } from "./lib/utils";
33

44
export type ServerAuthGateState =
@@ -56,7 +56,7 @@ async function fetchSessionState(): Promise<AuthSessionState> {
5656
return (await response.json()) as AuthSessionState;
5757
}
5858

59-
async function exchangeBootstrapCredential(credential: string): Promise<AuthBootstrapResult> {
59+
async function exchangeBootstrapCredential(credential: string): Promise<void> {
6060
const payload: AuthBootstrapInput = { credential };
6161
const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/bootstrap" }), {
6262
body: JSON.stringify(payload),
@@ -71,8 +71,6 @@ async function exchangeBootstrapCredential(credential: string): Promise<AuthBoot
7171
const message = await response.text();
7272
throw new Error(message || `Failed to bootstrap auth session (${response.status}).`);
7373
}
74-
75-
return (await response.json()) as AuthBootstrapResult;
7674
}
7775

7876
async function bootstrapServerAuth(): Promise<ServerAuthGateState> {

apps/web/test/authHttpHandlers.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { ServerAuthDescriptor } from "@t3tools/contracts";
22
import { HttpResponse, http } from "msw";
33

44
const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z";
5-
const TEST_SESSION_TOKEN = "browser-test-session-token";
65

76
export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) {
87
return [
@@ -18,7 +17,6 @@ export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => Serv
1817
HttpResponse.json({
1918
authenticated: true,
2019
sessionMethod: "browser-session-cookie",
21-
sessionToken: TEST_SESSION_TOKEN,
2220
expiresAt: TEST_SESSION_EXPIRES_AT,
2321
}),
2422
),

0 commit comments

Comments
 (0)