From ca9de12ac1164712cfb6e836cba85647f2076373 Mon Sep 17 00:00:00 2001 From: Drock0 Date: Tue, 30 Jun 2026 09:24:10 +0100 Subject: [PATCH 1/2] fix(security+tests): CSP/COOP/CORP headers, requireAdmin tests, secure GET /v1/events Closes #821 - Add Content-Security-Policy, Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy headers to the hand-rolled security middleware. Replace static isProduction var with dynamic process.env.NODE_ENV check for HSTS so the production gate is testable. Swagger UI (/api-docs) verified to load under the new CSP. Closes #822 - Add security-headers.test.ts asserting X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP, COOP, CORP and absence of x-powered-by on every response. Assert HSTS only present when NODE_ENV=production. Assert Swagger UI page loads with CSP header. Closes #823 - Add requireAdmin unit tests to auth.test.ts: - non-admin key JWT -> 403 Forbidden - admin key JWT -> 200 (next() called) - ADMIN_PUBLIC_KEY unset -> 403 (fail closed) Closes #825 - Secure GET /v1/events by adding requireAuth middleware and enforcing that the queried address matches the authenticated user publicKey (mirrors SSE subscription scoping). Returns 403 if caller queries another wallet. Add comment in sse.controller.ts documenting the aligned semantics. Update events-list integration tests with Authorization headers and add new auth/scoping test cases. --- backend/src/app.ts | 5 +- backend/src/controllers/sse.controller.ts | 2 + backend/src/routes/v1/events.routes.ts | 10 +- backend/tests/auth.test.ts | 191 ++++++++++++++++-- backend/tests/integration/events-list.test.ts | 44 +++- backend/tests/security-headers.test.ts | 46 +++++ 6 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 backend/tests/security-headers.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 5229ffbc..55ab045a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -39,7 +39,10 @@ app.use((req: Request, res: Response, next: NextFunction) => { res.setHeader('X-DNS-Prefetch-Control', 'off'); res.setHeader('X-Download-Options', 'noopen'); res.setHeader('X-Permitted-Cross-Domain-Policies', 'none'); - if (isProduction) { + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'"); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); + if (process.env.NODE_ENV === 'production') { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } next(); diff --git a/backend/src/controllers/sse.controller.ts b/backend/src/controllers/sse.controller.ts index 2134315a..9b702257 100644 --- a/backend/src/controllers/sse.controller.ts +++ b/backend/src/controllers/sse.controller.ts @@ -44,6 +44,8 @@ export const subscribe = async (req: Request, res: Response) => { const { publicKey } = (req as AuthenticatedRequest).user; const { streams, users, all } = subscribeSchema.parse(req.query); + // Consistent with GET /v1/events/ (which requires requireAuth and is scoped to user's address), + // SSE subscriptions are also restricted to streams owned by the authenticated user. // Scope: only streams where the authenticated user is sender or recipient const ownedStreams = await prisma.stream.findMany({ where: { OR: [{ sender: publicKey }, { recipient: publicKey }] }, diff --git a/backend/src/routes/v1/events.routes.ts b/backend/src/routes/v1/events.routes.ts index 60b51f27..28b97f87 100644 --- a/backend/src/routes/v1/events.routes.ts +++ b/backend/src/routes/v1/events.routes.ts @@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from 'express'; import { subscribe } from '../../controllers/sse.controller.js'; import { sseService } from '../../services/sse.service.js'; import { requireAuth } from '../../middleware/auth.js'; +import type { AuthenticatedRequest } from '../../types/auth.types.js'; import { prisma } from '../../lib/prisma.js'; import logger from '../../logger.js'; @@ -65,14 +66,21 @@ export const DEFAULT_EVENTS_PAGE_SIZE = 50; * 200: * description: Paginated event list */ -router.get('/', async (req: Request, res: Response, next: NextFunction) => { +router.get('/', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { + const { publicKey } = (req as AuthenticatedRequest).user; const address = typeof req.query.address === 'string' ? req.query.address.trim() : ''; if (!address) { res.status(400).json({ error: 'address query parameter is required' }); return; } + // Aligned with SSE security: history queries require authentication and are scoped to the caller. + if (address !== publicKey) { + res.status(403).json({ error: 'Forbidden', message: 'You can only view your own event history' }); + return; + } + const rawType = typeof req.query.type === 'string' ? req.query.type : ''; const requested = rawType .split(',') diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts index 309dfac7..9687d38b 100644 --- a/backend/tests/auth.test.ts +++ b/backend/tests/auth.test.ts @@ -1,9 +1,68 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import request from 'supertest'; import * as crypto from 'crypto'; import * as StellarSdk from '@stellar/stellar-sdk'; +import express from 'express'; import app from '../src/app.js'; -import { __authChallengeTestUtils } from '../src/middleware/auth.js'; +import { __authChallengeTestUtils, requireAdmin, signJwt } from '../src/middleware/auth.js'; + +// Mocking prisma for any downstream dependency +vi.mock('../src/lib/prisma.js', () => ({ + default: { + stream: { findMany: vi.fn(() => Promise.resolve([])) }, + streamEvent: { + findMany: vi.fn(() => Promise.resolve([])), + count: vi.fn(() => Promise.resolve(0)), + }, + $queryRaw: vi.fn(() => Promise.resolve([{ '?column?': 1n }])), + $disconnect: vi.fn(() => Promise.resolve()), + }, + prisma: { + stream: { findMany: vi.fn(() => Promise.resolve([])) }, + streamEvent: { + findMany: vi.fn(() => Promise.resolve([])), + count: vi.fn(() => Promise.resolve(0)), + }, + $queryRaw: vi.fn(() => Promise.resolve([{ '?column?': 1n }])), + $disconnect: vi.fn(() => Promise.resolve()), + }, +})); + +// Mock sseService so SSE subscribe endpoints resolve immediately (addClient ends the response) +vi.mock('../src/services/sse.service.js', () => ({ + sseService: { + isShuttingDown: vi.fn(() => false), + checkCapacity: vi.fn(() => ({ allowed: true })), + addClient: vi.fn((_id: string, res: any, _subs: string[], _ip: string) => { + res.end(); + }), + removeClient: vi.fn(), + getClientCount: vi.fn(() => 0), + getActiveIpCount: vi.fn(() => 0), + getPerIpPeakConnections: vi.fn(() => 0), + getMaxConnections: vi.fn(() => 10000), + broadcastToStream: vi.fn(), + broadcastToUser: vi.fn(), + initRedisSubscription: vi.fn(() => Promise.resolve()), + }, + SSEService: vi.fn(), +})); + +// Mock redis so SSE service doesn't try to connect +vi.mock('../src/lib/redis.js', () => ({ + cache: { + get: vi.fn(() => null), + set: vi.fn(), + del: vi.fn(), + getStats: vi.fn(() => ({ hits: 0, misses: 0, hitRate: 0, itemCount: 0 })), + cleanup: vi.fn(), + }, + isRedisAvailable: vi.fn(() => false), + getPublisher: vi.fn(() => null), + getSubscriber: vi.fn(() => null), + connectRedis: vi.fn(() => Promise.resolve()), + disconnectRedis: vi.fn(() => Promise.resolve()), +})); // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -182,20 +241,6 @@ describe('Authentication & Middleware Tests', () => { const keypair = makeKeypair(); const token = await getValidJwt(keypair); - // Mocking prisma for any downstream dependency - vi.mock('../src/lib/prisma.js', () => ({ - default: { - stream: { findMany: vi.fn().mockResolvedValue([]) }, - $queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]), - $disconnect: vi.fn(), - }, - prisma: { - stream: { findMany: vi.fn().mockResolvedValue([]) }, - $queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1n }]), - $disconnect: vi.fn(), - }, - })); - // Any route that uses requireAuth const res = await request(app) .get('/v1/events/subscribe') @@ -234,4 +279,118 @@ describe('Authentication & Middleware Tests', () => { expect(res.status).toBe(401); }); }); + + describe('Auth Middleware (requireAdmin)', () => { + let adminApp: any; + const originalAdminPublicKey = process.env.ADMIN_PUBLIC_KEY; + + beforeEach(() => { + adminApp = express(); + adminApp.use(express.json()); + adminApp.get('/test-admin', requireAdmin, (_req: any, res: any) => { + res.status(200).json({ success: true }); + }); + }); + + afterEach(() => { + process.env.ADMIN_PUBLIC_KEY = originalAdminPublicKey; + }); + + it('test_admin_middleware_rejects_non_admin_token', async () => { + const nonAdminKeypair = makeKeypair(); + const token = signJwt({ + sub: nonAdminKeypair.publicKey(), + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + // Set admin key to something else + process.env.ADMIN_PUBLIC_KEY = makeKeypair().publicKey(); + + const res = await request(adminApp) + .get('/test-admin') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('Forbidden'); + expect(res.body.message).toMatch(/Admin access required/i); + }); + + it('test_admin_middleware_accepts_admin_token', async () => { + const adminKeypair = makeKeypair(); + const token = signJwt({ + sub: adminKeypair.publicKey(), + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + process.env.ADMIN_PUBLIC_KEY = adminKeypair.publicKey(); + + const res = await request(adminApp) + .get('/test-admin') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('test_admin_middleware_fails_closed_when_key_unset', async () => { + const keypair = makeKeypair(); + const token = signJwt({ + sub: keypair.publicKey(), + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + // Unset the admin key + delete process.env.ADMIN_PUBLIC_KEY; + + const res = await request(adminApp) + .get('/test-admin') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('Forbidden'); + expect(res.body.message).toMatch(/Admin access required/i); + }); + }); + + describe('GET /v1/events (authenticated & scoped)', () => { + it('test_events_endpoint_rejects_unauthenticated', async () => { + const res = await request(app) + .get('/v1/events') + .query({ address: makeKeypair().publicKey() }); + expect(res.status).toBe(401); + }); + + it('test_events_endpoint_allows_authenticated_matching_address', async () => { + const keypair = makeKeypair(); + const token = signJwt({ + sub: keypair.publicKey(), + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + const res = await request(app) + .get('/v1/events') + .query({ address: keypair.publicKey() }) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.events).toEqual([]); + }); + + it('test_events_endpoint_rejects_authenticated_mismatched_address', async () => { + const keypair = makeKeypair(); + const otherKeypair = makeKeypair(); + const token = signJwt({ + sub: keypair.publicKey(), + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + const res = await request(app) + .get('/v1/events') + .query({ address: otherKeypair.publicKey() }) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('Forbidden'); + }); + }); }); diff --git a/backend/tests/integration/events-list.test.ts b/backend/tests/integration/events-list.test.ts index ff8beea5..caeb4fe5 100644 --- a/backend/tests/integration/events-list.test.ts +++ b/backend/tests/integration/events-list.test.ts @@ -57,8 +57,10 @@ vi.mock('../../src/lib/redis.js', () => ({ })); import app from '../../src/app.js'; +import { signJwt } from '../../src/middleware/auth.js'; const ADDR = 'GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA'; +const token = signJwt({ sub: ADDR, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600 }); function makeEvent(overrides: Partial> = {}) { return { @@ -80,19 +82,41 @@ describe('GET /v1/events', () => { vi.clearAllMocks(); }); + it('rejects requests missing authentication', async () => { + const res = await request(app).get(`/v1/events?address=${ADDR}`); + expect(res.status).toBe(401); + }); + it('rejects requests missing the `address` query parameter', async () => { - const res = await request(app).get('/v1/events'); + const res = await request(app) + .get('/v1/events') + .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(400); expect(res.body.error).toMatch(/address/i); expect(mocks.prisma.streamEvent.findMany).not.toHaveBeenCalled(); }); + it('rejects requests with mismatched authenticated user and address query', async () => { + const otherToken = signJwt({ + sub: 'GOTHER123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }); + const res = await request(app) + .get(`/v1/events?address=${ADDR}`) + .set('Authorization', `Bearer ${otherToken}`); + expect(res.status).toBe(403); + expect(res.body.error).toBe('Forbidden'); + }); + it('returns the paged event list for the wallet', async () => { const events = [makeEvent({ id: 'a', timestamp: 3 }), makeEvent({ id: 'b', timestamp: 2 })]; mocks.prisma.streamEvent.findMany.mockResolvedValueOnce(events); mocks.prisma.streamEvent.count.mockResolvedValueOnce(5); - const res = await request(app).get(`/v1/events?address=${ADDR}&limit=2`); + const res = await request(app) + .get(`/v1/events?address=${ADDR}&limit=2`) + .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.events).toHaveLength(2); @@ -114,9 +138,9 @@ describe('GET /v1/events', () => { mocks.prisma.streamEvent.findMany.mockResolvedValueOnce([]); mocks.prisma.streamEvent.count.mockResolvedValueOnce(0); - const res = await request(app).get( - `/v1/events?address=${ADDR}&type=PAUSED,RESUMED`, - ); + const res = await request(app) + .get(`/v1/events?address=${ADDR}&type=PAUSED,RESUMED`) + .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); const callArgs = mocks.prisma.streamEvent.findMany.mock.calls[0]![0] as { @@ -126,7 +150,9 @@ describe('GET /v1/events', () => { }); it('rejects a type filter when no values are valid', async () => { - const res = await request(app).get(`/v1/events?address=${ADDR}&type=BOGUS`); + const res = await request(app) + .get(`/v1/events?address=${ADDR}&type=BOGUS`) + .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(400); expect(mocks.prisma.streamEvent.findMany).not.toHaveBeenCalled(); }); @@ -135,9 +161,9 @@ describe('GET /v1/events', () => { mocks.prisma.streamEvent.findMany.mockResolvedValueOnce([]); mocks.prisma.streamEvent.count.mockResolvedValueOnce(100); - const res = await request(app).get( - `/v1/events?address=${ADDR}&limit=10&page=4`, - ); + const res = await request(app) + .get(`/v1/events?address=${ADDR}&limit=10&page=4`) + .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.offset).toBe(30); diff --git a/backend/tests/security-headers.test.ts b/backend/tests/security-headers.test.ts new file mode 100644 index 00000000..70669c3d --- /dev/null +++ b/backend/tests/security-headers.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import request from 'supertest'; +import app from '../src/app.js'; + +describe('Global Security Headers Middleware', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it('asserts normal response carries security headers', async () => { + const response = await request(app).get('/'); + + expect(response.headers['x-content-type-options']).toBe('nosniff'); + expect(response.headers['x-frame-options']).toBe('DENY'); + expect(response.headers['referrer-policy']).toBe('no-referrer'); + expect(response.headers['content-security-policy']).toBe( + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'" + ); + expect(response.headers['cross-origin-opener-policy']).toBe('same-origin'); + expect(response.headers['cross-origin-resource-policy']).toBe('same-origin'); + expect(response.headers['x-powered-by']).toBeUndefined(); + }); + + it('asserts Strict-Transport-Security is present only in production', async () => { + // Test in non-production + process.env.NODE_ENV = 'development'; + let response = await request(app).get('/'); + expect(response.headers['strict-transport-security']).toBeUndefined(); + + // Test in production + process.env.NODE_ENV = 'production'; + response = await request(app).get('/'); + expect(response.headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains'); + }); + + it('asserts Swagger UI page serves with security headers and CSP', async () => { + const response = await request(app).get('/api-docs/'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/html'); + expect(response.headers['content-security-policy']).toBe( + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'" + ); + }); +}); From 15e15f1ddb58cea33a8763923b00dcc89262a2db Mon Sep 17 00:00:00 2001 From: Drock0 Date: Tue, 30 Jun 2026 09:35:23 +0100 Subject: [PATCH 2/2] fix(csp): remove unsafe-inline from global CSP; scope to /api-docs only Global API responses now carry a strict CSP without unsafe-inline, removing the CodeQL high-severity XSS-via-CSP alert. The Swagger UI route (/api-docs) overrides the global CSP with the permissive version it needs to render inline scripts and styles correctly. Security-header tests updated to assert the strict policy on normal responses and the permissive policy on /api-docs. --- backend/src/app.ts | 9 +++++++-- backend/tests/security-headers.test.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 55ab045a..a2bfe853 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -32,6 +32,7 @@ app.use(requestIdMiddleware); app.disable('x-powered-by'); // Helmet-equivalent core headers without external dependency. +// Strict CSP applied globally; the /api-docs route overrides it below for Swagger UI. app.use((req: Request, res: Response, next: NextFunction) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); @@ -39,7 +40,7 @@ app.use((req: Request, res: Response, next: NextFunction) => { res.setHeader('X-DNS-Prefetch-Control', 'off'); res.setHeader('X-Download-Options', 'noopen'); res.setHeader('X-Permitted-Cross-Domain-Policies', 'none'); - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'"); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'"); res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); if (process.env.NODE_ENV === 'production') { @@ -81,7 +82,11 @@ app.use(express.json()); app.use(sandboxMiddleware); // Swagger UI setup -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { +// Override CSP for /api-docs only: Swagger UI requires inline scripts/styles. +app.use('/api-docs', (req: Request, res: Response, next: NextFunction) => { + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'"); + next(); +}, swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customCss: '.swagger-ui .topbar { display: none }', customSiteTitle: 'FlowFi API Documentation', })); diff --git a/backend/tests/security-headers.test.ts b/backend/tests/security-headers.test.ts index 70669c3d..33454a58 100644 --- a/backend/tests/security-headers.test.ts +++ b/backend/tests/security-headers.test.ts @@ -16,7 +16,7 @@ describe('Global Security Headers Middleware', () => { expect(response.headers['x-frame-options']).toBe('DENY'); expect(response.headers['referrer-policy']).toBe('no-referrer'); expect(response.headers['content-security-policy']).toBe( - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'" + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; object-src 'none'" ); expect(response.headers['cross-origin-opener-policy']).toBe('same-origin'); expect(response.headers['cross-origin-resource-policy']).toBe('same-origin');