feat(security): add CSRF protection for all state-changing API routes
Build & Push Docker Image / build (push) Successful in 2m16s
Build & Push Docker Image / build (push) Successful in 2m16s
- Synchronizer token stored in session; GET /auth/csrf to obtain it - csrfProtection middleware requires X-CSRF-Token on non-safe methods - customFetch injects the header via setCsrfTokenGetter - toolrate boot loads token; reload after local login (session regenerate) - OpenAPI GET /auth/csrf + CsrfToken schema, orval regenerated
This commit is contained in:
@@ -17,6 +17,7 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json";
|
||||
|
||||
let _baseUrl: string | null = null;
|
||||
let _authTokenGetter: AuthTokenGetter | null = null;
|
||||
let _csrfTokenGetter: (() => string | null) | null = null;
|
||||
|
||||
/**
|
||||
* Set a base URL that is prepended to every relative request URL
|
||||
@@ -44,6 +45,15 @@ export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
|
||||
_authTokenGetter = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a getter that supplies a CSRF token. Before every state-changing
|
||||
* fetch an `X-CSRF-Token` header is attached when the getter returns a value.
|
||||
* Pass `null` to clear the getter.
|
||||
*/
|
||||
export function setCsrfTokenGetter(getter: (() => string | null) | null): void {
|
||||
_csrfTokenGetter = getter;
|
||||
}
|
||||
|
||||
function isRequest(input: RequestInfo | URL): input is Request {
|
||||
return typeof Request !== "undefined" && input instanceof Request;
|
||||
}
|
||||
@@ -349,6 +359,14 @@ export async function customFetch<T = unknown>(
|
||||
headers.set("accept", DEFAULT_JSON_ACCEPT);
|
||||
}
|
||||
|
||||
// Attach CSRF token for state-changing requests, unless one is already set.
|
||||
if (_csrfTokenGetter && !headers.has("x-csrf-token")) {
|
||||
const csrf = _csrfTokenGetter();
|
||||
if (csrf) {
|
||||
headers.set("x-csrf-token", csrf);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach bearer token when an auth getter is configured and no
|
||||
// Authorization header has been explicitly provided.
|
||||
if (_authTokenGetter && !headers.has("authorization")) {
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface AuthMode {
|
||||
mode: AuthModeMode;
|
||||
}
|
||||
|
||||
export interface CsrfToken {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface LocalLoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
AuthUser,
|
||||
CategoryStats,
|
||||
ChangePasswordInput,
|
||||
CsrfToken,
|
||||
EmptyTrash200,
|
||||
ErrorResponse,
|
||||
GetRatingDistributionParams,
|
||||
@@ -1906,6 +1907,83 @@ export function useGetAuthMode<TData = Awaited<ReturnType<typeof getAuthMode>>,
|
||||
|
||||
|
||||
|
||||
export const getGetCsrfTokenUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/auth/csrf`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a CSRF token for state-changing requests
|
||||
*/
|
||||
export const getCsrfToken = async ( options?: RequestInit): Promise<CsrfToken> => {
|
||||
|
||||
return customFetch<CsrfToken>(getGetCsrfTokenUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetCsrfTokenQueryKey = () => {
|
||||
return [
|
||||
`/api/auth/csrf`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetCsrfTokenQueryOptions = <TData = Awaited<ReturnType<typeof getCsrfToken>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetCsrfTokenQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getCsrfToken>>> = ({ signal }) => getCsrfToken({ signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type GetCsrfTokenQueryResult = NonNullable<Awaited<ReturnType<typeof getCsrfToken>>>
|
||||
export type GetCsrfTokenQueryError = ErrorType<unknown>
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get a CSRF token for state-changing requests
|
||||
*/
|
||||
|
||||
export function useGetCsrfToken<TData = Awaited<ReturnType<typeof getCsrfToken>>, TError = ErrorType<unknown>>(
|
||||
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
|
||||
const queryOptions = getGetCsrfTokenQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getLocalLoginUrl = () => {
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./generated/api";
|
||||
export * from "./generated/api.schemas";
|
||||
export { setBaseUrl, setAuthTokenGetter, customFetch } from "./custom-fetch";
|
||||
export { setBaseUrl, setAuthTokenGetter, setCsrfTokenGetter, customFetch } from "./custom-fetch";
|
||||
export type { AuthTokenGetter } from "./custom-fetch";
|
||||
|
||||
Reference in New Issue
Block a user