diff --git a/backend/package-lock.json b/backend/package-lock.json
index d144acd2..2069787d 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -129,24 +129,26 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
- "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
- "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3736,6 +3738,29 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
+ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
+ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
diff --git a/backend/src/handlers/getUser.ts b/backend/src/handlers/getUser.ts
index 869d3448..bb29357d 100644
--- a/backend/src/handlers/getUser.ts
+++ b/backend/src/handlers/getUser.ts
@@ -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 });
};
diff --git a/backend/src/handlers/index.ts b/backend/src/handlers/index.ts
index 50720b16..f2e1dbad 100644
--- a/backend/src/handlers/index.ts
+++ b/backend/src/handlers/index.ts
@@ -59,6 +59,7 @@ export type AuthenticatedRequest = ExpressRequest & {
id: number;
email?: string;
name?: string;
+ csrfToken?: string;
};
};
diff --git a/backend/src/index.ts b/backend/src/index.ts
index dad4ec05..7540d67b 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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
+ "connect-src": ["'self'"],
+ "frame-ancestors": ["'none'"],
+ },
+ },
+ }),
+);
app.use(cookieParser());
diff --git a/backend/src/server/auth.ts b/backend/src/server/auth.ts
index 680d1e2a..fe970f49 100644
--- a/backend/src/server/auth.ts
+++ b/backend/src/server/auth.ts
@@ -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,
@@ -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) {
@@ -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;
}
@@ -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;
diff --git a/backend/src/types.ts b/backend/src/types.ts
index 1344fc82..cac17798 100644
--- a/backend/src/types.ts
+++ b/backend/src/types.ts
@@ -124,6 +124,7 @@ declare module "express-session" {
id: number;
name: string;
email?: string;
+ csrfToken?: string;
};
code_verifier?: string;
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index f9319f06..61adced7 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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");
@@ -20,8 +20,6 @@ const fetchClient = createFetchClient({
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.
*/
@@ -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(
+ USER_ME_QUERY_KEY,
+ );
+ if (user?.csrfToken) {
+ request.headers.set("X-CSRF-Token", user.csrfToken);
+ }
+ }
+ return request;
+ },
+});
+
+export const api = createClient(fetchClient);
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 2cbaec2d..07489d47 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -2113,6 +2113,8 @@ components:
nullable: true
name:
type: string
+ csrfToken:
+ type: string
orgs:
type: array
items:
@@ -2134,6 +2136,7 @@ components:
- email
- name
- orgs
+ - csrfToken
UnassignedInstallation:
type: object
properties: