mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🦺 feat: Configurable Baseline HTTP Security Headers (#14445)
Adds helmet's CSP-independent headers (HSTS, X-Frame-Options, X-Content-Type-Options, COOP, CORP, Referrer-Policy) on every response, with contentSecurityPolicy explicitly disabled. Every header that can break a deployment is configurable, so there is no allow-list to go stale the way #7377's hardcoded CSP directives did. HSTS includeSubDomains defaults off rather than matching helmet's on-by-default: it would otherwise pin every sibling subdomain to HTTPS for a year in every visitor's browser, and undoing that requires serving max-age=0 from each affected host.
This commit is contained in:
parent
3d2da403ce
commit
2ef12b1e1d
10 changed files with 431 additions and 0 deletions
32
.env.example
32
.env.example
|
|
@ -73,6 +73,38 @@ NO_INDEX=true
|
|||
# Defaulted to 1.
|
||||
TRUST_PROXY=1
|
||||
|
||||
#===============================#
|
||||
# Security Headers #
|
||||
#===============================#
|
||||
|
||||
# Baseline HTTP security headers (HSTS, X-Frame-Options, X-Content-Type-Options,
|
||||
# COOP, CORP, Referrer-Policy) are sent on every response. Content-Security-Policy
|
||||
# is never set here. Set to false to send no security headers at all.
|
||||
# SECURITY_HEADERS=true
|
||||
|
||||
# Strict-Transport-Security. Only meaningful over HTTPS; browsers ignore it on
|
||||
# plain HTTP. HSTS_INCLUDE_SUBDOMAINS applies the policy to every subdomain of
|
||||
# this host for the full max-age, so enable it only if all of them serve HTTPS.
|
||||
# HSTS_ENABLED=true
|
||||
# HSTS_MAX_AGE=31536000
|
||||
# HSTS_INCLUDE_SUBDOMAINS=false
|
||||
# HSTS_PRELOAD=false
|
||||
|
||||
# X-Frame-Options. Set to DENY to block all framing, or to `off` if you embed
|
||||
# LibreChat in an iframe on another origin.
|
||||
# X_FRAME_OPTIONS=SAMEORIGIN
|
||||
|
||||
# Referrer-Policy. Any standard token, or `off` to omit the header.
|
||||
# REFERRER_POLICY=no-referrer
|
||||
|
||||
# Cross-Origin-Opener-Policy. Use same-origin-allow-popups if a popup-based
|
||||
# sign-in flow needs to reach back to the window that opened it.
|
||||
# CROSS_ORIGIN_OPENER_POLICY=same-origin
|
||||
|
||||
# Cross-Origin-Resource-Policy. Use cross-origin if other sites need to load
|
||||
# resources served by LibreChat, such as uploaded images.
|
||||
# CROSS_ORIGIN_RESOURCE_POLICY=same-origin
|
||||
|
||||
# Trust X-Tenant-Id on unauthenticated routes. Disabled by default.
|
||||
# Enable only when a trusted reverse proxy strips any client-supplied value and sets its own.
|
||||
# TRUST_TENANT_HEADER=false
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const {
|
|||
apiNotFound,
|
||||
ErrorController,
|
||||
QUERY_DEVTOOLS_HEADER,
|
||||
createSecurityHeaders,
|
||||
performStartupChecks,
|
||||
handleJsonParseError,
|
||||
initializeFileStorage,
|
||||
|
|
@ -353,6 +354,12 @@ if (cluster.isMaster) {
|
|||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', trusted_proxy);
|
||||
|
||||
/* Registered ahead of every route so health checks carry the headers too. */
|
||||
const securityHeaders = createSecurityHeaders();
|
||||
if (securityHeaders) {
|
||||
app.use(securityHeaders);
|
||||
}
|
||||
|
||||
if (isEnabled(process.env.TRUST_TENANT_HEADER)) {
|
||||
logger.warn(
|
||||
'[Security] TRUST_TENANT_HEADER is active. Ensure your reverse proxy strips and sets ' +
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const {
|
|||
createMetrics,
|
||||
ErrorController,
|
||||
memoryDiagnostics,
|
||||
createSecurityHeaders,
|
||||
performStartupChecks,
|
||||
handleJsonParseError,
|
||||
GenerationJobManager,
|
||||
|
|
@ -155,6 +156,12 @@ const startServer = async () => {
|
|||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', trusted_proxy);
|
||||
|
||||
/* Registered ahead of every route so health checks carry the headers too. */
|
||||
const securityHeaders = createSecurityHeaders();
|
||||
if (securityHeaders) {
|
||||
app.use(securityHeaders);
|
||||
}
|
||||
|
||||
if (isEnabled(process.env.TRUST_TENANT_HEADER)) {
|
||||
logger.warn(
|
||||
'[Security] TRUST_TENANT_HEADER is active. Ensure your reverse proxy strips and sets ' +
|
||||
|
|
|
|||
|
|
@ -170,6 +170,22 @@ describe('Startup readiness wiring', () => {
|
|||
expect(timeoutConfigIndex).toBeLessThan(shutdownIndex);
|
||||
});
|
||||
|
||||
it('registers security headers ahead of the health endpoints in both server entries', () => {
|
||||
const experimental = fs.readFileSync(path.join(__dirname, 'experimental.js'), 'utf8');
|
||||
|
||||
for (const [name, contents] of [
|
||||
['index.js', source],
|
||||
['experimental.js', experimental],
|
||||
]) {
|
||||
const headersIndex = contents.indexOf('const securityHeaders = createSecurityHeaders();');
|
||||
const healthIndex = contents.indexOf("app.get('/health'");
|
||||
|
||||
expect([name, headersIndex > -1]).toEqual([name, true]);
|
||||
expect([name, healthIndex > -1]).toEqual([name, true]);
|
||||
expect([name, headersIndex < healthIndex]).toEqual([name, true]);
|
||||
}
|
||||
});
|
||||
|
||||
it('mounts the chat-start readiness gate before agent routes', () => {
|
||||
const readinessGateIndex = source.indexOf(
|
||||
"app.use('/api/agents/chat', rejectChatStartsUntilReady);",
|
||||
|
|
@ -250,6 +266,27 @@ describe('Server Configuration', () => {
|
|||
expect(response.text).toBe('OK');
|
||||
});
|
||||
|
||||
it('should set baseline security headers on health checks', async () => {
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.headers['strict-transport-security']).toBe('max-age=31536000');
|
||||
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(response.headers['cross-origin-opener-policy']).toBe('same-origin');
|
||||
expect(response.headers['cross-origin-resource-policy']).toBe('same-origin');
|
||||
expect(response.headers['referrer-policy']).toBe('no-referrer');
|
||||
});
|
||||
|
||||
it('should set baseline security headers on the index page without a CSP', async () => {
|
||||
const response = await request(app).get('/');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(response.headers['content-security-policy']).toBeUndefined();
|
||||
expect(response.headers['content-security-policy-report-only']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not cache index page', async () => {
|
||||
const response = await request(app).get('/');
|
||||
expect(response.status).toBe(200);
|
||||
|
|
|
|||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -27506,6 +27506,18 @@
|
|||
"integrity": "sha512-CxJE27BF6JcQvrL1giK478iSZr7EJNTnAN2Th1rAJiN1BSMYZxDLm4PL/p/ha3aSqVHvCo+YNk++5tIj0JVxLQ==",
|
||||
"license": "LGPL-3.0"
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.8.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
|
||||
|
|
@ -42771,6 +42783,7 @@
|
|||
"@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
|
||||
"cluster-key-slot": "^1.1.2",
|
||||
"croner": "^10.0.1",
|
||||
"helmet": "^8.3.0",
|
||||
"proxy-from-env": "^2.1.0",
|
||||
"re2js": "^2.8.6"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@
|
|||
"@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
|
||||
"cluster-key-slot": "^1.1.2",
|
||||
"croner": "^10.0.1",
|
||||
"helmet": "^8.3.0",
|
||||
"proxy-from-env": "^2.1.0",
|
||||
"re2js": "^2.8.6"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export * from './crypto';
|
|||
export * from './flow/manager';
|
||||
/* Middleware */
|
||||
export * from './middleware';
|
||||
/* Security */
|
||||
export * from './security';
|
||||
/* Content protection */
|
||||
export * from './protection';
|
||||
/* Imports */
|
||||
|
|
|
|||
134
packages/api/src/security/headers.spec.ts
Normal file
134
packages/api/src/security/headers.spec.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import type { Express } from 'express';
|
||||
|
||||
import { buildSecurityHeaderOptions, createSecurityHeaders } from './headers';
|
||||
|
||||
function appWith(env: NodeJS.ProcessEnv): Express {
|
||||
const app = express();
|
||||
const securityHeaders = createSecurityHeaders(env);
|
||||
if (securityHeaders) {
|
||||
app.use(securityHeaders);
|
||||
}
|
||||
app.get('/health', (_req, res) => {
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('buildSecurityHeaderOptions', () => {
|
||||
it('always disables CSP so no directive allow-list can go stale', () => {
|
||||
expect(buildSecurityHeaderOptions({})?.contentSecurityPolicy).toBe(false);
|
||||
expect(
|
||||
buildSecurityHeaderOptions({ CONTENT_SECURITY_POLICY: 'true' })?.contentSecurityPolicy,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when disabled outright', () => {
|
||||
expect(buildSecurityHeaderOptions({ SECURITY_HEADERS: 'false' })).toBeNull();
|
||||
expect(buildSecurityHeaderOptions({ SECURITY_HEADERS: 'off' })).toBeNull();
|
||||
expect(createSecurityHeaders({ SECURITY_HEADERS: 'false' })).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves HSTS includeSubDomains off unless opted in', () => {
|
||||
expect(buildSecurityHeaderOptions({})?.hsts).toEqual({
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: false,
|
||||
preload: false,
|
||||
});
|
||||
expect(buildSecurityHeaderOptions({ HSTS_INCLUDE_SUBDOMAINS: 'true' })?.hsts).toMatchObject({
|
||||
includeSubDomains: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to defaults for unparseable values', () => {
|
||||
const options = buildSecurityHeaderOptions({
|
||||
HSTS_MAX_AGE: 'forever',
|
||||
X_FRAME_OPTIONS: 'ALLOW-FROM https://portal.example.com',
|
||||
REFERRER_POLICY: 'whatever',
|
||||
SECURITY_HEADERS: 'maybe',
|
||||
});
|
||||
|
||||
expect(options?.hsts).toMatchObject({ maxAge: 31536000 });
|
||||
expect(options?.frameguard).toEqual({ action: 'sameorigin' });
|
||||
expect(options?.referrerPolicy).toEqual({ policy: 'no-referrer' });
|
||||
});
|
||||
|
||||
it('disables individual headers without disabling the rest', () => {
|
||||
const options = buildSecurityHeaderOptions({
|
||||
HSTS_ENABLED: 'false',
|
||||
X_FRAME_OPTIONS: 'off',
|
||||
CROSS_ORIGIN_RESOURCE_POLICY: 'false',
|
||||
});
|
||||
|
||||
expect(options?.hsts).toBe(false);
|
||||
expect(options?.frameguard).toBe(false);
|
||||
expect(options?.crossOriginResourcePolicy).toBe(false);
|
||||
expect(options?.crossOriginOpenerPolicy).toEqual({ policy: 'same-origin' });
|
||||
expect(options?.referrerPolicy).toEqual({ policy: 'no-referrer' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSecurityHeaders', () => {
|
||||
it('sets the baseline headers and never sets CSP', async () => {
|
||||
const response = await request(appWith({})).get('/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['strict-transport-security']).toBe('max-age=31536000');
|
||||
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(response.headers['cross-origin-opener-policy']).toBe('same-origin');
|
||||
expect(response.headers['cross-origin-resource-policy']).toBe('same-origin');
|
||||
expect(response.headers['referrer-policy']).toBe('no-referrer');
|
||||
expect(response.headers['origin-agent-cluster']).toBe('?1');
|
||||
expect(response.headers['content-security-policy']).toBeUndefined();
|
||||
expect(response.headers['content-security-policy-report-only']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('honors per-header overrides', async () => {
|
||||
const response = await request(
|
||||
appWith({
|
||||
HSTS_MAX_AGE: '600',
|
||||
HSTS_INCLUDE_SUBDOMAINS: 'true',
|
||||
HSTS_PRELOAD: 'true',
|
||||
X_FRAME_OPTIONS: 'DENY',
|
||||
CROSS_ORIGIN_RESOURCE_POLICY: 'cross-origin',
|
||||
CROSS_ORIGIN_OPENER_POLICY: 'same-origin-allow-popups',
|
||||
REFERRER_POLICY: 'strict-origin-when-cross-origin',
|
||||
}),
|
||||
).get('/health');
|
||||
|
||||
expect(response.headers['strict-transport-security']).toBe(
|
||||
'max-age=600; includeSubDomains; preload',
|
||||
);
|
||||
expect(response.headers['x-frame-options']).toBe('DENY');
|
||||
expect(response.headers['cross-origin-resource-policy']).toBe('cross-origin');
|
||||
expect(response.headers['cross-origin-opener-policy']).toBe('same-origin-allow-popups');
|
||||
expect(response.headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
|
||||
});
|
||||
|
||||
it('omits headers the operator turned off', async () => {
|
||||
const response = await request(
|
||||
appWith({
|
||||
HSTS_ENABLED: 'false',
|
||||
X_FRAME_OPTIONS: 'off',
|
||||
CROSS_ORIGIN_RESOURCE_POLICY: 'off',
|
||||
}),
|
||||
).get('/health');
|
||||
|
||||
expect(response.headers['strict-transport-security']).toBeUndefined();
|
||||
expect(response.headers['x-frame-options']).toBeUndefined();
|
||||
expect(response.headers['cross-origin-resource-policy']).toBeUndefined();
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('sets no headers at all when SECURITY_HEADERS is false', async () => {
|
||||
const response = await request(appWith({ SECURITY_HEADERS: 'false' })).get('/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['x-content-type-options']).toBeUndefined();
|
||||
expect(response.headers['x-frame-options']).toBeUndefined();
|
||||
expect(response.headers['strict-transport-security']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
197
packages/api/src/security/headers.ts
Normal file
197
packages/api/src/security/headers.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import helmet from 'helmet';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
const DEFAULT_HSTS_MAX_AGE = 31536000;
|
||||
|
||||
export type FrameOptionsAction = 'deny' | 'sameorigin';
|
||||
export type OpenerPolicy =
|
||||
| 'same-origin'
|
||||
| 'same-origin-allow-popups'
|
||||
| 'noopener-allow-popups'
|
||||
| 'unsafe-none';
|
||||
export type ResourcePolicy = 'same-origin' | 'same-site' | 'cross-origin';
|
||||
export type ReferrerPolicyToken =
|
||||
| 'no-referrer'
|
||||
| 'no-referrer-when-downgrade'
|
||||
| 'same-origin'
|
||||
| 'origin'
|
||||
| 'strict-origin'
|
||||
| 'origin-when-cross-origin'
|
||||
| 'strict-origin-when-cross-origin'
|
||||
| 'unsafe-url';
|
||||
|
||||
export interface HstsOptions {
|
||||
maxAge: number;
|
||||
includeSubDomains: boolean;
|
||||
preload: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityHeaderOptions {
|
||||
contentSecurityPolicy: false;
|
||||
hsts: HstsOptions | false;
|
||||
frameguard: { action: FrameOptionsAction } | false;
|
||||
crossOriginOpenerPolicy: { policy: OpenerPolicy } | false;
|
||||
crossOriginResourcePolicy: { policy: ResourcePolicy } | false;
|
||||
referrerPolicy: { policy: ReferrerPolicyToken } | false;
|
||||
}
|
||||
|
||||
const TRUTHY = new Set(['true', '1', 'yes', 'on', 'enabled']);
|
||||
const FALSY = new Set(['false', '0', 'no', 'off', 'disabled', 'none']);
|
||||
|
||||
const FRAME_ACTIONS = new Set<FrameOptionsAction>(['deny', 'sameorigin']);
|
||||
const OPENER_POLICIES = new Set<OpenerPolicy>([
|
||||
'same-origin',
|
||||
'same-origin-allow-popups',
|
||||
'noopener-allow-popups',
|
||||
'unsafe-none',
|
||||
]);
|
||||
const RESOURCE_POLICIES = new Set<ResourcePolicy>(['same-origin', 'same-site', 'cross-origin']);
|
||||
const REFERRER_TOKENS = new Set<ReferrerPolicyToken>([
|
||||
'no-referrer',
|
||||
'no-referrer-when-downgrade',
|
||||
'same-origin',
|
||||
'origin',
|
||||
'strict-origin',
|
||||
'origin-when-cross-origin',
|
||||
'strict-origin-when-cross-origin',
|
||||
'unsafe-url',
|
||||
]);
|
||||
|
||||
function normalize(value: string | undefined): string {
|
||||
return value == null ? '' : value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseSwitch(name: string, value: string | undefined, fallback: boolean): boolean {
|
||||
const normalized = normalize(value);
|
||||
if (normalized === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (TRUTHY.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (FALSY.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
logger.warn(`[SecurityHeaders] Ignoring invalid ${name}="${value}"; using ${fallback}.`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseMaxAge(value: string | undefined, fallback: number): number {
|
||||
const normalized = normalize(value);
|
||||
if (normalized === '') {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
logger.warn(`[SecurityHeaders] Ignoring invalid HSTS_MAX_AGE="${value}"; using ${fallback}.`);
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a header that is either disabled outright or set to one of a fixed
|
||||
* set of policy tokens. Returns `false` when the operator disabled it.
|
||||
*/
|
||||
function parsePolicy<T extends string>(
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
allowed: ReadonlySet<T>,
|
||||
fallback: T,
|
||||
): T | false {
|
||||
const normalized = normalize(value);
|
||||
if (normalized === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (FALSY.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (allowed.has(normalized as T)) {
|
||||
return normalized as T;
|
||||
}
|
||||
logger.warn(`[SecurityHeaders] Ignoring invalid ${name}="${value}"; using "${fallback}".`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildHsts(env: NodeJS.ProcessEnv): HstsOptions | false {
|
||||
if (!parseSwitch('HSTS_ENABLED', env.HSTS_ENABLED, true)) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
maxAge: parseMaxAge(env.HSTS_MAX_AGE, DEFAULT_HSTS_MAX_AGE),
|
||||
/* Opt-in rather than helmet's on-by-default: a bare-domain or `chat.example.com`
|
||||
* deployment would otherwise pin every sibling subdomain to HTTPS for a year in
|
||||
* every visitor's browser, and reversing that means serving `max-age=0` from each
|
||||
* affected host. */
|
||||
includeSubDomains: parseSwitch('HSTS_INCLUDE_SUBDOMAINS', env.HSTS_INCLUDE_SUBDOMAINS, false),
|
||||
preload: parseSwitch('HSTS_PRELOAD', env.HSTS_PRELOAD, false),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds helmet options from the environment. CSP is always disabled here so the
|
||||
* CSP-independent headers never depend on a directive allow-list staying current
|
||||
* with whichever optional features a deployment has enabled.
|
||||
*/
|
||||
export function buildSecurityHeaderOptions(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SecurityHeaderOptions | null {
|
||||
if (!parseSwitch('SECURITY_HEADERS', env.SECURITY_HEADERS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frameAction = parsePolicy(
|
||||
'X_FRAME_OPTIONS',
|
||||
env.X_FRAME_OPTIONS,
|
||||
FRAME_ACTIONS,
|
||||
'sameorigin',
|
||||
);
|
||||
const openerPolicy = parsePolicy(
|
||||
'CROSS_ORIGIN_OPENER_POLICY',
|
||||
env.CROSS_ORIGIN_OPENER_POLICY,
|
||||
OPENER_POLICIES,
|
||||
'same-origin',
|
||||
);
|
||||
const resourcePolicy = parsePolicy(
|
||||
'CROSS_ORIGIN_RESOURCE_POLICY',
|
||||
env.CROSS_ORIGIN_RESOURCE_POLICY,
|
||||
RESOURCE_POLICIES,
|
||||
'same-origin',
|
||||
);
|
||||
const referrerToken = parsePolicy(
|
||||
'REFERRER_POLICY',
|
||||
env.REFERRER_POLICY,
|
||||
REFERRER_TOKENS,
|
||||
'no-referrer',
|
||||
);
|
||||
|
||||
return {
|
||||
contentSecurityPolicy: false,
|
||||
hsts: buildHsts(env),
|
||||
frameguard: frameAction === false ? false : { action: frameAction },
|
||||
crossOriginOpenerPolicy: openerPolicy === false ? false : { policy: openerPolicy },
|
||||
crossOriginResourcePolicy: resourcePolicy === false ? false : { policy: resourcePolicy },
|
||||
referrerPolicy: referrerToken === false ? false : { policy: referrerToken },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the baseline security-header middleware, or `null` when the operator
|
||||
* disabled it via `SECURITY_HEADERS=false`.
|
||||
*
|
||||
* @example
|
||||
* const securityHeaders = createSecurityHeaders();
|
||||
* if (securityHeaders) {
|
||||
* app.use(securityHeaders);
|
||||
* }
|
||||
*/
|
||||
export function createSecurityHeaders(env: NodeJS.ProcessEnv = process.env): RequestHandler | null {
|
||||
const options = buildSecurityHeaderOptions(env);
|
||||
if (!options) {
|
||||
logger.warn('[SecurityHeaders] Disabled via SECURITY_HEADERS; no baseline headers are set.');
|
||||
return null;
|
||||
}
|
||||
return helmet(options) as RequestHandler;
|
||||
}
|
||||
1
packages/api/src/security/index.ts
Normal file
1
packages/api/src/security/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './headers';
|
||||
Loading…
Add table
Add a link
Reference in a new issue