diff --git a/package-lock.json b/package-lock.json index 515883b..3c07216 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2050,6 +2050,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -3127,6 +3137,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -7875,6 +7904,7 @@ "@flashcard/shared": "*", "bcryptjs": "^3.0.3", "better-sqlite3": "^11.0.0", + "cookie-parser": "^1.4.7", "drizzle-orm": "^0.33.0", "express": "^4.19.0", "multer": "^1.4.5-lts.1", @@ -7885,6 +7915,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", "@types/multer": "^1.4.0", "@types/node": "^20.0.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 87d10f9..4fd88eb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -19,6 +19,7 @@ "@flashcard/shared": "*", "bcryptjs": "^3.0.3", "better-sqlite3": "^11.0.0", + "cookie-parser": "^1.4.7", "drizzle-orm": "^0.33.0", "express": "^4.19.0", "multer": "^1.4.5-lts.1", @@ -29,6 +30,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.0", "@types/multer": "^1.4.0", "@types/node": "^20.0.0", diff --git a/packages/backend/src/lib/cookies.ts b/packages/backend/src/lib/cookies.ts new file mode 100644 index 0000000..ae37f56 --- /dev/null +++ b/packages/backend/src/lib/cookies.ts @@ -0,0 +1,34 @@ +import type { CookieOptions } from 'express'; + +export const SID_COOKIE = 'flashcard_sid'; +export const CSRF_COOKIE = 'flashcard_csrf'; +export const CSRF_HEADER = 'x-csrf-token'; + +export function sidCookieOptions(expiresAtSec: number): CookieOptions { + return { + httpOnly: true, + sameSite: 'lax', + secure: process.env.COOKIE_SECURE === 'true', + path: '/', + expires: new Date(expiresAtSec * 1000), + }; +} + +export function csrfCookieOptions(expiresAtSec: number): CookieOptions { + return { + httpOnly: false, + sameSite: 'lax', + secure: process.env.COOKIE_SECURE === 'true', + path: '/', + expires: new Date(expiresAtSec * 1000), + }; +} + +export function clearCookieOptions(): CookieOptions { + return { + httpOnly: true, + sameSite: 'lax', + secure: process.env.COOKIE_SECURE === 'true', + path: '/', + }; +} diff --git a/packages/backend/src/middleware/csrf.ts b/packages/backend/src/middleware/csrf.ts new file mode 100644 index 0000000..400ce4f --- /dev/null +++ b/packages/backend/src/middleware/csrf.ts @@ -0,0 +1,25 @@ +import { randomBytes } from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; +import { CSRF_COOKIE, CSRF_HEADER, csrfCookieOptions } from '../lib/cookies.js'; +import { ApiError } from '../lib/errors.js'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +export function ensureCsrfToken(req: Request, res: Response, next: NextFunction): void { + if (!req.cookies?.[CSRF_COOKIE]) { + const token = randomBytes(24).toString('base64url'); + const expires = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60; + res.cookie(CSRF_COOKIE, token, csrfCookieOptions(expires)); + } + next(); +} + +export function verifyCsrf(req: Request, _res: Response, next: NextFunction): void { + if (SAFE_METHODS.has(req.method)) return next(); + const cookieToken = req.cookies?.[CSRF_COOKIE]; + const headerToken = req.headers[CSRF_HEADER]; + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + return next(new ApiError(403, 'CSRF_MISMATCH', 'CSRF token mismatch')); + } + next(); +}