mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
Update CSRF handling to set secure host-bound where possible (#13203)
This commit is contained in:
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) ?? "";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user