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
41 changes: 41 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Security headers

The API configures baseline browser security headers during bootstrap in
`src/config/security.config.ts`.

## Helmet policy

`helmet()` is configured with:

- Content Security Policy limited to `self`, with inline scripts and styles kept
only so the Swagger UI at `/api/docs` can render correctly.
- HSTS with `max-age=31536000`, `includeSubDomains`, and `preload`.
- `X-Content-Type-Options: nosniff`.
- `X-XSS-Protection: 0` through Helmet's `xssFilter` middleware.
- `Referrer-Policy: strict-origin-when-cross-origin`.
- `X-Frame-Options: DENY`.
- `X-Permitted-Cross-Domain-Policies: none`.
- `Permissions-Policy: geolocation=(), camera=(), microphone=()`.

If Swagger UI is moved behind a CDN or external asset host, add only the exact
host needed to the relevant CSP directive.

## CORS

Set `CORS_ORIGIN` to control browser origins:

```bash
CORS_ORIGIN=https://app.stellartip.dev
```

Multiple origins are comma-separated:

```bash
CORS_ORIGIN=https://app.stellartip.dev,https://admin.stellartip.dev
```

`CORS_ORIGIN=*` is allowed for local or public read-only deployments, but the
API refuses credentialed CORS in that mode. Specific origins enable credentials.

Avoid using wildcard CORS for production sessions, dashboards, or any route that
depends on cookies or authorization headers.
88 changes: 88 additions & 0 deletions src/config/security.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Controller, Get, INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { App } from 'supertest/types';
import {
configureSecurity,
createCorsOptions,
PERMISSIONS_POLICY_HEADER,
} from './security.config';

@Controller()
class SecurityHeadersController {
@Get('/headers')
headers(): string {
return 'ok';
}
}

describe('security configuration', () => {
const originalCorsOrigin = process.env.CORS_ORIGIN;

afterEach(() => {
process.env.CORS_ORIGIN = originalCorsOrigin;
});

it('refuses credentials when wildcard CORS is configured', () => {
expect(createCorsOptions('*')).toMatchObject({
origin: '*',
credentials: false,
});
});

it('allows credentials for specific CORS origins', () => {
expect(
createCorsOptions(
'https://app.stellartip.dev, https://admin.stellartip.dev',
),
).toMatchObject({
origin: ['https://app.stellartip.dev', 'https://admin.stellartip.dev'],
credentials: true,
});
});

it('sets the required security headers on responses', async () => {
process.env.CORS_ORIGIN = 'https://app.stellartip.dev';

const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [SecurityHeadersController],
}).compile();
const app: INestApplication<App> = moduleRef.createNestApplication();
configureSecurity(app);
await app.init();

try {
const response = await request(app.getHttpServer())
.get('/headers')
.set('Origin', 'https://app.stellartip.dev')
.expect(200);

expect(response.headers['content-security-policy']).toContain(
"default-src 'self'",
);
expect(response.headers['content-security-policy']).toContain(
"script-src 'self' 'unsafe-inline'",
);
expect(response.headers['strict-transport-security']).toBe(
'max-age=31536000; includeSubDomains; preload',
);
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['x-frame-options']).toBe('DENY');
expect(response.headers['x-permitted-cross-domain-policies']).toBe(
'none',
);
expect(response.headers['referrer-policy']).toBe(
'strict-origin-when-cross-origin',
);
expect(response.headers['permissions-policy']).toBe(
PERMISSIONS_POLICY_HEADER,
);
expect(response.headers['access-control-allow-origin']).toBe(
'https://app.stellartip.dev',
);
expect(response.headers['access-control-allow-credentials']).toBe('true');
} finally {
await app.close();
}
});
});
92 changes: 92 additions & 0 deletions src/config/security.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { INestApplication } from '@nestjs/common';
import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
import type { RequestHandler } from 'express';
import helmet from 'helmet';
import type { HelmetOptions } from 'helmet';

const SELF = "'self'";
const INLINE = "'unsafe-inline'";
const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];

export const PERMISSIONS_POLICY_HEADER =
'geolocation=(), camera=(), microphone=()';

export function createHelmetOptions(): HelmetOptions {
return {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'default-src': [SELF],
'base-uri': [SELF],
'font-src': [SELF, 'data:'],
'form-action': [SELF],
'frame-ancestors': ["'none'"],
'img-src': [SELF, 'data:', 'validator.swagger.io'],
'object-src': ["'none'"],
'script-src': [SELF, INLINE],
'style-src': [SELF, INLINE],
'connect-src': [SELF],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
noSniff: true,
xssFilter: true,
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
frameguard: {
action: 'deny',
},
permittedCrossDomainPolicies: {
permittedPolicies: 'none',
},
};
}

export function createCorsOptions(
rawOrigin = process.env.CORS_ORIGIN,
): CorsOptions {
const origin = parseCorsOrigin(rawOrigin);

return {
origin,
methods: CORS_METHODS,
credentials: origin !== '*',
};
}

export function parseCorsOrigin(
rawOrigin: string | undefined,
): string | string[] {
if (!rawOrigin || rawOrigin.trim() === '') {
return '*';
}

const origins = rawOrigin
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);

if (origins.length === 0 || origins.includes('*')) {
return '*';
}

return origins.length === 1 ? origins[0] : origins;
}

export function permissionsPolicy(): RequestHandler {
return (_req, res, next) => {
res.setHeader('Permissions-Policy', PERMISSIONS_POLICY_HEADER);
next();
};
}

export function configureSecurity(app: INestApplication): void {
app.use(helmet(createHelmetOptions()));
app.use(permissionsPolicy());
app.enableCors(createCorsOptions());
}
13 changes: 3 additions & 10 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import * as compression from 'compression';
import { StructuredLogger } from './shared/logging/logging.config';
import { configureSecurity } from './config/security.config';

async function bootstrap(): Promise<void> {
const appLogger = new StructuredLogger();
Expand All @@ -15,19 +15,12 @@ async function bootstrap(): Promise<void> {
logger: appLogger,
});

// Security headers
app.use(helmet());
// Security headers and CORS
configureSecurity(app);

// Response compression
app.use(compression());

// CORS
app.enableCors({
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});

// Global validation pipes
app.useGlobalPipes(
new ValidationPipe({
Expand Down
Loading