From 0e48b4d831fb44d15a298eb23df6585ef7542b6b Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 5 May 2026 22:26:30 +0300 Subject: [PATCH 1/7] fix(security): patch 4 CodeQL alerts, npm audit deps, enforce audit in CI --- .github/workflows/ci.yml | 7 +- .gitignore | 1 + apps/api/package.json | 1 + apps/api/src/app.ts | 38 ++-- apps/api/src/middleware/csrf.ts | 65 +++---- apps/api/src/middleware/rateLimit.ts | 8 + apps/api/src/modules/user/route.ts | 20 +- apps/api/src/routes/apiRoutes.ts | 8 +- .../middleware/security.middleware.test.ts | 43 +++-- apps/web/next-env.d.ts | 2 +- apps/web/package.json | 7 +- apps/web/src/api/api.ts | 28 +++ .../_components/AnalyticsHistoryList.tsx | 9 +- apps/web/src/test/api/api.test.ts | 3 + apps/web/src/utils/components.ts | 4 +- package-lock.json | 177 +++++++++--------- package.json | 7 +- 17 files changed, 247 insertions(+), 181 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8dab9..73f7221 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,16 +68,13 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-node - - uses: ./.github/actions/turbo-cache - name: Audit dependencies - run: npm audit --audit-level=high - continue-on-error: true + run: npm audit --audit-level=moderate - name: Review dependency changes if: github.event_name == 'pull_request' uses: actions/dependency-review-action@v4 with: - fail-on-severity: high - continue-on-error: true + fail-on-severity: moderate test: name: Tests diff --git a/.gitignore b/.gitignore index 511c67a..8a4852c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ pnpm-debug.log* .cache/ # Other +.claude .turbo *.tsbuildinfo .next/ diff --git a/apps/api/package.json b/apps/api/package.json index 1f4d3f6..7e93abc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -45,6 +45,7 @@ "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csrf-csrf": "^4.0.3", "dotenv": "^16.6.1", "express": "^4.19.2", "express-rate-limit": "^8.3.2", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 95fa603..23a4cbc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,8 +3,8 @@ import type { Request, Response, NextFunction } from "express"; import cors from "cors"; import helmet from "helmet"; import cookieParser from "cookie-parser"; -import { errorHandler } from "./middleware/errorHandler.js"; -import { csrfProtection } from "./middleware/csrf.js"; +import { errorHandler, AppError } from "./middleware/errorHandler.js"; +import { doubleCsrfProtection } from "./middleware/csrf.js"; import { apiRouter } from "./routes/apiRoutes.js"; import { swaggerDocs } from "./docs/swagger.js"; import { ENV } from "./config/env.js"; @@ -29,11 +29,21 @@ const allowedOrigins = app.set("trust proxy", 1); -// Configure Helmet: disable CSP in development to allow Swagger UI app.use( helmet({ - contentSecurityPolicy: ENV.NODE_ENV === "production" ? true : false, - crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: + ENV.NODE_ENV === "production" + ? true + : { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:"], + connectSrc: ["'self'"], + }, + }, + crossOriginResourcePolicy: { policy: "same-origin" }, }), ); @@ -59,21 +69,11 @@ app.use(express.urlencoded({ extended: true, limit: "32kb" })); // Initialize Swagger (defines /api-docs and /api-docs.json) swaggerDocs(app); -// Initialize CSRF middleware -const csrfMiddleware = csrfProtection(allowedOrigins); - -// Apply CSRF protection but skip it for Swagger documentation in development app.use((req: Request, res: Response, next: NextFunction) => { - const isSwagger = - req.path.startsWith("/api-docs") || req.path.startsWith("/api/auth/login"); - const isDev = ENV.NODE_ENV !== "production"; - - // In dev, we allow Swagger to bypass CSRF for testing - if (isDev && isSwagger && req.get("referer")?.includes("/api-docs")) { - return next(); - } - - csrfMiddleware(req, res, next); + doubleCsrfProtection(req, res, (err) => { + if (err) return next(new AppError("CSRF validation failed", 403, err)); + next(); + }); }); app.use("/api", apiRouter); diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index bb52f8b..80725e6 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -1,42 +1,39 @@ -import type { NextFunction, Request, Response } from "express"; +import type { Request } from "express"; +import { doubleCsrf } from "csrf-csrf"; import { ENV } from "../config/env.js"; -import { AppError } from "./errorHandler.js"; -const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const CSRF_BYPASS_PATHS = new Set([ - "/donations/webhook", "/api/donations/webhook", + "/api/auth/login", + "/api/auth/google/exchange", + "/api/auth/token", + "/api/auth/logout", + "/api/auth/logout-all", + "/api/auth/verify-email", + "/api/auth/resend-verification", ]); -function getRequestOrigin(req: Request): string | null { - const origin = req.headers.origin; - if (typeof origin === "string" && origin.length > 0) return origin; - - const referer = req.headers.referer; - if (typeof referer !== "string" || referer.length === 0) return null; - - try { - return new URL(referer).origin; - } catch { - return null; - } -} - -export function csrfProtection(allowedOrigins: string[]) { - const origins = new Set(allowedOrigins); - - return (req: Request, _res: Response, next: NextFunction) => { - if (ENV.NODE_ENV !== "production") return next(); - if (CSRF_BYPASS_PATHS.has(req.path)) { - return next(); - } - if (SAFE_METHODS.has(req.method)) return next(); - - const requestOrigin = getRequestOrigin(req); - if (!requestOrigin || !origins.has(requestOrigin)) { - return next(new AppError("CSRF validation failed", 403)); +export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({ + getSecret: () => ENV.ACCESS_TOKEN_SECRET, + getSessionIdentifier: (req: Request) => { + const accessToken = req.cookies?.["fintrack_access_token"]; + if (typeof accessToken === "string" && accessToken.length > 0) { + return accessToken; } - next(); - }; -} + return req.ip ?? "anonymous"; + }, + cookieName: "x-csrf-token", + cookieOptions: { + httpOnly: true, + sameSite: ENV.NODE_ENV === "production" ? "none" : "lax", + secure: ENV.NODE_ENV === "production", + path: "/", + }, + getCsrfTokenFromRequest: (req: Request) => + req.headers["x-csrf-token"] ?? undefined, + skipCsrfProtection: (req: Request) => + ENV.NODE_ENV === "test" || + (ENV.NODE_ENV !== "production" && req.path.startsWith("/api-docs")) || + CSRF_BYPASS_PATHS.has(req.path), +}); diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index 5545e17..0bf53c9 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -57,3 +57,11 @@ export const resendVerificationLimiter = rateLimit({ legacyHeaders: false, message: { error: "Too many resend attempts. Try again in an hour." }, }); + +export const userMutationLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many requests. Try again later." }, +}); diff --git a/apps/api/src/modules/user/route.ts b/apps/api/src/modules/user/route.ts index f254f08..869cfe7 100644 --- a/apps/api/src/modules/user/route.ts +++ b/apps/api/src/modules/user/route.ts @@ -1,6 +1,9 @@ import express from "express"; import { authenticateToken } from "../auth/controller.js"; -import { registrationLimiter } from "../../middleware/rateLimit.js"; +import { + registrationLimiter, + userMutationLimiter, +} from "../../middleware/rateLimit.js"; import { getCurrentUser, createUser, @@ -15,12 +18,23 @@ export const userRouter = express.Router(); userRouter.get("/me", authenticateToken, getCurrentUser); userRouter.post("/", registrationLimiter, createUser); // userRouter.patch("/:id", authenticateToken, updateUser); -userRouter.patch("/me", authenticateToken, updateCurrentUser); +userRouter.patch( + "/me", + authenticateToken, + userMutationLimiter, + updateCurrentUser, +); // userRouter.delete("/:id", authenticateToken, deleteUser); -userRouter.delete("/me", authenticateToken, deleteCurrentUser); +userRouter.delete( + "/me", + authenticateToken, + userMutationLimiter, + deleteCurrentUser, +); // userRouter.delete("/:userId/auth-methods/:authMethodId", authenticateToken, deleteAuthMethod); userRouter.delete( "/me/auth-methods/:authMethodId", authenticateToken, + userMutationLimiter, deleteAuthMethodForCurrentUser, ); diff --git a/apps/api/src/routes/apiRoutes.ts b/apps/api/src/routes/apiRoutes.ts index 2b998b5..6e11ce7 100644 --- a/apps/api/src/routes/apiRoutes.ts +++ b/apps/api/src/routes/apiRoutes.ts @@ -1,5 +1,6 @@ import express from "express"; -// import type { Request, Response, NextFunction } from "express"; +import type { Request, Response } from "express"; +import { generateCsrfToken } from "../middleware/csrf.js"; import { userRouter } from "../modules/user/route.js"; import { authRouter } from "../modules/auth/route.js"; import { transactionRouter } from "../modules/transaction/route.js"; @@ -22,6 +23,11 @@ apiRouter.use("/donations", donationRouter); apiRouter.get("/health", (_req, res) => res.json({ ok: true })); +apiRouter.get("/csrf-token", (req: Request, res: Response) => { + const csrfToken = generateCsrfToken(req, res); + res.json({ csrfToken }); +}); + // apiRouter.all("*", (req: Request, res: Response, next: NextFunction) => { // res.status(404).json({ error: "Endpoint not found" }); // }); diff --git a/apps/api/test/unit/middleware/security.middleware.test.ts b/apps/api/test/unit/middleware/security.middleware.test.ts index 4e4a632..98df7ea 100644 --- a/apps/api/test/unit/middleware/security.middleware.test.ts +++ b/apps/api/test/unit/middleware/security.middleware.test.ts @@ -5,56 +5,55 @@ describe("Security middleware", () => { jest.resetModules(); }); - it("blocks unsafe request with invalid origin in production CSRF", async () => { + it("calls next with error when no CSRF token on mutation request in production", async () => { jest.unstable_mockModule("../../../src/config/env.js", () => ({ ENV: { NODE_ENV: "production", + ACCESS_TOKEN_SECRET: "test-secret-for-csrf", }, })); - const { csrfProtection } = await import("../../../src/middleware/csrf.js"); + const { doubleCsrfProtection } = + await import("../../../src/middleware/csrf.js"); const next = jest.fn(); - const middleware = csrfProtection(["https://app.fintrack.dev"]); - - middleware( + doubleCsrfProtection( { method: "POST", - path: "/transactions", - headers: { origin: "https://evil.site" }, + path: "/api/transactions", + headers: {}, + cookies: {}, } as never, - {} as never, + { cookie: jest.fn() } as never, next, ); expect(next).toHaveBeenCalledTimes(1); - const firstArg = next.mock.calls[0]?.[0] as { - message?: string; - statusCode?: number; - }; - expect(firstArg?.message).toBe("CSRF validation failed"); - expect(firstArg?.statusCode).toBe(403); + const err = next.mock.calls[0]?.[0] as { status?: number }; + expect(err).toBeTruthy(); + expect(err.status).toBe(403); }); - it("allows Stripe webhook path in production CSRF middleware", async () => { + it("skips CSRF for Stripe webhook path", async () => { jest.unstable_mockModule("../../../src/config/env.js", () => ({ ENV: { NODE_ENV: "production", + ACCESS_TOKEN_SECRET: "test-secret-for-csrf", }, })); - const { csrfProtection } = await import("../../../src/middleware/csrf.js"); + const { doubleCsrfProtection } = + await import("../../../src/middleware/csrf.js"); const next = jest.fn(); - const middleware = csrfProtection(["https://app.fintrack.dev"]); - - middleware( + doubleCsrfProtection( { method: "POST", - path: "/donations/webhook", - headers: { origin: "https://evil.site" }, + path: "/api/donations/webhook", + headers: {}, + cookies: {}, } as never, - {} as never, + { cookie: jest.fn() } as never, next, ); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index adcd220..ee415ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,14 +19,15 @@ "@fintrack/types": "*", "@tanstack/react-query": "^5.84.2", "@tanstack/react-query-devtools": "^5.84.2", - "axios": "^1.15.0", + "axios": "^1.16.0", "chart.js": "^4.5.0", "framer-motion": "^12.23.12", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", - "next": "^16.2.3", - "next-auth": "^4.24.13", + "next": "^16.2.4", + "next-auth": "^4.24.14", + "nodemailer": "^8.0.5", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", diff --git a/apps/web/src/api/api.ts b/apps/web/src/api/api.ts index 7c02a36..feea050 100644 --- a/apps/web/src/api/api.ts +++ b/apps/web/src/api/api.ts @@ -9,6 +9,27 @@ const api = axios.create({ }, }); +let csrfTokenCache: string | null = null; + +async function fetchCsrfToken(): Promise { + if (csrfTokenCache) return csrfTokenCache; + const { data } = await axios.get<{ csrfToken: string }>( + `${process.env.NEXT_PUBLIC_API_URL}/csrf-token`, + { withCredentials: true }, + ); + csrfTokenCache = data.csrfToken; + return csrfTokenCache; +} + +const MUTATION_METHODS = new Set(["post", "put", "patch", "delete"]); + +api.interceptors.request.use(async (config) => { + if (config.method && MUTATION_METHODS.has(config.method.toLowerCase())) { + config.headers["x-csrf-token"] = await fetchCsrfToken(); + } + return config; +}); + api.interceptors.response.use( (response) => response, async (error) => { @@ -19,6 +40,12 @@ api.interceptors.response.use( requestUrl.includes("/auth/login") || requestUrl.includes("/auth/google/exchange"); + if (error.response?.status === 403 && !originalRequest?._csrfRetry) { + csrfTokenCache = null; + originalRequest._csrfRetry = true; + return api(originalRequest); + } + if ( error.response?.status === 401 && !originalRequest?._retry && @@ -36,6 +63,7 @@ api.interceptors.response.use( }, ); + csrfTokenCache = null; useAuthStore.getState().setAuthenticated(true); return api(originalRequest); } catch (refreshError) { diff --git a/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx index d57a0bf..46b1491 100644 --- a/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx +++ b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx @@ -1,5 +1,5 @@ import { motion } from "framer-motion"; -import { sanitizeText } from "@/utils/components"; +import { decodeHtmlEntities } from "@/utils/components"; import { TypingText } from "./TypingText"; import type { AIResponseWithDiff } from "@/types/ai"; import type { UserResponse } from "@fintrack/types"; @@ -48,10 +48,13 @@ export function AnalyticsHistoryList({
{isLatestMessage ? ( - + ) : (

- {sanitizeText(item.result)} + {decodeHtmlEntities(item.result)}

)}
diff --git a/apps/web/src/test/api/api.test.ts b/apps/web/src/test/api/api.test.ts index cc51849..e8c79bd 100644 --- a/apps/web/src/test/api/api.test.ts +++ b/apps/web/src/test/api/api.test.ts @@ -18,6 +18,9 @@ const apiInstance = Object.assign(retryRequest, { baseURL: "https://api.fintrack.dev", }, interceptors: { + request: { + use: vi.fn(), + }, response: { use: vi.fn((_onFulfilled, onRejected) => { rejectedHandler = onRejected; diff --git a/apps/web/src/utils/components.ts b/apps/web/src/utils/components.ts index f29fa55..c578293 100644 --- a/apps/web/src/utils/components.ts +++ b/apps/web/src/utils/components.ts @@ -100,11 +100,9 @@ export function sanitizeAmountInput(raw: string): string { return s; } -export function sanitizeText(text: string): string { +export function decodeHtmlEntities(text: string): string { return text .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .trim(); diff --git a/package-lock.json b/package-lock.json index 726f0f0..4775a40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "globals": "^17.0.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "next": "^16.2.3", + "next": "^16.2.4", "prettier": "^3.6.2", "turbo": "^2.5.6", "typescript": "5.9.2", @@ -48,6 +48,7 @@ "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csrf-csrf": "^4.0.3", "dotenv": "^16.6.1", "express": "^4.19.2", "express-rate-limit": "^8.3.2", @@ -96,19 +97,10 @@ "undici-types": "~7.16.0" } }, - "apps/api/node_modules/nodemailer": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", - "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "apps/bot": { "name": "fintrack-bot", "version": "0.1.0", - "license": "ISC", + "license": "MIT", "dependencies": { "dotenv": "^16.6.1", "grammy": "^1.38.2" @@ -139,14 +131,15 @@ "@fintrack/types": "*", "@tanstack/react-query": "^5.84.2", "@tanstack/react-query-devtools": "^5.84.2", - "axios": "^1.15.0", + "axios": "^1.16.0", "chart.js": "^4.5.0", "framer-motion": "^12.23.12", "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", - "next": "^16.2.3", - "next-auth": "^4.24.13", + "next": "^16.2.4", + "next-auth": "^4.24.14", + "nodemailer": "^8.0.5", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", @@ -313,6 +306,17 @@ "node": ">= 10" } }, + "apps/web/node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "apps/web/node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", "dev": true, @@ -388,32 +392,36 @@ } } }, - "apps/web/node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", + "apps/web/node_modules/next-auth": { + "version": "4.24.14", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz", + "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==", + "license": "ISC", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" }, - "engines": { - "node": "^10 || ^12 || >=14" + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } } }, "apps/web/node_modules/typescript": { @@ -2456,6 +2464,7 @@ "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": { @@ -2831,6 +2840,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2853,6 +2863,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2875,6 +2886,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2891,6 +2903,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2907,6 +2920,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2923,6 +2937,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2939,6 +2954,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2955,6 +2971,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2971,6 +2988,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2987,6 +3005,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3003,6 +3022,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3019,6 +3039,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3035,6 +3056,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3057,6 +3079,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3079,6 +3102,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3101,6 +3125,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3123,6 +3148,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3145,6 +3171,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3167,6 +3194,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3189,6 +3217,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3211,6 +3240,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3230,6 +3260,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3249,6 +3280,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3266,6 +3298,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3732,6 +3765,7 @@ "version": "16.2.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "dev": true, "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { @@ -3741,6 +3775,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3757,6 +3792,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3773,6 +3809,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3789,6 +3826,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3805,6 +3843,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3821,6 +3860,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3837,6 +3877,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3853,6 +3894,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6117,17 +6159,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "dev": true, @@ -7090,6 +7121,15 @@ "node": ">= 8" } }, + "node_modules/csrf-csrf": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz", + "integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/css-select": { "version": "5.2.2", "dev": true, @@ -11377,6 +11417,7 @@ "version": "16.2.4", "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "dev": true, "license": "MIT", "dependencies": { "@next/env": "16.2.4", @@ -11426,36 +11467,6 @@ } } }, - "node_modules/next-auth": { - "version": "4.24.13", - "license": "ISC", - "dependencies": { - "@babel/runtime": "^7.20.13", - "@panva/hkdf": "^1.0.2", - "cookie": "^0.7.0", - "jose": "^4.15.5", - "oauth": "^0.9.15", - "openid-client": "^5.4.0", - "preact": "^10.6.3", - "preact-render-to-string": "^5.1.19", - "uuid": "^8.3.2" - }, - "peerDependencies": { - "@auth/core": "0.34.3", - "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", - "nodemailer": "^7.0.7", - "react": "^17.0.2 || ^18 || ^19", - "react-dom": "^17.0.2 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "@auth/core": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, "node_modules/no-case": { "version": "3.0.4", "dev": true, @@ -11565,12 +11576,10 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", - "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "license": "MIT-0", - "optional": true, - "peer": true, "engines": { "node": ">=6.0.0" } diff --git a/package.json b/package.json index a51a487..854aa90 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ ] }, "devDependencies": { - "next": "^16.2.3", "@eslint/js": "^9.39.2", "eslint": "^9.30.1", "eslint-config-prettier": "^10.1.8", @@ -91,6 +90,7 @@ "globals": "^17.0.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", + "next": "^16.2.4", "prettier": "^3.6.2", "turbo": "^2.5.6", "typescript": "5.9.2", @@ -99,11 +99,12 @@ "overrides": { "@types/express": "4.17.21", "@types/express-serve-static-core": "4.19.0", - "next": "^16.2.3", + "next": "^16.2.4", "@prisma/client": "^6.19.3", "prisma": "^6.19.3", "zod": "^4.3.6", - "postcss": "^8.5.10" + "postcss": "^8.5.10", + "nodemailer": "^8.0.5" }, "optionalDependencies": { "@rolldown/binding-linux-x64-gnu": "*", From e9dc2b1f7d54184847f7edc19b692784a118f29c Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 6 May 2026 00:18:43 +0300 Subject: [PATCH 2/7] fix(ci): stabilize web tests and harden csrf handling for release --- .github/workflows/release.yml | 6 ++-- apps/api/.env.docker.example | 1 + apps/api/.env.example | 1 + apps/api/src/app.ts | 15 +++++--- apps/api/src/config/env.ts | 2 ++ apps/api/src/middleware/csrf.ts | 19 +++++++++-- apps/api/test/jest.setup.cjs | 1 + .../middleware/security.middleware.test.ts | 4 +-- apps/web/package.json | 7 ++-- apps/web/src/api/api.ts | 6 ++++ .../_components/AnalyticsHistoryList.tsx | 10 ++---- apps/web/src/test/auth/OAuthBridge.test.tsx | 16 +++++---- apps/web/src/utils/components.ts | 8 ----- apps/web/vitest.config.ts | 1 + package-lock.json | 34 ++++++++++++++++--- 15 files changed, 89 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5434850..76d5ea1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,21 +102,21 @@ jobs: exit-code: "0" - name: Upload API Trivy results - if: always() + if: always() && hashFiles('trivy-api.sarif') != '' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-api.sarif category: api - name: Upload Web Trivy results - if: always() + if: always() && hashFiles('trivy-web.sarif') != '' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-web.sarif category: web - name: Upload Bot Trivy results - if: always() + if: always() && hashFiles('trivy-bot.sarif') != '' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-bot.sarif diff --git a/apps/api/.env.docker.example b/apps/api/.env.docker.example index 63208ba..4d7c0ad 100644 --- a/apps/api/.env.docker.example +++ b/apps/api/.env.docker.example @@ -15,6 +15,7 @@ DIRECT_URL="postgresql://fintrack:fintrack@postgres:5432/fintrack" # JWT/access token ACCESS_TOKEN_SECRET="your_jwt_access_token_secret_here" +CSRF_SECRET="your_csrf_secret_here" # Google OAuth verification (must match Google Cloud OAuth client) GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com" diff --git a/apps/api/.env.example b/apps/api/.env.example index 6cf6b26..92191c4 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -15,6 +15,7 @@ DIRECT_URL="postgresql://postgres:password@localhost:5432/fintrack?schema=public # JWT/access token ACCESS_TOKEN_SECRET="your_jwt_access_token_secret_here" +CSRF_SECRET="your_csrf_secret_here" # Google OAuth verification (must match Google Cloud OAuth client) GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 23a4cbc..69baa9c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,7 +4,7 @@ import cors from "cors"; import helmet from "helmet"; import cookieParser from "cookie-parser"; import { errorHandler, AppError } from "./middleware/errorHandler.js"; -import { doubleCsrfProtection } from "./middleware/csrf.js"; +import { csrfProtection } from "./middleware/csrf.js"; import { apiRouter } from "./routes/apiRoutes.js"; import { swaggerDocs } from "./docs/swagger.js"; import { ENV } from "./config/env.js"; @@ -69,9 +69,16 @@ app.use(express.urlencoded({ extended: true, limit: "32kb" })); // Initialize Swagger (defines /api-docs and /api-docs.json) swaggerDocs(app); -app.use((req: Request, res: Response, next: NextFunction) => { - doubleCsrfProtection(req, res, (err) => { - if (err) return next(new AppError("CSRF validation failed", 403, err)); +app.use("/api", (req: Request, res: Response, next: NextFunction) => { + csrfProtection(req, res, (err) => { + if (err) { + return next( + new AppError("CSRF validation failed", 403, { + code: "CSRF_INVALID", + cause: err, + }), + ); + } next(); }); }); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 1efb227..fa9d6db 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -6,6 +6,7 @@ dotenv.config(); const requiredEnvVars = [ "DATABASE_URL", "ACCESS_TOKEN_SECRET", + "CSRF_SECRET", "API_KEY_ENCRYPTION_SECRET", ]; @@ -59,6 +60,7 @@ export const ENV = { : "verifyIdToken", DATABASE_URL: process.env.DATABASE_URL as string, ACCESS_TOKEN_SECRET: process.env.ACCESS_TOKEN_SECRET as string, + CSRF_SECRET: process.env.CSRF_SECRET as string, GROQAPITOKENS, API_KEY_ENCRYPTION_SECRET: process.env.API_KEY_ENCRYPTION_SECRET as string, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? "", diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 80725e6..5a562f9 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -1,4 +1,4 @@ -import type { Request } from "express"; +import type { NextFunction, Request, Response } from "express"; import { doubleCsrf } from "csrf-csrf"; import { ENV } from "../config/env.js"; @@ -14,7 +14,7 @@ const CSRF_BYPASS_PATHS = new Set([ ]); export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({ - getSecret: () => ENV.ACCESS_TOKEN_SECRET, + getSecret: () => ENV.CSRF_SECRET, getSessionIdentifier: (req: Request) => { const accessToken = req.cookies?.["fintrack_access_token"]; if (typeof accessToken === "string" && accessToken.length > 0) { @@ -23,7 +23,7 @@ export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({ return req.ip ?? "anonymous"; }, - cookieName: "x-csrf-token", + cookieName: "csrf-token", cookieOptions: { httpOnly: true, sameSite: ENV.NODE_ENV === "production" ? "none" : "lax", @@ -37,3 +37,16 @@ export const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({ (ENV.NODE_ENV !== "production" && req.path.startsWith("/api-docs")) || CSRF_BYPASS_PATHS.has(req.path), }); + +export function csrfProtection( + req: Request, + res: Response, + next: NextFunction, +) { + doubleCsrfProtection(req, res, (err) => { + if (err) { + return next(err); + } + next(); + }); +} diff --git a/apps/api/test/jest.setup.cjs b/apps/api/test/jest.setup.cjs index 79421cb..a673ef0 100644 --- a/apps/api/test/jest.setup.cjs +++ b/apps/api/test/jest.setup.cjs @@ -2,6 +2,7 @@ process.env.NODE_ENV = "test"; process.env.DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/fintrack_test?schema=public"; process.env.ACCESS_TOKEN_SECRET = "test_access_secret"; +process.env.CSRF_SECRET = "test_csrf_secret"; process.env.API_KEY_ENCRYPTION_SECRET = "test_api_key_encryption_secret_123456"; process.env.CORS_ORIGINS = "http://localhost:5173,http://127.0.0.1:5173"; process.env.GOOGLE_CLIENT_ID = diff --git a/apps/api/test/unit/middleware/security.middleware.test.ts b/apps/api/test/unit/middleware/security.middleware.test.ts index 98df7ea..02c8392 100644 --- a/apps/api/test/unit/middleware/security.middleware.test.ts +++ b/apps/api/test/unit/middleware/security.middleware.test.ts @@ -9,7 +9,7 @@ describe("Security middleware", () => { jest.unstable_mockModule("../../../src/config/env.js", () => ({ ENV: { NODE_ENV: "production", - ACCESS_TOKEN_SECRET: "test-secret-for-csrf", + CSRF_SECRET: "test-secret-for-csrf", }, })); @@ -38,7 +38,7 @@ describe("Security middleware", () => { jest.unstable_mockModule("../../../src/config/env.js", () => ({ ENV: { NODE_ENV: "production", - ACCESS_TOKEN_SECRET: "test-secret-for-csrf", + CSRF_SECRET: "test-secret-for-csrf", }, })); diff --git a/apps/web/package.json b/apps/web/package.json index ee415ee..b19ab3b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,12 +25,11 @@ "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", - "next": "^16.2.4", + "next": "^16.2.3", "next-auth": "^4.24.14", - "nodemailer": "^8.0.5", - "react": "^19.1.0", + "react": "^19.2.5", "react-chartjs-2": "^5.3.0", - "react-dom": "^19.1.0", + "react-dom": "^19.2.5", "react-i18next": "^17.0.2", "react-leaflet": "^5.0.0", "react-select": "^5.10.2", diff --git a/apps/web/src/api/api.ts b/apps/web/src/api/api.ts index feea050..b9cf54d 100644 --- a/apps/web/src/api/api.ts +++ b/apps/web/src/api/api.ts @@ -41,6 +41,12 @@ api.interceptors.response.use( requestUrl.includes("/auth/google/exchange"); if (error.response?.status === 403 && !originalRequest?._csrfRetry) { + const isCsrfInvalid = + error.response?.data?.code === "CSRF_INVALID" || + error.response?.data?.details?.code === "CSRF_INVALID"; + if (!isCsrfInvalid) { + return Promise.reject(error); + } csrfTokenCache = null; originalRequest._csrfRetry = true; return api(originalRequest); diff --git a/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx index 46b1491..9ec56fb 100644 --- a/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx +++ b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx @@ -1,5 +1,4 @@ import { motion } from "framer-motion"; -import { decodeHtmlEntities } from "@/utils/components"; import { TypingText } from "./TypingText"; import type { AIResponseWithDiff } from "@/types/ai"; import type { UserResponse } from "@fintrack/types"; @@ -48,14 +47,9 @@ export function AnalyticsHistoryList({
{isLatestMessage ? ( - + ) : ( -

- {decodeHtmlEntities(item.result)} -

+

{item.result}

)}
{currentDate} diff --git a/apps/web/src/test/auth/OAuthBridge.test.tsx b/apps/web/src/test/auth/OAuthBridge.test.tsx index d571082..81843a6 100644 --- a/apps/web/src/test/auth/OAuthBridge.test.tsx +++ b/apps/web/src/test/auth/OAuthBridge.test.tsx @@ -1,9 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, waitFor } from "@testing-library/react"; +import { OAuthBridge } from "@/app/_components/auth/OAuthBridge"; -const exchangeGoogleSession = vi.fn(); -const invalidateQueries = vi.fn(); -const signOut = vi.fn(); +const { exchangeGoogleSession, invalidateQueries, signOut } = vi.hoisted( + () => ({ + exchangeGoogleSession: vi.fn(), + invalidateQueries: vi.fn(), + signOut: vi.fn(), + }), +); let sessionStatus: "authenticated" | "loading" | "unauthenticated" = "unauthenticated"; @@ -40,7 +45,8 @@ vi.mock("@/lib/oauthBridge", () => ({ clearProcessedGoogleIdToken: vi.fn(), })); -describe("OAuthBridge", () => { +// TODO: Re-enable after stabilizing React module resolution in Vitest workspace setup. +describe.skip("OAuthBridge", () => { beforeEach(() => { vi.clearAllMocks(); sessionStatus = "unauthenticated"; @@ -52,7 +58,6 @@ describe("OAuthBridge", () => { sessionData = { googleIdToken: "google-token-1" }; exchangeGoogleSession.mockResolvedValue(undefined); - const { OAuthBridge } = await import("@/app/_components/auth/OAuthBridge"); render(); await waitFor(() => { @@ -72,7 +77,6 @@ describe("OAuthBridge", () => { sessionData = { googleIdToken: "google-token-2" }; exchangeGoogleSession.mockRejectedValue(new Error("exchange failed")); - const { OAuthBridge } = await import("@/app/_components/auth/OAuthBridge"); render(); await waitFor(() => { diff --git a/apps/web/src/utils/components.ts b/apps/web/src/utils/components.ts index c578293..7636c8f 100644 --- a/apps/web/src/utils/components.ts +++ b/apps/web/src/utils/components.ts @@ -99,11 +99,3 @@ export function sanitizeAmountInput(raw: string): string { if (parts.length > 2) s = parts.shift() + "." + parts.join(""); return s; } - -export function decodeHtmlEntities(text: string): string { - return text - .replace(/&/g, "&") - .replace(/"/g, '"') - .replace(/'/g, "'") - .trim(); -} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index b28ae85..120044a 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ clearMocks: true, }, resolve: { + dedupe: ["react", "react-dom"], alias: { "@": path.resolve(__dirname, "./src"), }, diff --git a/package-lock.json b/package-lock.json index 4775a40..39b2d22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,12 +137,11 @@ "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "leaflet": "^1.9.4", - "next": "^16.2.4", + "next": "^16.2.3", "next-auth": "^4.24.14", - "nodemailer": "^8.0.5", - "react": "^19.1.0", + "react": "^19.2.5", "react-chartjs-2": "^5.3.0", - "react-dom": "^19.1.0", + "react-dom": "^19.2.5", "react-i18next": "^17.0.2", "react-leaflet": "^5.0.0", "react-select": "^5.10.2", @@ -424,6 +423,27 @@ } } }, + "apps/web/node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "apps/web/node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, "apps/web/node_modules/typescript": { "version": "5.8.3", "dev": true, @@ -12479,7 +12499,10 @@ }, "node_modules/react": { "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12494,7 +12517,10 @@ }, "node_modules/react-dom": { "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, From 8e874e9fde8a4199894c3a949c6679f2ae5c4238 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 6 May 2026 00:36:00 +0300 Subject: [PATCH 3/7] fix(codeql): apply explicit api csrf middleware and map csrf errors to 403 --- apps/api/src/app.ts | 18 +++--------------- apps/api/src/middleware/csrf.ts | 8 +++++++- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 69baa9c..6c15a4b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,9 +1,9 @@ import express from "express"; -import type { Request, Response, NextFunction } from "express"; +import type { Request, Response } from "express"; import cors from "cors"; import helmet from "helmet"; import cookieParser from "cookie-parser"; -import { errorHandler, AppError } from "./middleware/errorHandler.js"; +import { errorHandler } from "./middleware/errorHandler.js"; import { csrfProtection } from "./middleware/csrf.js"; import { apiRouter } from "./routes/apiRoutes.js"; import { swaggerDocs } from "./docs/swagger.js"; @@ -69,19 +69,7 @@ app.use(express.urlencoded({ extended: true, limit: "32kb" })); // Initialize Swagger (defines /api-docs and /api-docs.json) swaggerDocs(app); -app.use("/api", (req: Request, res: Response, next: NextFunction) => { - csrfProtection(req, res, (err) => { - if (err) { - return next( - new AppError("CSRF validation failed", 403, { - code: "CSRF_INVALID", - cause: err, - }), - ); - } - next(); - }); -}); +app.use("/api", csrfProtection); app.use("/api", apiRouter); diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 5a562f9..eb5390a 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -1,6 +1,7 @@ import type { NextFunction, Request, Response } from "express"; import { doubleCsrf } from "csrf-csrf"; import { ENV } from "../config/env.js"; +import { AppError } from "./errorHandler.js"; const CSRF_BYPASS_PATHS = new Set([ "/api/donations/webhook", @@ -45,7 +46,12 @@ export function csrfProtection( ) { doubleCsrfProtection(req, res, (err) => { if (err) { - return next(err); + return next( + new AppError("CSRF validation failed", 403, { + code: "CSRF_INVALID", + cause: err, + }), + ); } next(); }); From 1220a1cbe98269161e80783c10330ced2473dae7 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 6 May 2026 00:47:41 +0300 Subject: [PATCH 4/7] fix(tests): resolve oauthbridge invalid-hook-call in vitest by unifying react module resolution --- .github/workflows/release.yml | 97 +--- apps/api/src/app.ts | 6 +- apps/web/package.json | 1 + apps/web/src/test/auth/OAuthBridge.test.tsx | 3 +- apps/web/vitest.config.ts | 13 +- package-lock.json | 512 ++++++++++++++++++++ 6 files changed, 554 insertions(+), 78 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76d5ea1..ce66ec2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,20 @@ jobs: build-scan: name: Build & Scan Images (api/web/bot) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - image_name: api + dockerfile: apps/api/Dockerfile + build_args: "" + - image_name: web + dockerfile: apps/web/Dockerfile + build_args: | + NEXT_PUBLIC_API_URL=/api + - image_name: bot + dockerfile: apps/bot/Dockerfile + build_args: "" permissions: contents: read packages: write @@ -33,91 +47,32 @@ jobs: id: repo run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - - name: Build API image + - name: Build ${{ matrix.image_name }} image uses: docker/build-push-action@v6 with: context: . - file: apps/api/Dockerfile + file: ${{ matrix.dockerfile }} push: ${{ github.event_name == 'push' }} load: ${{ github.event_name == 'pull_request' }} cache-from: type=gha cache-to: type=gha,mode=max + build-args: ${{ matrix.build_args }} tags: | - ghcr.io/${{ steps.repo.outputs.name }}-api:latest - ghcr.io/${{ steps.repo.outputs.name }}-api:${{ github.sha }} + ghcr.io/${{ steps.repo.outputs.name }}-${{ matrix.image_name }}:latest + ghcr.io/${{ steps.repo.outputs.name }}-${{ matrix.image_name }}:${{ github.sha }} - - name: Scan API image + - name: Scan ${{ matrix.image_name }} image uses: aquasecurity/trivy-action@v0.24.0 with: - image-ref: ghcr.io/${{ steps.repo.outputs.name }}-api:${{ github.sha }} + image-ref: ghcr.io/${{ steps.repo.outputs.name }}-${{ matrix.image_name }}:${{ github.sha }} format: sarif - output: trivy-api.sarif + output: trivy-${{ matrix.image_name }}.sarif severity: CRITICAL,HIGH exit-code: "0" - - name: Build Web image - uses: docker/build-push-action@v6 - with: - context: . - file: apps/web/Dockerfile - push: ${{ github.event_name == 'push' }} - load: ${{ github.event_name == 'pull_request' }} - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - NEXT_PUBLIC_API_URL=/api - tags: | - ghcr.io/${{ steps.repo.outputs.name }}-web:latest - ghcr.io/${{ steps.repo.outputs.name }}-web:${{ github.sha }} - - - name: Scan Web image - uses: aquasecurity/trivy-action@v0.24.0 - with: - image-ref: ghcr.io/${{ steps.repo.outputs.name }}-web:${{ github.sha }} - format: sarif - output: trivy-web.sarif - severity: CRITICAL,HIGH - exit-code: "0" - - - name: Build Bot image - uses: docker/build-push-action@v6 - with: - context: . - file: apps/bot/Dockerfile - push: ${{ github.event_name == 'push' }} - load: ${{ github.event_name == 'pull_request' }} - cache-from: type=gha - cache-to: type=gha,mode=max - tags: | - ghcr.io/${{ steps.repo.outputs.name }}-bot:latest - ghcr.io/${{ steps.repo.outputs.name }}-bot:${{ github.sha }} - - - name: Scan Bot image - uses: aquasecurity/trivy-action@v0.24.0 - with: - image-ref: ghcr.io/${{ steps.repo.outputs.name }}-bot:${{ github.sha }} - format: sarif - output: trivy-bot.sarif - severity: CRITICAL,HIGH - exit-code: "0" - - - name: Upload API Trivy results - if: always() && hashFiles('trivy-api.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: trivy-api.sarif - category: api - - - name: Upload Web Trivy results - if: always() && hashFiles('trivy-web.sarif') != '' - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: trivy-web.sarif - category: web - - - name: Upload Bot Trivy results - if: always() && hashFiles('trivy-bot.sarif') != '' + - name: Upload Trivy results + if: always() && hashFiles(format('trivy-{0}.sarif', matrix.image_name)) != '' uses: github/codeql-action/upload-sarif@v4 with: - sarif_file: trivy-bot.sarif - category: bot + sarif_file: trivy-${{ matrix.image_name }}.sarif + category: ${{ matrix.image_name }} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6c15a4b..4fce31a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -47,6 +47,8 @@ app.use( }), ); +app.use(cookieParser()); +app.use("/api", csrfProtection); app.use( cors({ origin: (origin, callback) => { @@ -57,8 +59,6 @@ app.use( credentials: true, }), ); - -app.use(cookieParser()); app.use( "/api/donations/webhook", express.raw({ type: "application/json", limit: "256kb" }), @@ -69,8 +69,6 @@ app.use(express.urlencoded({ extended: true, limit: "32kb" })); // Initialize Swagger (defines /api-docs and /api-docs.json) swaggerDocs(app); -app.use("/api", csrfProtection); - app.use("/api", apiRouter); app.use((_req: Request, res: Response) => { diff --git a/apps/web/package.json b/apps/web/package.json index b19ab3b..b95035f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "@types/node": "^25.5.2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.1", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", "jsdom": "^29.0.2", diff --git a/apps/web/src/test/auth/OAuthBridge.test.tsx b/apps/web/src/test/auth/OAuthBridge.test.tsx index 81843a6..8166c4a 100644 --- a/apps/web/src/test/auth/OAuthBridge.test.tsx +++ b/apps/web/src/test/auth/OAuthBridge.test.tsx @@ -45,8 +45,7 @@ vi.mock("@/lib/oauthBridge", () => ({ clearProcessedGoogleIdToken: vi.fn(), })); -// TODO: Re-enable after stabilizing React module resolution in Vitest workspace setup. -describe.skip("OAuthBridge", () => { +describe("OAuthBridge", () => { beforeEach(() => { vi.clearAllMocks(); sessionStatus = "unauthenticated"; diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 120044a..59e63d2 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; +import react from "@vitejs/plugin-react"; export default defineConfig({ + plugins: [react()], test: { environment: "jsdom", setupFiles: ["./src/test/setup.ts"], @@ -10,9 +12,18 @@ export default defineConfig({ clearMocks: true, }, resolve: { - dedupe: ["react", "react-dom"], alias: { "@": path.resolve(__dirname, "./src"), + react: path.resolve(__dirname, "../../node_modules/react"), + "react-dom": path.resolve(__dirname, "../../node_modules/react-dom"), + "react/jsx-runtime": path.resolve( + __dirname, + "../../node_modules/react/jsx-runtime.js", + ), + "react/jsx-dev-runtime": path.resolve( + __dirname, + "../../node_modules/react/jsx-dev-runtime.js", + ), }, }, }); diff --git a/package-lock.json b/package-lock.json index 39b2d22..4d19fff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,6 +161,7 @@ "@types/node": "^25.5.2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.1", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.3.0", "jsdom": "^29.0.2", @@ -178,6 +179,31 @@ "lightningcss-linux-x64-musl": "*" } }, + "apps/web/node_modules/@emnapi/core": { + "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" + } + }, + "apps/web/node_modules/@emnapi/runtime": { + "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" + } + }, "apps/web/node_modules/@next/env": { "version": "16.2.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", @@ -305,6 +331,286 @@ "node": ">= 10" } }, + "apps/web/node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "apps/web/node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "apps/web/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "apps/web/node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, "apps/web/node_modules/axios": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", @@ -327,6 +633,25 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, + "apps/web/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "apps/web/node_modules/globals": { "version": "16.5.0", "dev": true, @@ -423,6 +748,20 @@ } } }, + "apps/web/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "apps/web/node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -444,6 +783,67 @@ "react": "^19.2.5" } }, + "apps/web/node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "apps/web/node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "apps/web/node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "apps/web/node_modules/typescript": { "version": "5.8.3", "dev": true, @@ -456,6 +856,103 @@ "node": ">=14.17" } }, + "apps/web/node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "apps/web/node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -8671,6 +9168,21 @@ "version": "1.0.0", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", From 026a625a7f0dc52c2658a3de14cc63b1447c806a Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 6 May 2026 00:59:59 +0300 Subject: [PATCH 5/7] fix(codeql): enforce explicit csrf middleware chain for /api and tidy release matrix job names --- .github/workflows/release.yml | 2 +- apps/api/src/app.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce66ec2..788d09e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: jobs: build-scan: - name: Build & Scan Images (api/web/bot) + name: Build & Scan (${{ matrix.image_name }}) runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4fce31a..f67fa75 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -47,8 +47,6 @@ app.use( }), ); -app.use(cookieParser()); -app.use("/api", csrfProtection); app.use( cors({ origin: (origin, callback) => { @@ -63,13 +61,18 @@ app.use( "/api/donations/webhook", express.raw({ type: "application/json", limit: "256kb" }), ); -app.use(express.json({ limit: "32kb" })); -app.use(express.urlencoded({ extended: true, limit: "32kb" })); // Initialize Swagger (defines /api-docs and /api-docs.json) swaggerDocs(app); -app.use("/api", apiRouter); +app.use( + "/api", + cookieParser(), + csrfProtection, + express.json({ limit: "32kb" }), + express.urlencoded({ extended: true, limit: "32kb" }), + apiRouter, +); app.use((_req: Request, res: Response) => { res.status(404).json({ error: "Endpoint not found" }); From a3b11c25a877f57f3b53e0a20f3b6493cab50f1d Mon Sep 17 00:00:00 2001 From: BOHDAN MATULA <99603423+BODMAT@users.noreply.github.com> Date: Wed, 6 May 2026 01:20:07 +0300 Subject: [PATCH 6/7] =?UTF-8?q?fix(csrf):=20apply=20Copilot=20autofix=20?= =?UTF-8?q?=E2=80=94=20deterministic=20CSRF=20bypass=20for=20docs=20and=20?= =?UTF-8?q?webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- apps/api/src/app.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f67fa75..fd3c9de 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -62,13 +62,27 @@ app.use( express.raw({ type: "application/json", limit: "256kb" }), ); +app.use((req, res, next) => { + const skipCsrf = + req.path === "/api-docs.json" || + req.path.startsWith("/api-docs") || + req.path.startsWith("/api/donations/webhook"); + + if (skipCsrf) { + return next(); + } + + return cookieParser()(req, res, (cookieErr?: unknown) => { + if (cookieErr) return next(cookieErr); + return csrfProtection(req, res, next); + }); +}); + // Initialize Swagger (defines /api-docs and /api-docs.json) swaggerDocs(app); app.use( "/api", - cookieParser(), - csrfProtection, express.json({ limit: "32kb" }), express.urlencoded({ extended: true, limit: "32kb" }), apiRouter, From 694134ac248513253f6d2899f79b400ff0c3ed5f Mon Sep 17 00:00:00 2001 From: Bohdan Date: Wed, 6 May 2026 01:34:27 +0300 Subject: [PATCH 7/7] ci(security): ignore known ip-address advisory until express-rate-limit upstream fix --- .github/workflows/ci.yml | 49 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73f7221..afe4127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,54 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-node - name: Audit dependencies - run: npm audit --audit-level=moderate + shell: bash + run: | + npm audit --json > audit.json || true + node - <<'NODE' + const fs = require("node:fs"); + const report = JSON.parse(fs.readFileSync("audit.json", "utf8")); + const ignoreGhsa = new Set(["GHSA-v2v4-37r5-5v8g"]); + + const vulns = report.vulnerabilities ?? {}; + const failing = []; + + for (const [name, vuln] of Object.entries(vulns)) { + const severity = vuln?.severity; + if (!["moderate", "high", "critical"].includes(severity)) continue; + + const via = Array.isArray(vuln?.via) ? vuln.via : []; + const viaPackages = via + .filter((v) => typeof v === "string") + .map((v) => String(v)); + const advisoryUrls = via + .filter((v) => typeof v === "object" && v?.url) + .map((v) => String(v.url)); + + const hasOnlyIgnoredAdvisories = + advisoryUrls.length > 0 && + advisoryUrls.every((url) => + Array.from(ignoreGhsa).some((id) => url.includes(id)), + ); + + const isExpressRateLimitViaIgnoredIpAddress = + name === "express-rate-limit" && viaPackages.includes("ip-address"); + + if (hasOnlyIgnoredAdvisories) continue; + if (isExpressRateLimitViaIgnoredIpAddress) continue; + failing.push({ name, severity, via: advisoryUrls }); + } + + if (failing.length > 0) { + console.error("Blocking vulnerabilities found:"); + for (const item of failing) { + console.error(`- ${item.name} (${item.severity})`); + for (const url of item.via) console.error(` ${url}`); + } + process.exit(1); + } + + console.log("Security audit passed (known GHSA-v2v4-37r5-5v8g ignored until upstream fix)."); + NODE - name: Review dependency changes if: github.event_name == 'pull_request' uses: actions/dependency-review-action@v4