From 16ea3e08e51901233349d4f23f18955017733e26 Mon Sep 17 00:00:00 2001 From: SamixYasuke Date: Sat, 27 Jun 2026 04:26:39 +0100 Subject: [PATCH] fix: security headers --- backend/src/index.js | 296 ++++++++++++++++++++++++------------------- 1 file changed, 164 insertions(+), 132 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index 84b5b00..b53db3d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,168 +1,194 @@ -require('dotenv').config(); -require('./config/env').validateEnv(); +require("dotenv").config(); +require("./config/env").validateEnv(); -const Sentry = require('@sentry/node'); +const Sentry = require("@sentry/node"); Sentry.init({ dsn: process.env.SENTRY_DSN, - environment: process.env.NODE_ENV || 'development', - tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.2 : 1.0, + environment: process.env.NODE_ENV || "development", + tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, enabled: !!process.env.SENTRY_DSN, integrations: [Sentry.expressIntegration()], }); -const path = require('path'); -const express = require('express'); -const cors = require('cors'); -const helmet = require('helmet'); -const cookieParser = require('cookie-parser'); -const logger = require('./config/logger'); -const { requestIdMiddleware } = require('./middleware/requestId'); -const { requestLogger } = require('./middleware/requestLogger'); -const { normalizeErrorResponse, errorHandler } = require('./middleware/errorHandler'); -const { startLedgerMonitor, getLedgerStreamHealth } = require('./services/ledgerMonitor'); -const { refreshActiveCampaignStatuses } = require('./services/campaignStatusService'); -const { sendAlert } = require('./services/alerting'); -const { assertNoLegacyPlaintextUserWalletSecrets } = require('./services/walletSecrets'); -const db = require('./config/database'); -const swaggerUi = require('swagger-ui-express'); -const swaggerJsdoc = require('swagger-jsdoc'); -const rateLimit = require('express-rate-limit'); +const path = require("path"); +const express = require("express"); +const cors = require("cors"); +const helmet = require("helmet"); +const cookieParser = require("cookie-parser"); +const logger = require("./config/logger"); +const { requestIdMiddleware } = require("./middleware/requestId"); +const { requestLogger } = require("./middleware/requestLogger"); +const { + normalizeErrorResponse, + errorHandler, +} = require("./middleware/errorHandler"); +const { + startLedgerMonitor, + getLedgerStreamHealth, +} = require("./services/ledgerMonitor"); +const { + refreshActiveCampaignStatuses, +} = require("./services/campaignStatusService"); +const { sendAlert } = require("./services/alerting"); +const { + assertNoLegacyPlaintextUserWalletSecrets, +} = require("./services/walletSecrets"); +const db = require("./config/database"); +const swaggerUi = require("swagger-ui-express"); +const swaggerJsdoc = require("swagger-jsdoc"); +const rateLimit = require("express-rate-limit"); const app = express(); -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", 'data:', 'https:'], - connectSrc: ["'self'"], - fontSrc: ["'self'"], - objectSrc: ["'none'"], - mediaSrc: ["'self'"], - frameSrc: ["'none'"], +const connectSrcDirectives = ["'self'"]; +if (process.env.API_URL) connectSrcDirectives.push(process.env.API_URL); + +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: connectSrcDirectives, + fontSrc: ["'self'"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, }, - }, -})); + hsts: { + maxAge: 31_536_000, // 1 year + includeSubDomains: true, + preload: true, + }, + frameguard: { action: "deny" }, + noSniff: true, + }), +); app.use( cors({ - origin: process.env.FRONTEND_URL || 'http://localhost:5173', + origin: process.env.FRONTEND_URL || "http://localhost:5173", credentials: true, - }) + }), ); app.post( - '/api/webhooks/kyc', - express.raw({ type: 'application/json' }), - require('./routes/kycWebhook') + "/api/webhooks/kyc", + express.raw({ type: "application/json" }), + require("./routes/kycWebhook"), ); -app.use(express.json({ limit: '50kb' })); +app.use(express.json({ limit: "50kb" })); app.use(cookieParser()); -app.use(Sentry.sentryRequestMiddleware ? Sentry.sentryRequestMiddleware() : (req, res, next) => next()); +app.use( + Sentry.sentryRequestMiddleware + ? Sentry.sentryRequestMiddleware() + : (req, res, next) => next(), +); app.use(requestIdMiddleware); app.use(requestLogger); app.use(normalizeErrorResponse); -const isTest = process.env.NODE_ENV === 'test'; +const isTest = process.env.NODE_ENV === "test"; const globalApiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: isTest ? 100000 : 100, standardHeaders: true, legacyHeaders: false, - message: { error: 'Too many requests, please try again later.' }, + message: { error: "Too many requests, please try again later." }, skip: (req) => { if (isTest) return true; - const isPost = req.method === 'POST'; - const p = req.path || ''; + const isPost = req.method === "POST"; + const p = req.path || ""; if (!isPost) return false; return ( - p === '/auth/register' || - p === '/users/register' || - p === '/auth/login' || - p === '/users/login' || - p === '/contributions' + p === "/auth/register" || + p === "/users/register" || + p === "/auth/login" || + p === "/users/login" || + p === "/contributions" ); }, }); -app.use('/api', globalApiLimiter); +app.use("/api", globalApiLimiter); const openApiSpec = swaggerJsdoc({ definition: { - openapi: '3.0.0', + openapi: "3.0.0", info: { - title: 'CrowdPay API', - version: '1.0.0', + title: "CrowdPay API", + version: "1.0.0", }, components: { securitySchemes: { bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', + type: "http", + scheme: "bearer", + bearerFormat: "JWT", }, }, }, }, - apis: ['./src/routes/*.js'], + apis: ["./src/routes/*.js"], }); -app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(openApiSpec)); +app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec)); const v1OpenApiSpec = swaggerJsdoc({ definition: { - openapi: '3.0.0', + openapi: "3.0.0", info: { - title: 'CrowdPay Public API', - version: '1.0.0', - description: 'Versioned public API for third-party integrations', + title: "CrowdPay Public API", + version: "1.0.0", + description: "Versioned public API for third-party integrations", }, - servers: [{ url: '/api/v1' }, { url: '/v1' }], + servers: [{ url: "/api/v1" }, { url: "/v1" }], components: { securitySchemes: { bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'cp_live_…', + type: "http", + scheme: "bearer", + bearerFormat: "cp_live_…", }, }, }, }, - apis: ['./src/routes/v1.js'], + apis: ["./src/routes/v1.js"], }); -const v1Router = require('./routes/v1'); -app.use('/api/v1', v1Router); -app.use('/v1', v1Router); -app.get('/api/v1/docs/openapi.json', (_req, res) => res.json(v1OpenApiSpec)); -app.get('/v1/docs/openapi.json', (_req, res) => res.json(v1OpenApiSpec)); -app.use('/api/v1/docs', swaggerUi.serve, swaggerUi.setup(v1OpenApiSpec)); -app.use('/v1/docs', swaggerUi.serve, swaggerUi.setup(v1OpenApiSpec)); +const v1Router = require("./routes/v1"); +app.use("/api/v1", v1Router); +app.use("/v1", v1Router); +app.get("/api/v1/docs/openapi.json", (_req, res) => res.json(v1OpenApiSpec)); +app.get("/v1/docs/openapi.json", (_req, res) => res.json(v1OpenApiSpec)); +app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(v1OpenApiSpec)); +app.use("/v1/docs", swaggerUi.serve, swaggerUi.setup(v1OpenApiSpec)); -app.use('/api/auth', require('./routes/auth')); +app.use("/api/auth", require("./routes/auth")); // Backwards/alternate compatibility for docs + clients expecting /api/users/register|login. -app.use('/api/users', require('./routes/auth')); -app.use('/api/users', require('./routes/users')); -app.use('/api/invites', require('./routes/invites')); +app.use("/api/users", require("./routes/auth")); +app.use("/api/users", require("./routes/users")); +app.use("/api/invites", require("./routes/invites")); app.use("/api/campaigns", require("./routes/campaignUpdates")); app.use("/api/campaigns", require("./routes/campaigns")); -app.use('/api/anchor', require('./routes/anchor')); -app.use('/api/contributions', require('./routes/contributions')); -app.use('/api/withdrawals', require('./routes/withdrawals')); -app.use('/api/stellar/transactions', require('./routes/stellarTransactions')); -app.use('/api/admin', require('./routes/admin')); -const apiKeysRouter = require('./routes/apiKeys'); -app.use('/api/api-keys', apiKeysRouter); -app.use('/api/auth/api-keys', apiKeysRouter); -app.use('/api/webhooks', require('./routes/webhooks')); -app.use('/api/milestones', require('./routes/milestones')); -app.use('/api', require('./routes/disputes')); -app.use('/api/notifications', require('./routes/notifications')); -app.use('/api/emails', require('./routes/emails')); +app.use("/api/anchor", require("./routes/anchor")); +app.use("/api/contributions", require("./routes/contributions")); +app.use("/api/withdrawals", require("./routes/withdrawals")); +app.use("/api/stellar/transactions", require("./routes/stellarTransactions")); +app.use("/api/admin", require("./routes/admin")); +const apiKeysRouter = require("./routes/apiKeys"); +app.use("/api/api-keys", apiKeysRouter); +app.use("/api/auth/api-keys", apiKeysRouter); +app.use("/api/webhooks", require("./routes/webhooks")); +app.use("/api/milestones", require("./routes/milestones")); +app.use("/api", require("./routes/disputes")); +app.use("/api/notifications", require("./routes/notifications")); +app.use("/api/emails", require("./routes/emails")); -app.get('/health', async (_, res) => { +app.get("/health", async (_, res) => { try { - await db.query('SELECT 1'); + await db.query("SELECT 1"); res.json({ - status: 'ok', + status: "ok", db: { total: db.totalCount, idle: db.idleCount, @@ -170,28 +196,30 @@ app.get('/health', async (_, res) => { }, }); } catch (err) { - res.status(503).json({ status: 'error', error: err.message }); + res.status(503).json({ status: "error", error: err.message }); } }); -app.get('/api/config', (_, res) => { - const { USDC } = require('./config/stellar'); +app.get("/api/config", (_, res) => { + const { USDC } = require("./config/stellar"); res.json({ - platform_fee_bps: parseInt(process.env.PLATFORM_FEE_BPS || '0', 10), - usdc_issuer: USDC.issuer || process.env.USDC_ISSUER || 'GBBD472Q6TDQNCA24G2UG4M326T7J62TK2TYWNDSTXT5VBN2O4OXCT3U', + platform_fee_bps: parseInt(process.env.PLATFORM_FEE_BPS || "0", 10), + usdc_issuer: + USDC.issuer || + process.env.USDC_ISSUER || + "GBBD472Q6TDQNCA24G2UG4M326T7J62TK2TYWNDSTXT5VBN2O4OXCT3U", }); }); - // Public platform stats — used on the hero / landing section. // Cached for 60 s; invalidated by ledgerMonitor after each indexed contribution. -const cache = require('./utils/cache'); -const STATS_CACHE_KEY = 'stats:public'; -app.get('/api/stats', async (_req, res) => { +const cache = require("./utils/cache"); +const STATS_CACHE_KEY = "stats:public"; +app.get("/api/stats", async (_req, res) => { const cached = cache.get(STATS_CACHE_KEY); if (cached) return res.json(cached); try { - const db = require('./config/database'); + const db = require("./config/database"); const [campaigns, raised, contributions] = await Promise.all([ db.query(`SELECT COUNT(*)::int AS total FROM campaigns @@ -211,66 +239,70 @@ app.get('/api/stats', async (_req, res) => { cache.set(STATS_CACHE_KEY, payload, 60_000); // 60 s TTL res.json(payload); } catch (err) { - logger.error('Failed to fetch public stats', { error: err.message }); - res.status(500).json({ error: 'Failed to fetch stats' }); + logger.error("Failed to fetch public stats", { error: err.message }); + res.status(500).json({ error: "Failed to fetch stats" }); } }); -app.get('/health/ledger', async (_req, res) => { +app.get("/health/ledger", async (_req, res) => { try { const body = await getLedgerStreamHealth(); res.json(body); } catch (err) { - res.status(500).json({ error: err.message || 'ledger health failed' }); + res.status(500).json({ error: err.message || "ledger health failed" }); } }); -if (process.env.SERVE_FRONTEND === 'true') { - const dist = path.join(__dirname, '../../frontend/dist'); +if (process.env.SERVE_FRONTEND === "true") { + const dist = path.join(__dirname, "../../frontend/dist"); app.use(express.static(dist)); - app.get('*', (req, res, next) => { - if (req.path.startsWith('/api') || req.path.startsWith('/health')) return next(); - res.sendFile(path.join(dist, 'index.html')); + app.get("*", (req, res, next) => { + if (req.path.startsWith("/api") || req.path.startsWith("/health")) + return next(); + res.sendFile(path.join(dist, "index.html")); }); } app.use(Sentry.expressErrorHandler()); app.use(errorHandler); -const { startWebhookRetryPoller } = require('./services/webhookDispatcher'); +const { startWebhookRetryPoller } = require("./services/webhookDispatcher"); const PORT = process.env.PORT || 3001; function startCampaignStatusCron() { - if (process.env.ENABLE_CAMPAIGN_STATUS_CRON === 'false') return; - const cron = require('node-cron'); - cron.schedule('0 * * * *', () => { + if (process.env.ENABLE_CAMPAIGN_STATUS_CRON === "false") return; + const cron = require("node-cron"); + cron.schedule("0 * * * *", () => { refreshActiveCampaignStatuses().catch((err) => { - logger.error('Campaign status cron failed', { error: err.message }); + logger.error("Campaign status cron failed", { error: err.message }); }); }); - logger.info('Campaign status cron scheduled (hourly)'); + logger.info("Campaign status cron scheduled (hourly)"); } function startReconciliationCron() { - if (process.env.ENABLE_RECONCILIATION_CRON === 'false') return; - const cron = require('node-cron'); - const { reconcileCampaignBalances } = require('./services/reconciliation'); - cron.schedule('*/15 * * * *', () => { + if (process.env.ENABLE_RECONCILIATION_CRON === "false") return; + const cron = require("node-cron"); + const { reconcileCampaignBalances } = require("./services/reconciliation"); + cron.schedule("*/15 * * * *", () => { reconcileCampaignBalances().catch((err) => { - logger.error('Reconciliation cron failed', { error: err.message }); + logger.error("Reconciliation cron failed", { error: err.message }); }); }); - logger.info('Reconciliation cron scheduled (every 15 minutes)'); + logger.info("Reconciliation cron scheduled (every 15 minutes)"); } async function bootstrap() { - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV === "production") { await assertNoLegacyPlaintextUserWalletSecrets(); } app.listen(PORT, () => { - logger.info('CrowdPay backend running', { port: PORT, stellar_network: process.env.STELLAR_NETWORK }); + logger.info("CrowdPay backend running", { + port: PORT, + stellar_network: process.env.STELLAR_NETWORK, + }); startLedgerMonitor(); startWebhookRetryPoller(); startCampaignStatusCron(); @@ -280,8 +312,8 @@ async function bootstrap() { if (require.main === module) { bootstrap().catch((err) => { - logger.error('Backend bootstrap failed', { error: err.message }); - sendAlert('Backend bootstrap failed', { error: err.message }); + logger.error("Backend bootstrap failed", { error: err.message }); + sendAlert("Backend bootstrap failed", { error: err.message }); process.exit(1); }); }