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
@@ -4,18 +4,58 @@ import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
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 {
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// the 2MB decoded size cap plus base64 overhead.
|
||||
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(requestLoggerMiddleware);
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
Reference in new issue
Block a user