Update CSRF handling to set secure host-bound where possible (#13203)

This commit is contained in:
Tom Moor
2026-07-30 17:27:11 -04:00
committed by GitHub
parent a15e66740a
commit 9a50a980f8
9 changed files with 56 additions and 17 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useRef } from "react";
import { getCookie } from "tiny-cookie";
import { CSRF } from "@shared/constants";
import { getCSRFToken } from "~/utils/csrf";
/**
* Form component that automatically includes a CSRF token as a hidden input
@@ -16,7 +16,7 @@ export const Form = ({
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
if (tokenRef.current) {
tokenRef.current.value = getCookie(CSRF.cookieName) ?? "";
tokenRef.current.value = getCSRFToken();
}
onSubmit?.(event);
},
@@ -29,7 +29,7 @@ export const Form = ({
ref={tokenRef}
type="hidden"
name={CSRF.fieldName}
defaultValue={getCookie(CSRF.cookieName) ?? ""}
defaultValue={getCSRFToken()}
/>
{children}
</form>
@@ -3,7 +3,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { getCookie } from "tiny-cookie";
import { CSRF } from "@shared/constants";
import { Client } from "@shared/types";
import { errToString } from "@shared/utils/error";
@@ -12,6 +11,7 @@ import PluginIcon from "~/components/PluginIcon";
import useQuery from "~/hooks/useQuery";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import { getCSRFToken } from "~/utils/csrf";
type Props = React.ComponentProps<typeof ButtonLarge>;
@@ -99,7 +99,7 @@ export function PasskeyAuthenticationProvider(props: Props) {
flattenFormFields({
...authResp,
challengeId,
[CSRF.fieldName]: getCookie(CSRF.cookieName),
[CSRF.fieldName]: getCSRFToken(),
client: verifyClient,
})
);
+2 -2
View File
@@ -23,8 +23,8 @@ import {
UnprocessableEntityError,
UpdateRequiredError,
} from "./errors";
import { getCookie } from "tiny-cookie";
import { CSRF } from "@shared/constants";
import { getCSRFToken } from "./csrf";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
type Options = {
@@ -186,7 +186,7 @@ class ApiClient {
// rotation of the cookie since the request was prepared.
const fetchWithFreshCsrfToken: typeof fetch = (input, init) => {
if (requiresCsrfToken) {
const csrfToken = getCookie(CSRF.cookieName);
const csrfToken = getCSRFToken();
if (csrfToken) {
headers.set(CSRF.headerName, csrfToken);
}
+12
View File
@@ -0,0 +1,12 @@
import { getCookie } from "tiny-cookie";
import { CSRF } from "@shared/constants";
/**
* Reads the CSRF token that the server attached to the current document,
* preferring the host-bound cookie when present.
*
* @returns The token, or an empty string when no CSRF cookie is present.
*/
export function getCSRFToken(): string {
return getCookie(CSRF.secureCookieName) ?? getCookie(CSRF.cookieName) ?? "";
}
+2 -1
View File
@@ -15,6 +15,7 @@ import type { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { VerificationCode } from "@server/utils/VerificationCode";
import { signIn } from "@server/utils/authentication";
import { getTokenFromCookie } from "@server/utils/csrf";
import { getUserForEmailSigninToken } from "@server/utils/jwt";
import { getTeamFromContext } from "@server/utils/passport";
import * as T from "./schema";
@@ -112,7 +113,7 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
const csrfToken = ctx.cookies.get(CSRF.cookieName);
const csrfToken = getTokenFromCookie(ctx);
// Parse the current URL to extract existing query parameters
const url = new URL(ctx.request.href);
+15 -8
View File
@@ -7,8 +7,8 @@ import {
generateRawToken,
bundleToken,
unbundleToken,
getTokenFromCookie,
} from "@server/utils/csrf";
import { getCookieDomain } from "@shared/utils/domains";
import { CSRF } from "@shared/constants";
import { CSRFError } from "@server/errors";
import { parseAuthentication } from "./authentication";
@@ -22,13 +22,20 @@ export function attachCSRFToken() {
if (["GET", "HEAD", "OPTIONS"].includes(ctx.method)) {
const raw = generateRawToken(16);
const bundled = bundleToken(raw, env.SECRET_KEY);
const secure = ctx.request.secure;
// Set cookie that JavaScript can read (not HttpOnly)
ctx.cookies.set(CSRF.cookieName, bundled, {
httpOnly: false,
sameSite: "lax",
domain: getCookieDomain(ctx.request.hostname, env.isCloudHosted),
});
// Set cookie that JavaScript can read (not HttpOnly). Unlike the UI hint
// cookies, this one is deliberately host-only and never scoped to the
// base domain
ctx.cookies.set(
secure ? CSRF.secureCookieName : CSRF.cookieName,
bundled,
{
httpOnly: false,
sameSite: "lax",
secure,
}
);
}
await next();
@@ -77,7 +84,7 @@ export function verifyCSRFToken() {
}
// Get token from cookie
const cookieVal = ctx.cookies.get(CSRF.cookieName);
const cookieVal = getTokenFromCookie(ctx);
if (!cookieVal) {
throw CSRFError("CSRF token missing from cookie");
}
+2 -1
View File
@@ -12,6 +12,7 @@ import { toError } from "@shared/utils/error";
import env from "@server/env";
import { InternalError, ValidationError } from "@server/errors";
import Logger from "@server/logging/Logger";
import { getTokenFromCookie } from "@server/utils/csrf";
import BaseStorage from "./BaseStorage";
import { CSRF } from "@shared/constants";
import type { AppContext } from "@server/types";
@@ -37,7 +38,7 @@ export default class LocalStorage extends BaseStorage {
maxUploadSize: String(maxUploadSize),
contentType,
sig,
[CSRF.fieldName]: ctx.cookies.get(CSRF.cookieName) || "",
[CSRF.fieldName]: getTokenFromCookie(ctx) ?? "",
},
});
}
+13
View File
@@ -1,6 +1,19 @@
import { randomBytes, createHmac } from "node:crypto";
import type { Context } from "koa";
import { CSRF } from "@shared/constants";
import { safeEqual } from "./crypto";
/**
* Reads the CSRF token from the request cookies, preferring the host-bound cookie
*
* @param ctx The request context
* @returns The token, or undefined when no CSRF cookie is present.
*/
export const getTokenFromCookie = (
ctx: Pick<Context, "cookies">
): string | undefined =>
ctx.cookies.get(CSRF.secureCookieName) ?? ctx.cookies.get(CSRF.cookieName);
/**
* Generates cryptographically secure random bytes
*
+5
View File
@@ -33,8 +33,13 @@ export const Pagination = {
};
export const CSRF = {
/** Cookie name used on secure origins. */
secureCookieName: "__Host-csrfToken",
/** Cookie name used over HTTP, where the `__Host-` prefix is not accepted. */
cookieName: "csrfToken",
/** Request header that carries the token for API requests. */
headerName: "x-csrf-token",
/** Hidden field that carries the token for native form submissions. */
fieldName: "_csrf",
};