Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/src/handlers/getUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ export const getUserHandler: HandlerMap["getUser"] = async (
res,
) => {
const user = await getUserService.getUser(req.user.id);
return json(200, res, user);
return json(200, res, { ...user, csrfToken: req.user.csrfToken });
};
1 change: 1 addition & 0 deletions backend/src/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type AuthenticatedRequest = ExpressRequest & {
id: number;
email?: string;
name?: string;
csrfToken?: string;
};
};

Expand Down
12 changes: 11 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ app.use(
);

// Sets several security-related HTTP headers
app.use(helmet());
app.use(
helmet({
contentSecurityPolicy: {
directives: {
"script-src": ["'self'"], // blocks inline <script></script>
"connect-src": ["'self'"],
"frame-ancestors": ["'none'"],
},
},
}),
);

app.use(cookieParser());

Expand Down
52 changes: 50 additions & 2 deletions backend/src/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { SpanStatusCode, trace } from "@opentelemetry/api";
import express from "express";
import express, { type Request } from "express";
import crypto from "node:crypto";
import type { operations } from "../generated/openapi.ts";
import type { AuthenticatedRequest } from "../handlers/index.ts";
import { env } from "../lib/env.ts";
import { logger } from "../logger.ts";
import {
InvalidIDPError,
Expand Down Expand Up @@ -33,10 +35,13 @@ router.get("/oauth_callback", async (req, res) => {
req.session.nonce,
);

const csrfToken = crypto.randomBytes(32).toString("hex");

req.session.user = {
id: user.id,
name: user.name,
email: user.email,
csrfToken,
};
return res.redirect("/dashboard");
} catch (err) {
Expand Down Expand Up @@ -84,8 +89,14 @@ export const ALLOWED_ANONYMOUS_OPERATIONS: (keyof operations)[] = [
"acmeChallenge",
];

const isAllowedAnonymousRoute = (req: Request) => {
return ALLOWED_ANONYMOUS_ROUTES.some(
(path) => req.path === path || req.path.startsWith(`${path}/`),
);
};

router.use((req, res, next) => {
if (ALLOWED_ANONYMOUS_ROUTES.some((path) => req.url.startsWith(path))) {
if (isAllowedAnonymousRoute(req)) {
next();
return;
}
Expand All @@ -102,4 +113,41 @@ router.use((req, res, next) => {
next();
});

const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

const validateOrigin = (req: Request) => {
for (const value of [req.get("origin"), req.get("referer")]) {
let source: string;
try {
source = new URL(value).origin;
} catch {
continue;
}

if (source === env.BASE_URL) {
return true;
}
}
return false;
};

router.use((req, res, next) => {
if (isAllowedAnonymousRoute(req) || !UNSAFE_METHODS.has(req.method)) {
next();
return;
}

if (!validateOrigin(req)) {
res.status(401).json({ code: 401, message: "Unauthorized" });
return;
}

if (req.session["user"].csrfToken !== req.headers["x-csrf-token"]) {
res.status(401).json({ code: 401, message: "Unauthorized" });
return;
}

next();
});

export default router;
1 change: 1 addition & 0 deletions backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ declare module "express-session" {
id: number;
name: string;
email?: string;
csrfToken?: string;
};

code_verifier?: string;
Expand Down
24 changes: 21 additions & 3 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import createFetchClient from "openapi-fetch";
import createClient from "openapi-react-query";
import { toast } from "sonner";
import { type paths } from "../generated/openapi";
import { type components, type paths } from "../generated/openapi";

const acceptJson = new Headers();
acceptJson.set("Accept", "application/json");
Expand All @@ -20,8 +20,6 @@ const fetchClient = createFetchClient<paths>({
headers: acceptJson,
});

export const api = createClient(fetchClient);

/**
* When the user visits one of these pages, they won't be redirected to the sign-in page if they're logged out.
*/
Expand Down Expand Up @@ -60,3 +58,23 @@ export const queryClient = new QueryClient({
queryCache: new QueryCache({ onError }),
mutationCache: new MutationCache({ onError }),
});

const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const USER_ME_QUERY_KEY = ["get", "/user/me", {}] as const;

fetchClient.use({
onRequest({ request }) {
if (MUTATING_METHODS.has(request.method)) {
const user =
queryClient.getQueryData<components["schemas"]["User"]>(
USER_ME_QUERY_KEY,
);
if (user?.csrfToken) {
request.headers.set("X-CSRF-Token", user.csrfToken);
}
}
return request;
},
});

export const api = createClient(fetchClient);
3 changes: 3 additions & 0 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2113,6 +2113,8 @@ components:
nullable: true
name:
type: string
csrfToken:
type: string
orgs:
type: array
items:
Expand All @@ -2134,6 +2136,7 @@ components:
- email
- name
- orgs
- csrfToken
UnassignedInstallation:
type: object
properties:
Expand Down
Loading