diff --git a/backend/src/app.ts b/backend/src/app.ts index 5229ffbc..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,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'; 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') { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } next(); @@ -78,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/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..33454a58 --- /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'; 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'); + 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'" + ); + }); +});