Skip to content
This repository was archived by the owner on Feb 10, 2025. It is now read-only.

Commit 67b0e86

Browse files
committed
fix: get CSRF token before making graphql requests
Graphql is only POST requests and those need a csrf token. If the cookie is not set redirect to a Django page that only sets the csrf cookie and redirects back. Add Referer to SSR headers, Django requires it to be set.
1 parent da8d40e commit 67b0e86

2 files changed

Lines changed: 85 additions & 24 deletions

File tree

apps/ui/middleware.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import type { NextRequest } from "next/server";
1010
import { getSignInUrl } from "@/modules/const";
1111
import { env } from "@/env.mjs";
1212

13+
const apiBaseUrl = env.TILAVARAUS_API_URL ?? "";
14+
15+
/// should we redirect to the login page
1316
function redirectProtectedRoute(req: NextRequest) {
1417
// TODO check that the cookie is valid not just present
1518
const { cookies, headers } = req;
1619
const hasSession = cookies.has("sessionid");
17-
const apiBaseUrl = env.TILAVARAUS_API_URL ?? "";
1820

1921
if (!hasSession) {
2022
// on the server we are behind a gateway so get the forwarded headers
@@ -29,6 +31,40 @@ function redirectProtectedRoute(req: NextRequest) {
2931
return undefined;
3032
}
3133

34+
/// are we missing a csrf token in cookies and should redirect to get one
35+
function redirectCsrfToken(req: NextRequest): URL | undefined {
36+
// need to ignore all assets outside of html requests (which don't have an extension)
37+
// so could we just check any request that doesn't have an extension?
38+
if (
39+
req.url.startsWith("/_next") ||
40+
req.url.match(
41+
/\.(js|css|png|jpg|jpeg|svg|gif|ico|json|woff|woff2|ttf|eot|otf)$/
42+
)
43+
) {
44+
return undefined;
45+
}
46+
47+
const { cookies } = req;
48+
const hasCsrfToken = cookies.has("csrftoken");
49+
if (hasCsrfToken) {
50+
return undefined;
51+
}
52+
53+
const csrfUrl = `${apiBaseUrl}/csrf/`;
54+
const redirectUrl = new URL(csrfUrl);
55+
const requestUrl = new URL(req.url);
56+
57+
// On server envs everything is in the same domain and 80/443 ports, so ignore the host part of the url.
58+
// More robust solution (supporting separate domains) would need to take into account us being behind
59+
// a gateway so the public url doesn't match the internal url.
60+
const origin = requestUrl.origin;
61+
const hostPart = origin.includes("localhost") ? origin : "";
62+
const next = `${hostPart}${requestUrl.pathname}`;
63+
redirectUrl.searchParams.set("redirect_to", next);
64+
65+
return redirectUrl;
66+
}
67+
3268
// Run the middleware only on paths that require authentication
3369
// NOTE don't define nested routes, only single word top level routes are supported
3470
// refactor the matcher or fix the underlining matcher issue in nextjs
@@ -43,20 +79,31 @@ const authenticatedRoutes = [
4379
"success",
4480
];
4581
// url matcher that is very specific to our case
46-
const doesUrlMatch = (url: string, route: string) => {
82+
function doesUrlMatch(url: string, route: string) {
4783
const ref: string[] = url.split("/");
4884
return ref.includes(route);
49-
};
85+
}
86+
87+
export async function middleware(req: NextRequest) {
88+
const redirectUrl = redirectCsrfToken(req);
89+
if (redirectUrl) {
90+
// block infinite redirect loop (there is no graceful way to handle this)
91+
if (req.url.includes("redirect_to")) {
92+
// eslint-disable-next-line no-console
93+
console.error("Infinite redirect loop detected");
94+
return NextResponse.next();
95+
}
96+
return NextResponse.redirect(redirectUrl);
97+
}
5098

51-
export const middleware = async (req: NextRequest) => {
5299
if (authenticatedRoutes.some((route) => doesUrlMatch(req.url, route))) {
53100
const redirect = redirectProtectedRoute(req);
54101
if (redirect) {
55102
return NextResponse.redirect(new URL(redirect, req.url));
56103
}
57104
}
58105
return NextResponse.next();
59-
};
106+
}
60107

61108
export const config = {
62109
/* i18n locale router and middleware have a bug in nextjs, matcher breaks the router

apps/ui/modules/apolloClient.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,16 @@ function getServerCookie(
3131
) {
3232
const cookie = headers?.cookie;
3333
if (cookie == null) {
34-
// eslint-disable-next-line no-console
35-
console.warn("cookie not found in headers", headers);
3634
return null;
3735
}
3836
const decoded = qs.decode(cookie, "; ");
3937
const token = decoded[name];
4038
if (token == null) {
41-
// eslint-disable-next-line no-console
42-
console.warn(`${name} not found in cookie`, decoded);
4339
return null;
4440
}
4541
if (Array.isArray(token)) {
4642
// eslint-disable-next-line no-console
47-
console.warn(`multiple ${name} in cookies`, decoded);
43+
console.warn(`multiple ${name} in cookies`, token);
4844
return token[0];
4945
}
5046
return token;
@@ -55,28 +51,46 @@ export function createApolloClient(
5551
ctx?: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
5652
) {
5753
const isServer = typeof window === "undefined";
54+
55+
const uri = buildGraphQLUrl(hostUrl, env.ENABLE_FETCH_HACK);
5856
const csrfToken = isServer
5957
? getServerCookie(ctx?.req?.headers, "csrftoken")
6058
: getCookie("csrftoken");
6159

62-
const sessionCookie = isServer
63-
? getServerCookie(ctx?.req?.headers, "sessionid")
64-
: getCookie("sessionid");
65-
66-
const uri = buildGraphQLUrl(hostUrl, env.ENABLE_FETCH_HACK);
67-
68-
// TODO on client side we might not need this (only on SSR)
69-
const enchancedFetch = (url: RequestInfo | URL, init?: RequestInit) => {
60+
const enchancedFetch = async (url: RequestInfo | URL, init?: RequestInit) => {
7061
const headers = new Headers({
7162
...(init?.headers != null ? init.headers : {}),
63+
// TODO missing csrf token is a non recoverable error
7264
...(csrfToken != null ? { "X-Csrftoken": csrfToken } : {}),
73-
// NOTE server requests don't include cookies by default
74-
// TODO include session cookie here also when we use SSR for user specific requests
75-
...(csrfToken != null ? { Cookie: `csrftoken=${csrfToken}` } : {}),
7665
});
7766

78-
if (sessionCookie != null) {
79-
headers.append("Cookie", `sessionid=${sessionCookie}`);
67+
// NOTE server requests don't include cookies by default
68+
// TODO do we want to copy request headers from client or no?
69+
if (isServer) {
70+
if (csrfToken == null) {
71+
throw new Error("csrftoken not found in cookies");
72+
}
73+
headers.append("Set-Cookie", `csrftoken=${csrfToken}`);
74+
headers.append("Cookie", `csrftoken=${csrfToken}`);
75+
// Django fails with 403 if there is no referer (only on Kubernetes)
76+
const requestUrl = ctx?.req?.url ?? "";
77+
const hostname =
78+
ctx?.req?.headers?.["x-forwarded-host"] ??
79+
ctx?.req?.headers?.host ??
80+
"";
81+
// NOTE not exactly correct
82+
// For our case this is sufficent because we are always behind a proxy,
83+
// but technically there is a case where we are not behind a gateway and not localhost
84+
// so the proto would be https and no x-forwarded-proto set
85+
// TODO we have .json blobs in the referer (translations), does it matter?
86+
const proto = ctx?.req?.headers?.["x-forwarded-proto"] ?? "http";
87+
headers.append("Referer", `${proto}://${hostname}${requestUrl}`);
88+
89+
const sessionCookie = getServerCookie(ctx?.req?.headers, "sessionid");
90+
if (sessionCookie != null) {
91+
headers.append("Cookie", `sessionid=${sessionCookie}`);
92+
headers.append("Set-Cookie", `sessionid=${sessionCookie}`);
93+
}
8094
}
8195

8296
return fetch(url, {
@@ -89,7 +103,7 @@ export function createApolloClient(
89103
uri,
90104
// TODO this might be useless
91105
credentials: "include",
92-
// @ts-expect-error: TODO undici (node fetch) is a mess
106+
// @ts-expect-error: node-fetch is a subset of fetch API
93107
fetch: enchancedFetch,
94108
});
95109

0 commit comments

Comments
 (0)