diff --git a/apps/ui/middleware.ts b/apps/ui/middleware.ts index 58ff89833d..7d2acfdcd8 100644 --- a/apps/ui/middleware.ts +++ b/apps/ui/middleware.ts @@ -10,11 +10,13 @@ import type { NextRequest } from "next/server"; import { getSignInUrl } from "@/modules/const"; import { env } from "@/env.mjs"; +const apiBaseUrl = env.TILAVARAUS_API_URL ?? ""; + +/// should we redirect to the login page function redirectProtectedRoute(req: NextRequest) { // TODO check that the cookie is valid not just present const { cookies, headers } = req; const hasSession = cookies.has("sessionid"); - const apiBaseUrl = env.TILAVARAUS_API_URL ?? ""; if (!hasSession) { // on the server we are behind a gateway so get the forwarded headers @@ -29,6 +31,40 @@ function redirectProtectedRoute(req: NextRequest) { return undefined; } +/// are we missing a csrf token in cookies and should redirect to get one +function redirectCsrfToken(req: NextRequest): URL | undefined { + // need to ignore all assets outside of html requests (which don't have an extension) + // so could we just check any request that doesn't have an extension? + if ( + req.url.startsWith("/_next") || + req.url.match( + /\.(js|css|png|jpg|jpeg|svg|gif|ico|json|woff|woff2|ttf|eot|otf)$/ + ) + ) { + return undefined; + } + + const { cookies } = req; + const hasCsrfToken = cookies.has("csrftoken"); + if (hasCsrfToken) { + return undefined; + } + + const csrfUrl = `${apiBaseUrl}/csrf/`; + const redirectUrl = new URL(csrfUrl); + const requestUrl = new URL(req.url); + + // On server envs everything is in the same domain and 80/443 ports, so ignore the host part of the url. + // More robust solution (supporting separate domains) would need to take into account us being behind + // a gateway so the public url doesn't match the internal url. + const origin = requestUrl.origin; + const hostPart = origin.includes("localhost") ? origin : ""; + const next = `${hostPart}${requestUrl.pathname}`; + redirectUrl.searchParams.set("redirect_to", next); + + return redirectUrl; +} + // Run the middleware only on paths that require authentication // NOTE don't define nested routes, only single word top level routes are supported // refactor the matcher or fix the underlining matcher issue in nextjs @@ -43,12 +79,23 @@ const authenticatedRoutes = [ "success", ]; // url matcher that is very specific to our case -const doesUrlMatch = (url: string, route: string) => { +function doesUrlMatch(url: string, route: string) { const ref: string[] = url.split("/"); return ref.includes(route); -}; +} + +export async function middleware(req: NextRequest) { + const redirectUrl = redirectCsrfToken(req); + if (redirectUrl) { + // block infinite redirect loop (there is no graceful way to handle this) + if (req.url.includes("redirect_to")) { + // eslint-disable-next-line no-console + console.error("Infinite redirect loop detected"); + return NextResponse.next(); + } + return NextResponse.redirect(redirectUrl); + } -export const middleware = async (req: NextRequest) => { if (authenticatedRoutes.some((route) => doesUrlMatch(req.url, route))) { const redirect = redirectProtectedRoute(req); if (redirect) { @@ -56,7 +103,7 @@ export const middleware = async (req: NextRequest) => { } } return NextResponse.next(); -}; +} export const config = { /* i18n locale router and middleware have a bug in nextjs, matcher breaks the router diff --git a/apps/ui/modules/apolloClient.ts b/apps/ui/modules/apolloClient.ts index af0f0ea04f..acdd4188da 100644 --- a/apps/ui/modules/apolloClient.ts +++ b/apps/ui/modules/apolloClient.ts @@ -31,20 +31,16 @@ function getServerCookie( ) { const cookie = headers?.cookie; if (cookie == null) { - // eslint-disable-next-line no-console - console.warn("cookie not found in headers", headers); return null; } const decoded = qs.decode(cookie, "; "); const token = decoded[name]; if (token == null) { - // eslint-disable-next-line no-console - console.warn(`${name} not found in cookie`, decoded); return null; } if (Array.isArray(token)) { // eslint-disable-next-line no-console - console.warn(`multiple ${name} in cookies`, decoded); + console.warn(`multiple ${name} in cookies`, token); return token[0]; } return token; @@ -55,28 +51,46 @@ export function createApolloClient( ctx?: GetServerSidePropsContext ) { const isServer = typeof window === "undefined"; + + const uri = buildGraphQLUrl(hostUrl, env.ENABLE_FETCH_HACK); const csrfToken = isServer ? getServerCookie(ctx?.req?.headers, "csrftoken") : getCookie("csrftoken"); - const sessionCookie = isServer - ? getServerCookie(ctx?.req?.headers, "sessionid") - : getCookie("sessionid"); - - const uri = buildGraphQLUrl(hostUrl, env.ENABLE_FETCH_HACK); - - // TODO on client side we might not need this (only on SSR) - const enchancedFetch = (url: RequestInfo | URL, init?: RequestInit) => { + const enchancedFetch = async (url: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers({ ...(init?.headers != null ? init.headers : {}), + // TODO missing csrf token is a non recoverable error ...(csrfToken != null ? { "X-Csrftoken": csrfToken } : {}), - // NOTE server requests don't include cookies by default - // TODO include session cookie here also when we use SSR for user specific requests - ...(csrfToken != null ? { Cookie: `csrftoken=${csrfToken}` } : {}), }); - if (sessionCookie != null) { - headers.append("Cookie", `sessionid=${sessionCookie}`); + // NOTE server requests don't include cookies by default + // TODO do we want to copy request headers from client or no? + if (isServer) { + if (csrfToken == null) { + throw new Error("csrftoken not found in cookies"); + } + headers.append("Set-Cookie", `csrftoken=${csrfToken}`); + headers.append("Cookie", `csrftoken=${csrfToken}`); + // Django fails with 403 if there is no referer (only on Kubernetes) + const requestUrl = ctx?.req?.url ?? ""; + const hostname = + ctx?.req?.headers?.["x-forwarded-host"] ?? + ctx?.req?.headers?.host ?? + ""; + // NOTE not exactly correct + // For our case this is sufficent because we are always behind a proxy, + // but technically there is a case where we are not behind a gateway and not localhost + // so the proto would be https and no x-forwarded-proto set + // TODO we have .json blobs in the referer (translations), does it matter? + const proto = ctx?.req?.headers?.["x-forwarded-proto"] ?? "http"; + headers.append("Referer", `${proto}://${hostname}${requestUrl}`); + + const sessionCookie = getServerCookie(ctx?.req?.headers, "sessionid"); + if (sessionCookie != null) { + headers.append("Cookie", `sessionid=${sessionCookie}`); + headers.append("Set-Cookie", `sessionid=${sessionCookie}`); + } } return fetch(url, { @@ -89,7 +103,7 @@ export function createApolloClient( uri, // TODO this might be useless credentials: "include", - // @ts-expect-error: TODO undici (node fetch) is a mess + // @ts-expect-error: node-fetch is a subset of fetch API fetch: enchancedFetch, });