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
52 changes: 52 additions & 0 deletions .github/workflows/tenant-isolation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Cross-Tenant Isolation Audit

on:
push:
branches: [main, dev]
paths:
- 'backend/src/routes/**'
- 'backend/src/services/**'
- 'backend/src/repositories/**'
- 'backend/src/security/tenant-isolation/**'
- 'backend/prisma/schema.prisma'
pull_request:
branches: [main, dev]
paths:
- 'backend/src/routes/**'
- 'backend/src/services/**'
- 'backend/src/repositories/**'
- 'backend/src/security/tenant-isolation/**'
- 'backend/prisma/schema.prisma'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
tenant-isolation-audit:
name: Static scan + runtime tenant boundary tests
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: backend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Generate Prisma client
run: npm run db:generate

- name: Run static cross-tenant isolation scan
run: npm run tenant-isolation:scan

- name: Run runtime tenant-boundary tests
run: npm run tenant-isolation:test
3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"lint": "eslint src/",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest --passWithNoTests",
"tenant-isolation:scan": "tsx src/security/tenant-isolation/scan.ts",
"tenant-isolation:test": "vitest run src/security/tenant-isolation",
"db:generate": "prisma generate",
"db:migrate": "npx tsx migrations/runner.ts deploy",
"db:migrate:status": "npx tsx migrations/runner.ts status",
Expand Down Expand Up @@ -89,6 +91,7 @@
"@types/ws": "^8.5.12",
"eslint": "^9.0.0",
"prisma": "^5.22.0",
"ts-morph": "^24.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"typescript-eslint": "^8.0.0",
Expand Down
10 changes: 10 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Sentry from '@sentry/node';
import cors from 'cors';
import { tokenBucketRateLimit } from './middleware/rate-limit.js';
import { apiExpressRateLimit } from './middleware/express-api-rate-limit.js';
import { slidingWindowRateLimit } from './middleware/sliding-window-rate-limit.js';
import { compressionMiddleware, getCompressionMetrics } from './middleware/compression.js';
import { poolMetrics } from './config/database.js';
import { config } from './config.js';
Expand Down Expand Up @@ -79,6 +80,8 @@ import { bridgeRouter } from './routes/bridge.js';
import { tokenizationRouter } from './routes/tokenization.js';
import { routingRouter } from './routes/routing.js';
import { disputesRouter } from './routes/disputes.js';
import { withdrawalsRouter } from './routes/withdrawals.js';
import { swapSimulationRouter } from './routes/swap-simulation.js';
import { startWebhookWorker, stopWebhookWorker } from './services/webhooks.js';
import { analyticsService } from './services/analytics.js';
import { createAnalyticsRouter } from './routes/analytics.js';
Expand Down Expand Up @@ -211,6 +214,11 @@ app.use('/api', apiExpressRateLimit);

app.use('/api/', apiRateLimiter);

// Sliding-window rate limiter: per-endpoint granularity with graduated
// warning/throttle/block penalties, layered on top of the token-bucket
// limiter above (Issue #520).
app.use('/api/', slidingWindowRateLimit({ keyPrefix: 'sw:api' }));

// Apply sandbox-aware rate limiting for sandbox endpoints
const sandboxRateLimiter = tokenBucketRateLimit({
keyPrefix: 'rl:sandbox',
Expand Down Expand Up @@ -258,6 +266,8 @@ apiV1Router.use('/tokenization', tokenizationRouter);
apiV1Router.use('/routing', routingRouter);
apiV1Router.use('/escrow', escrowRouter);
apiV1Router.use('/disputes', disputesRouter);
apiV1Router.use('/withdrawals', withdrawalsRouter);
apiV1Router.use('/swap', swapSimulationRouter);
apiV1Router.get('/compression/metrics', (_req, res) => {
res.json(getCompressionMetrics());
});
Expand Down
15 changes: 11 additions & 4 deletions backend/src/lib/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@

import { PrismaClient } from '@prisma/client';
import { SLOW_QUERY_THRESHOLD_MS, VERY_SLOW_QUERY_THRESHOLD_MS } from '../config/database.js';
import { withTenantIsolationGuard } from '../security/tenant-isolation/guard.js';

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
const basePrismaClient =
globalForPrisma.prisma ??
new PrismaClient({
log: [
Expand All @@ -16,8 +17,14 @@ export const prisma =
],
});

// Attach slow-query detection to Prisma query events
(prisma.$on as Function)('query', (e: { query: string; duration: number }) => {
// Cross-tenant isolation enforcement (Issue #522) — throws instead of
// silently leaking data when a query targets a tenant other than the
// caller's active tenant context.
export const prisma = withTenantIsolationGuard(basePrismaClient);

// Attach slow-query detection to Prisma query events (must be registered on
// the base client — extended clients don't re-expose $on).
(basePrismaClient.$on as Function)('query', (e: { query: string; duration: number }) => {
if (e.duration >= VERY_SLOW_QUERY_THRESHOLD_MS) {
console.warn(`[db] 🔴 CRITICAL query ${e.duration}ms: ${e.query.slice(0, 120)}…`);
} else if (e.duration >= SLOW_QUERY_THRESHOLD_MS) {
Expand All @@ -26,7 +33,7 @@ export const prisma =
});

if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
globalForPrisma.prisma = basePrismaClient;
}

// Graceful disconnect helper — call in server shutdown handler
Expand Down
116 changes: 116 additions & 0 deletions backend/src/middleware/__tests__/sliding-window-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
slidingWindowRateLimit,
resolveIntelligentClientKey,
resetSlidingWindowMemoryStore,
ENDPOINT_SLIDING_WINDOWS,
DEFAULT_SLIDING_WINDOW,
} from '../sliding-window-rate-limit.js';

function mockReq(path: string, headers: Record<string, string> = {}, ip = '10.0.0.1') {
return { path, headers, ip } as any;
}

function mockRes() {
const headers: Record<string, string> = {};
return {
headers,
statusCode: 200,
setHeader(name: string, value: string) {
headers[name] = value;
},
status(code: number) {
this.statusCode = code;
return this;
},
json(body: unknown) {
this.body = body;
return this;
},
} as any;
}

describe('sliding window rate limiting', () => {
beforeEach(() => {
resetSlidingWindowMemoryStore();
});

it('allows requests under the limit and exposes window headers', async () => {
const mw = slidingWindowRateLimit({ keyPrefix: 'test-ok' });
const req = mockReq('/api/v1/widgets', {}, '10.0.0.2');
const res = mockRes();
const next = vi.fn();

await mw(req, res, next);

expect(next).toHaveBeenCalledOnce();
expect(res.headers['X-SlidingWindow-Limit']).toBe(String(DEFAULT_SLIDING_WINDOW.free.window.limit));
expect(res.statusCode).toBe(200);
});

it('throttles once the per-window limit is exceeded for a sensitive endpoint', async () => {
const mw = slidingWindowRateLimit({ keyPrefix: 'test-throttle' });
const limit = ENDPOINT_SLIDING_WINDOWS['/api/v1/withdrawals'].free.window.limit;
const ip = '10.0.0.3';

let lastRes;
for (let i = 0; i < limit; i++) {
const req = mockReq('/api/v1/withdrawals/123', {}, ip);
lastRes = mockRes();
await mw(req, lastRes, vi.fn());
expect(lastRes.statusCode).toBe(200);
}

const req = mockReq('/api/v1/withdrawals/123', {}, ip);
const res = mockRes();
const next = vi.fn();
await mw(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(429);
expect(res.body.error.code).toBe('RATE_LIMIT_THROTTLED');
});

it('escalates to a hard block after sustained throttled windows', async () => {
const mw = slidingWindowRateLimit({ keyPrefix: 'test-block' });
const cfg = ENDPOINT_SLIDING_WINDOWS['/api/v1/withdrawals'].free;
const ip = '10.0.0.4';

// Fill the window to the limit, then exceed it `blockAfterThrottledWindows` times.
for (let i = 0; i < cfg.window.limit; i++) {
await mw(mockReq('/api/v1/withdrawals', {}, ip), mockRes(), vi.fn());
}

let res;
for (let i = 0; i < cfg.penalties.blockAfterThrottledWindows; i++) {
res = mockRes();
await mw(mockReq('/api/v1/withdrawals', {}, ip), res, vi.fn());
}

expect(res.statusCode).toBe(403);
expect(res.body.error.code).toBe('RATE_LIMIT_BLOCKED');
});

it('applies different limits per endpoint prefix', () => {
expect(ENDPOINT_SLIDING_WINDOWS['/api/v1/withdrawals'].free.window.limit).toBeLessThan(
DEFAULT_SLIDING_WINDOW.free.window.limit
);
expect(ENDPOINT_SLIDING_WINDOWS['/api/v1/auth'].free.window.limit).toBeLessThan(
ENDPOINT_SLIDING_WINDOWS['/api/v1/invoice'].free.window.limit
);
});

it('fingerprints anonymous clients by IP + user-agent rather than IP alone', () => {
const reqA = mockReq('/api/v1/widgets', { 'user-agent': 'curl/8.0' }, '10.0.0.5');
const reqB = mockReq('/api/v1/widgets', { 'user-agent': 'Mozilla/5.0' }, '10.0.0.5');

expect(resolveIntelligentClientKey(reqA)).not.toBe(resolveIntelligentClientKey(reqB));
});

it('tracks authenticated clients by identity rather than shared IP', () => {
const reqA = mockReq('/api/v1/widgets', { 'x-user-tier': 'pro' }, '10.0.0.6');
const reqB = mockReq('/api/v1/widgets', { 'x-user-tier': 'pro' }, '10.0.0.6');
// Same IP, no api key -> falls back to IP+UA fingerprint, both identical here
expect(resolveIntelligentClientKey(reqA)).toBe(resolveIntelligentClientKey(reqB));
});
});
Loading