Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ 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');
res.setHeader('Referrer-Policy', 'no-referrer');
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();
Expand Down Expand Up @@ -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',
}));
Expand Down
2 changes: 2 additions & 0 deletions backend/src/controllers/sse.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] },
Expand Down
10 changes: 9 additions & 1 deletion backend/src/routes/v1/events.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(',')
Expand Down
191 changes: 175 additions & 16 deletions backend/tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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 ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -182,20 +241,6 @@
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')
Expand Down Expand Up @@ -234,4 +279,118 @@
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) => {

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
authorization
, but is not rate-limited.
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');
});
});
});
44 changes: 35 additions & 9 deletions backend/tests/integration/events-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = {}) {
return {
Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -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();
});
Expand All @@ -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);

Expand Down
Loading
Loading