diff --git a/artifacts/api-server/src/middleware/csrf.ts b/artifacts/api-server/src/middleware/csrf.ts new file mode 100644 index 0000000..a83a4dd --- /dev/null +++ b/artifacts/api-server/src/middleware/csrf.ts @@ -0,0 +1,25 @@ +import crypto from "node:crypto"; +import type { Request, Response, NextFunction } from "express"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); + +export function getCsrfToken(req: Request): string { + if (!req.session.csrfToken) { + req.session.csrfToken = crypto.randomBytes(24).toString("hex"); + } + return req.session.csrfToken; +} + +export function csrfProtection(req: Request, res: Response, next: NextFunction): void { + if (SAFE_METHODS.has(req.method.toUpperCase())) { + next(); + return; + } + const provided = req.headers["x-csrf-token"]; + const token = getCsrfToken(req); + if (typeof provided === "string" && provided && provided === token) { + next(); + return; + } + res.status(403).json({ error: "CSRF token missing or invalid" }); +} \ No newline at end of file diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index e628dd8..a2f094e 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -8,6 +8,7 @@ import { logger } from "../lib/logger"; import { writeAuditLog } from "../lib/audit"; import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit"; import { getEntitlements, requireFeature } from "../middleware/feature"; +import { getCsrfToken } from "../middleware/csrf"; const router: IRouter = Router(); @@ -109,6 +110,10 @@ router.get("/auth/mode", (_req, res): void => { res.json({ mode: isOidcConfigured() ? "oidc" : "local" }); }); +router.get("/auth/csrf", (req, res): void => { + res.json({ token: getCsrfToken(req) }); +}); + router.post("/auth/login", loginRateLimit, async (req, res): Promise => { if (isOidcConfigured()) { res.status(400).json({ error: "Use OIDC login when Keycloak is configured." }); diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 12df8c4..94ab680 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -8,9 +8,12 @@ import usersRouter from "./users"; import auditRouter from "./audit"; import costsRouter from "./costs"; import adminRouter from "./admin"; +import { csrfProtection } from "../middleware/csrf"; const router: IRouter = Router(); +router.use(csrfProtection); + router.use(authRouter); router.use(healthRouter); router.use(toolsRouter); diff --git a/artifacts/api-server/src/types/session.d.ts b/artifacts/api-server/src/types/session.d.ts index fe0f5cb..bb31568 100644 --- a/artifacts/api-server/src/types/session.d.ts +++ b/artifacts/api-server/src/types/session.d.ts @@ -14,5 +14,6 @@ declare module "express-session" { codeVerifier?: string; returnTo?: string; oidcState?: string; + csrfToken?: string; } } diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index 92babec..28f10e6 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -1,7 +1,9 @@ import { Switch, Route, Router as WouterRouter } from "wouter"; +import { useEffect } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nextProvider } from "react-i18next"; import i18n from "@/i18n"; +import { loadCsrfToken } from "@/lib/csrf"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -50,6 +52,9 @@ function Router() { } function App() { + useEffect(() => { + void loadCsrfToken(); + }, []); return ( diff --git a/artifacts/toolrate/src/lib/csrf.ts b/artifacts/toolrate/src/lib/csrf.ts new file mode 100644 index 0000000..ebc460c --- /dev/null +++ b/artifacts/toolrate/src/lib/csrf.ts @@ -0,0 +1,27 @@ +import { setCsrfTokenGetter } from "@workspace/api-client-react"; + +let token: string | null = null; + +setCsrfTokenGetter(() => token); + +export function getCsrfToken(): string | null { + return token; +} + +export async function loadCsrfToken(): Promise { + try { + const res = await fetch(`/api/auth/csrf`, { + credentials: "include", + }); + if (!res.ok) { + token = null; + return null; + } + const data = (await res.json()) as { token?: string }; + token = data.token ?? null; + return token; + } catch { + token = null; + return null; + } +} \ No newline at end of file diff --git a/artifacts/toolrate/src/pages/login.tsx b/artifacts/toolrate/src/pages/login.tsx index 838c78c..9f1c5f6 100644 --- a/artifacts/toolrate/src/pages/login.tsx +++ b/artifacts/toolrate/src/pages/login.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/password-input"; +import { loadCsrfToken } from "@/lib/csrf"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { ThemeToggle } from "@/components/theme-toggle"; import { Wrench, AlertCircle } from "lucide-react"; @@ -33,6 +34,7 @@ export default function Login() { { onSuccess: () => { queryClient.invalidateQueries(); + void loadCsrfToken(); setLocation(returnTo); }, onError: (err) => { diff --git a/lib/api-client-react/src/custom-fetch.ts b/lib/api-client-react/src/custom-fetch.ts index ee21bad..06df2fb 100644 --- a/lib/api-client-react/src/custom-fetch.ts +++ b/lib/api-client-react/src/custom-fetch.ts @@ -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( 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")) { diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 635892d..2244b19 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -30,6 +30,10 @@ export interface AuthMode { mode: AuthModeMode; } +export interface CsrfToken { + token: string; +} + export interface LocalLoginInput { username: string; password: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 81939f4..ea9e07d 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -26,6 +26,7 @@ import type { AuthUser, CategoryStats, ChangePasswordInput, + CsrfToken, EmptyTrash200, ErrorResponse, GetRatingDistributionParams, @@ -1906,6 +1907,83 @@ export function useGetAuthMode>, +export const getGetCsrfTokenUrl = () => { + + + + + return `/api/auth/csrf` +} + +/** + * @summary Get a CSRF token for state-changing requests + */ +export const getCsrfToken = async ( options?: RequestInit): Promise => { + + return customFetch(getGetCsrfTokenUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetCsrfTokenQueryKey = () => { + return [ + `/api/auth/csrf` + ] as const; + } + + +export const getGetCsrfTokenQueryOptions = >, TError = ErrorType>( options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetCsrfTokenQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getCsrfToken({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetCsrfTokenQueryResult = NonNullable>> +export type GetCsrfTokenQueryError = ErrorType + + +/** + * @summary Get a CSRF token for state-changing requests + */ + +export function useGetCsrfToken>, TError = ErrorType>( + options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getGetCsrfTokenQueryOptions(options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + export const getLocalLoginUrl = () => { diff --git a/lib/api-client-react/src/index.ts b/lib/api-client-react/src/index.ts index e554747..406dc23 100644 --- a/lib/api-client-react/src/index.ts +++ b/lib/api-client-react/src/index.ts @@ -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"; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 8932905..3297a39 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -570,6 +570,19 @@ paths: schema: $ref: "#/components/schemas/AuthMode" + /auth/csrf: + get: + operationId: getCsrfToken + tags: [auth] + summary: Get a CSRF token for state-changing requests + responses: + "200": + description: CSRF token + content: + application/json: + schema: + $ref: "#/components/schemas/CsrfToken" + /auth/login: post: operationId: localLogin @@ -942,6 +955,13 @@ components: type: string enum: [oidc, local] + CsrfToken: + type: object + required: [token] + properties: + token: + type: string + LocalLoginInput: type: object required: [username, password] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index aca536e..f3b047c 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -445,6 +445,14 @@ export const GetAuthModeResponse = zod.object({ }) +/** + * @summary Get a CSRF token for state-changing requests + */ +export const GetCsrfTokenResponse = zod.object({ + "token": zod.string() +}) + + /** * @summary Local username/password login */ diff --git a/lib/api-zod/src/generated/types/csrfToken.ts b/lib/api-zod/src/generated/types/csrfToken.ts new file mode 100644 index 0000000..57f4019 --- /dev/null +++ b/lib/api-zod/src/generated/types/csrfToken.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * ToolRate API — Tool listing and rating platform + * OpenAPI spec version: 0.1.0 + */ + +export interface CsrfToken { + token: string; +} diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index 22023aa..7f60dc6 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -15,6 +15,7 @@ export * from './authUserRole'; export * from './authUserTier'; export * from './categoryStats'; export * from './changePasswordInput'; +export * from './csrfToken'; export * from './emptyTrash200'; export * from './errorResponse'; export * from './getRatingDistributionParams';