diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index 015013e..12cebe9 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -14,6 +14,10 @@ SESSION_MAX_AGE="7d" PORT=3001 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 # local HTTP development - every real deployment must leave this unset (or diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts index 3f67c90..6a9b67c 100644 --- a/ApiCore-Backend/src/app.config.ts +++ b/ApiCore-Backend/src/app.config.ts @@ -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()); diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 1c76317..38fa3e7 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -106,9 +106,11 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea ], controllers: [], 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: PermissionsGuard }, - { provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, IdempotencyCleanupService, ], diff --git a/ApiCore-Backend/src/common/middleware/no-store.middleware.ts b/ApiCore-Backend/src/common/middleware/no-store.middleware.ts new file mode 100644 index 0000000..9e7b474 --- /dev/null +++ b/ApiCore-Backend/src/common/middleware/no-store.middleware.ts @@ -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(); +} diff --git a/ApiCore-Backend/src/common/middleware/request-logger.middleware.ts b/ApiCore-Backend/src/common/middleware/request-logger.middleware.ts index 75d90f3..8102260 100644 --- a/ApiCore-Backend/src/common/middleware/request-logger.middleware.ts +++ b/ApiCore-Backend/src/common/middleware/request-logger.middleware.ts @@ -18,7 +18,9 @@ export function requestLoggerMiddleware( const start = Date.now(); res.on('finish', () => { 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) { logger.error(line); } else if (res.statusCode >= 400) { diff --git a/ApiCore-Backend/src/main.ts b/ApiCore-Backend/src/main.ts index 4db0e53..6fdc3a5 100644 --- a/ApiCore-Backend/src/main.ts +++ b/ApiCore-Backend/src/main.ts @@ -15,6 +15,21 @@ const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const; // generate one. 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 { const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]); if (missing.length > 0) { @@ -32,6 +47,7 @@ function assertRequiredEnvVars(): void { async function bootstrap() { assertRequiredEnvVars(); + assertProductionSettings(); const app = await NestFactory.create(AppModule); app.enableShutdownHooks(); diff --git a/ApiCore-Backend/test/security-headers.e2e-spec.ts b/ApiCore-Backend/test/security-headers.e2e-spec.ts new file mode 100644 index 0000000..4e8acf9 --- /dev/null +++ b/ApiCore-Backend/test/security-headers.e2e-spec.ts @@ -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; + + 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); + }); +});