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: