Skip to content
Open
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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ JWT_SECRET=your-super-secret-jwt-key-change-this
JWT_EXPIRES_IN=7d
BCRYPT_ROUNDS=10
LOG_LEVEL=debug
CORS_ORIGIN=http://localhost:3000
# Comma-separated list of allowed frontend origins. Use "*" to allow any origin (not recommended for production).
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
18 changes: 9 additions & 9 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@ import compression from 'compression';
import rateLimit from 'express-rate-limit';
import { connectDatabase } from './config/database';
import logger from './config/logger';
import { corsOptionsDelegate, helmetOptions } from './config/security';
import errorHandler from './middleware/errorHandler';
import routes from './routes';

const app = express();

// Security middleware
app.use(helmet());
// Trust the first proxy (load balancer / reverse proxy) so that
// secure headers and rate limiting use the correct client IP.
app.set('trust proxy', 1);

// CORS configuration
app.use(
cors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true,
}),
);
// Secure HTTP headers (Helmet)
app.use(helmet(helmetOptions));

// Cross-Origin Resource Sharing restricted to the configured frontend origins
app.use(cors(corsOptionsDelegate));

// Rate limiting
const limiter = rateLimit({
Expand Down
108 changes: 108 additions & 0 deletions src/config/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { CorsOptions, CorsOptionsDelegate } from 'cors';
import type { HelmetOptions } from 'helmet';
import type { Request } from 'express';
import logger from './logger';

/**
* Error raised when a request originates from a disallowed origin.
* Carries an HTTP status code so the global error handler can respond
* with `403 Forbidden` instead of a generic `500`.
*/
export class CorsNotAllowedError extends Error {
public readonly statusCode = 403;

constructor(origin: string) {
super(`Origin "${origin}" is not permitted by the CORS policy`);
this.name = 'CorsNotAllowedError';
}
}

/**
* Parse the comma-separated `CORS_ORIGIN` environment variable into a
* normalized list of allowed frontend origins.
*
* Example: `CORS_ORIGIN=http://localhost:3000,https://app.swiftchain.io`
*/
export const getAllowedOrigins = (): string[] =>
(process.env.CORS_ORIGIN ?? '')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);

/**
* Determine whether a given request origin is allowed.
*
* - Requests without an `Origin` header (server-to-server, curl, mobile
* clients, same-origin navigations) are always permitted.
* - A configured wildcard (`*`) permits any origin.
* - Otherwise the origin must be present in the allow-list.
*/
export const isOriginAllowed = (origin: string | undefined, allowedOrigins: string[]): boolean => {
if (!origin) {
return true;
}

if (allowedOrigins.includes('*')) {
return true;
}

return allowedOrigins.includes(origin);
};

/**
* CORS configuration delegate.
*
* The allow-list is resolved per request so that the policy reflects the
* current environment configuration without requiring a server restart in
* setups where the variable is reloaded.
*/
export const corsOptionsDelegate: CorsOptionsDelegate<Request> = (req, callback) => {
const allowedOrigins = getAllowedOrigins();
const requestOrigin = req.headers.origin;

const baseOptions: CorsOptions = {
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset'],
maxAge: 86400,
optionsSuccessStatus: 204,
};

if (isOriginAllowed(requestOrigin, allowedOrigins)) {
callback(null, { ...baseOptions, origin: true });
return;
}

logger.warn(`Blocked CORS request from disallowed origin: ${requestOrigin}`);
callback(new CorsNotAllowedError(requestOrigin ?? 'unknown'), { ...baseOptions, origin: false });
};

/**
* Helmet configuration applying production-grade HTTP security headers.
*
* Builds on Helmet's secure defaults and additionally enforces a strict
* Content-Security-Policy and a one-year HSTS policy.
*/
export const helmetOptions: Readonly<HelmetOptions> = {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"],
fontSrc: ["'self'", 'https:', 'data:'],
imgSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
crossOriginResourcePolicy: { policy: 'same-site' },
referrerPolicy: { policy: 'no-referrer' },
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
};
78 changes: 78 additions & 0 deletions tests/security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import request from 'supertest';
import type { Express } from 'express';

// Mock the database connection to prevent open handles during tests
jest.mock('../src/config/database', () => ({
connectDatabase: jest.fn(),
}));

// Mock the logger to keep test output clean
jest.mock('../src/config/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
}));

const ALLOWED_ORIGIN = 'http://localhost:3000';
const DISALLOWED_ORIGIN = 'http://evil.example.com';

const loadApp = async (): Promise<Express> => {
process.env.CORS_ORIGIN = ALLOWED_ORIGIN;
jest.resetModules();
const { default: app } = await import('../src/app');
return app;
};

describe('Security headers (Helmet)', () => {
it('sets hardened HTTP security headers on responses', async () => {
const app = await loadApp();
const res = await request(app).get('/health');

expect(res.status).toBe(200);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-dns-prefetch-control']).toBe('off');
expect(res.headers).toHaveProperty('content-security-policy');
expect(res.headers).toHaveProperty('strict-transport-security');
// Helmet removes the framework fingerprint header
expect(res.headers).not.toHaveProperty('x-powered-by');
});
});

describe('CORS policy', () => {
it('reflects the origin for allowed frontend origins', async () => {
const app = await loadApp();
const res = await request(app).get('/health').set('Origin', ALLOWED_ORIGIN);

expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN);
expect(res.headers['access-control-allow-credentials']).toBe('true');
});

it('rejects requests from disallowed origins', async () => {
const app = await loadApp();
const res = await request(app).get('/health').set('Origin', DISALLOWED_ORIGIN);

expect(res.status).toBe(403);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});

it('answers preflight (OPTIONS) requests for allowed origins', async () => {
const app = await loadApp();
const res = await request(app)
.options('/api/v1')
.set('Origin', ALLOWED_ORIGIN)
.set('Access-Control-Request-Method', 'POST');

expect(res.status).toBe(204);
expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN);
expect(res.headers['access-control-allow-methods']).toContain('POST');
});

it('allows non-browser requests without an Origin header', async () => {
const app = await loadApp();
const res = await request(app).get('/health');

expect(res.status).toBe(200);
});
});