fix(api): throttle before auth, configurable proxy trust, no-store and a strict CSP
- ThrottlerGuard runs first, so requests with wrong tokens or API keys count against the rate limit before any database lookup. - TRUST_PROXY (hop count or Express list) replaces the hard-coded 1. - Every API answer except the public lot pages is Cache-Control: no-store without ETag; helmet sends default-src 'none' / frame-ancestors 'none' (Swagger keeps the page policy when enabled) and same-site CORP. - Request log lines carry the path only - no query strings (the SSO callback's code and state no longer reach the logs). - In production the server refuses to start without an https FRONTEND_ORIGIN or with COOKIE_SECURE=false. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
2de93bc087
commit
9ae1d96a32
7 files changed
+134
-4
No files matched your search
@@ -14,6 +14,10 @@ SESSION_MAX_AGE="7d"
|
|||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
FRONTEND_ORIGIN="http://localhost:3000"
|
FRONTEND_ORIGIN="http://localhost:3000"
|
||||||
|
# Reverse-proxy hops in front of the backend whose X-Forwarded-For is
|
||||||
|
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
||||||
|
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
||||||
|
TRUST_PROXY=1
|
||||||
|
|
||||||
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
||||||
# local HTTP development - every real deployment must leave this unset (or
|
# local HTTP development - every real deployment must leave this unset (or
|
||||||
|
|||||||
@@ -4,18 +4,58 @@ import helmet from 'helmet';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
|
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
|
||||||
|
import { noStoreMiddleware } from './common/middleware/no-store.middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many reverse-proxy hops to trust for X-Forwarded-For. It decides the
|
||||||
|
* client IP behind every rate limit and API-key IP allowlist: too few and
|
||||||
|
* all users share the proxy's IP, too many and a client can forge its own.
|
||||||
|
* A hop count ("1", the single K8s ingress) or Express' list syntax
|
||||||
|
* ("loopback, 10.0.0.0/8").
|
||||||
|
*/
|
||||||
|
export function trustProxySetting(): number | string {
|
||||||
|
const raw = process.env.TRUST_PROXY?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return /^\d+$/.test(raw) ? Number(raw) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
export function configureApp(app: NestExpressApplication): void {
|
export function configureApp(app: NestExpressApplication): void {
|
||||||
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
|
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
|
||||||
// real client IP from X-Forwarded-For — required for per-client throttling
|
// real client IP from X-Forwarded-For — required for per-client throttling
|
||||||
// to work correctly rather than rate-limiting all traffic together.
|
// to work correctly rather than rate-limiting all traffic together.
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', trustProxySetting());
|
||||||
|
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||||
|
// responses that are meant to be no-store.
|
||||||
|
app.set('etag', false);
|
||||||
// Default 100kb JSON body limit is too small for the avatar upload, which
|
// Default 100kb JSON body limit is too small for the avatar upload, which
|
||||||
// sends the image as a base64 data: URL in the JSON body (not multipart -
|
// sends the image as a base64 data: URL in the JSON body (not multipart -
|
||||||
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
|
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
|
||||||
// the 2MB decoded size cap plus base64 overhead.
|
// the 2MB decoded size cap plus base64 overhead.
|
||||||
app.useBodyParser('json', { limit: '5mb' });
|
app.useBodyParser('json', { limit: '5mb' });
|
||||||
app.use(helmet());
|
app.use(
|
||||||
|
helmet({
|
||||||
|
// This server only answers JSON and files. A strict policy means even
|
||||||
|
// a response that is somehow rendered as a page can load nothing and
|
||||||
|
// cannot be framed. The Swagger UI (opt-in) needs scripts, so it keeps
|
||||||
|
// helmet's default page policy.
|
||||||
|
contentSecurityPolicy:
|
||||||
|
process.env.ENABLE_SWAGGER === 'true'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
useDefaults: false,
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'none'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
baseUri: ["'none'"],
|
||||||
|
formAction: ["'none'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(noStoreMiddleware);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(requestLoggerMiddleware);
|
app.use(requestLoggerMiddleware);
|
||||||
app.useGlobalFilters(new AllExceptionsFilter());
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
|||||||
@@ -106,9 +106,11 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
// Throttle first: a request with a wrong token or API key must count
|
||||||
|
// against the limit too, before any database lookup is spent on it.
|
||||||
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
|
||||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||||
IdempotencyCleanupService,
|
IdempotencyCleanupService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
// The public origin pages of honey lots are the only answers meant for
|
||||||
|
// everyone; they may be cached briefly.
|
||||||
|
const CACHEABLE_PREFIX = '/api/v1/public/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal data, tokens and exports must not end up in a browser's
|
||||||
|
* back/forward cache or a shared proxy. Every API answer is no-store unless
|
||||||
|
* it is one of the public, anonymous ones.
|
||||||
|
*/
|
||||||
|
export function noStoreMiddleware(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): void {
|
||||||
|
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ export function requestLoggerMiddleware(
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
const line = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
|
// Path only: query strings can carry secrets (the SSO callback's
|
||||||
|
// authorization code and state, search terms with personal data).
|
||||||
|
const line = `${req.method} ${req.baseUrl}${req.path} ${res.statusCode} ${duration}ms`;
|
||||||
if (res.statusCode >= 500) {
|
if (res.statusCode >= 500) {
|
||||||
logger.error(line);
|
logger.error(line);
|
||||||
} else if (res.statusCode >= 400) {
|
} else if (res.statusCode >= 400) {
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
|||||||
// generate one.
|
// generate one.
|
||||||
const MIN_JWT_SECRET_LENGTH = 32;
|
const MIN_JWT_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
|
function assertProductionSettings(): void {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Without it CORS and SSO redirects silently fall back to localhost.
|
||||||
|
if (!process.env.FRONTEND_ORIGIN?.startsWith('https://')) {
|
||||||
|
throw new Error(
|
||||||
|
'FRONTEND_ORIGIN must be set to an https:// origin in production',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (process.env.COOKIE_SECURE === 'false') {
|
||||||
|
throw new Error('COOKIE_SECURE=false is for local HTTP development only');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertRequiredEnvVars(): void {
|
function assertRequiredEnvVars(): void {
|
||||||
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
|
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
@@ -32,6 +47,7 @@ function assertRequiredEnvVars(): void {
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
assertRequiredEnvVars();
|
assertRequiredEnvVars();
|
||||||
|
assertProductionSettings();
|
||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
|
||||||
|
describe('Security headers (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks API answers no-store, unframeable and without an ETag', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/auth/sso/status')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store');
|
||||||
|
expect(response.headers['content-security-policy']).toContain(
|
||||||
|
"default-src 'none'",
|
||||||
|
);
|
||||||
|
expect(response.headers['content-security-policy']).toContain(
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
);
|
||||||
|
expect(response.headers['etag']).toBeUndefined();
|
||||||
|
expect(response.headers['x-powered-by']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limits requests with a wrong API key like any other', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const statuses: number[] = [];
|
||||||
|
for (let i = 0; i < 105 && !statuses.includes(429); i++) {
|
||||||
|
const response = await request(server)
|
||||||
|
.get('/api/v1/shop-sync')
|
||||||
|
.set('Authorization', `Bearer ack_aaaaaaaa_${'A'.repeat(43)}`);
|
||||||
|
statuses.push(response.status);
|
||||||
|
}
|
||||||
|
expect(statuses).toContain(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user