Skip to content

Commit

Permalink
fix: get CSRF token before making graphql requests
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
joonatank committed Apr 24, 2024
1 parent da8d40e commit 67b0e86
Show file tree
Hide file tree
Showing 2 changed files with 85 additions and 24 deletions.
57 changes: 52 additions & 5 deletions apps/ui/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -43,20 +79,31 @@ 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) {
return NextResponse.redirect(new URL(redirect, req.url));
}
}
return NextResponse.next();
};
}

export const config = {
/* i18n locale router and middleware have a bug in nextjs, matcher breaks the router
Expand Down
52 changes: 33 additions & 19 deletions apps/ui/modules/apolloClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,28 +51,46 @@ export function createApolloClient(
ctx?: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
) {
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, {
Expand All @@ -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,
});

Expand Down

0 comments on commit 67b0e86

Please sign in to comment.