mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
chore(core,cloud): add cloud route security headers (#3674)
* chore(core,cloud): add cloud route security headers add cloud routes security headers * chore(core,cloud): only allow ws on dev environment only allow ws on dev environment * refactor(core,cloud): update using helmetjs package update using helmetjs package * chore(core): fix comment fix comment
This commit is contained in:
parent
7af8e9c9b1
commit
764d0dd5ac
6 changed files with 152 additions and 24 deletions
|
@ -38,6 +38,7 @@
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"fetch-retry": "^5.0.4",
|
"fetch-retry": "^5.0.4",
|
||||||
"find-up": "^6.3.0",
|
"find-up": "^6.3.0",
|
||||||
|
"helmet": "^6.0.1",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
"jose": "^4.11.0",
|
"jose": "^4.11.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|
|
@ -17,6 +17,7 @@ const { default: withHttpProxy } = await import('./middleware/with-http-proxy.js
|
||||||
const { default: withPathname } = await import('./middleware/with-pathname.js');
|
const { default: withPathname } = await import('./middleware/with-pathname.js');
|
||||||
const { default: withSpa } = await import('./middleware/with-spa.js');
|
const { default: withSpa } = await import('./middleware/with-spa.js');
|
||||||
const { default: withErrorReport } = await import('./middleware/with-error-report.js');
|
const { default: withErrorReport } = await import('./middleware/with-error-report.js');
|
||||||
|
const { default: withSecurityHeaders } = await import('./middleware/with-security-headers.js');
|
||||||
|
|
||||||
const { EnvSet } = await import('./env-set/index.js');
|
const { EnvSet } = await import('./env-set/index.js');
|
||||||
const { default: router } = await import('./routes/index.js');
|
const { default: router } = await import('./routes/index.js');
|
||||||
|
@ -30,6 +31,7 @@ const { listen } = createServer({
|
||||||
.and(withErrorReport())
|
.and(withErrorReport())
|
||||||
.and(withRequest())
|
.and(withRequest())
|
||||||
.and(anonymousRouter.routes())
|
.and(anonymousRouter.routes())
|
||||||
|
.and(withSecurityHeaders())
|
||||||
.and(
|
.and(
|
||||||
withPathname(
|
withPathname(
|
||||||
'/api',
|
'/api',
|
||||||
|
|
110
packages/cloud/src/middleware/with-security-headers.ts
Normal file
110
packages/cloud/src/middleware/with-security-headers.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
import type { NextFunction, HttpContext, RequestContext } from '@withtyped/server';
|
||||||
|
import helmet, { type HelmetOptions } from 'helmet';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply security headers to the response using helmet
|
||||||
|
* @see https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html for recommended headers
|
||||||
|
* @see https://helmetjs.github.io/ for more details
|
||||||
|
* @returns middleware
|
||||||
|
*/
|
||||||
|
|
||||||
|
const helmetPromise = async (
|
||||||
|
settings: HelmetOptions,
|
||||||
|
request: IncomingMessage,
|
||||||
|
response: ServerResponse
|
||||||
|
) =>
|
||||||
|
promisify((callback) => {
|
||||||
|
helmet(settings)(request, response, (error) => {
|
||||||
|
// Make TS happy
|
||||||
|
callback(error, null);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
export default function withSecurityHeaders<InputContext extends RequestContext>() {
|
||||||
|
const {
|
||||||
|
global: { adminUrlSet, cloudUrlSet, urlSet },
|
||||||
|
isProduction,
|
||||||
|
} = EnvSet;
|
||||||
|
|
||||||
|
const adminOrigins = adminUrlSet.origins;
|
||||||
|
const cloudOrigins = cloudUrlSet.origins;
|
||||||
|
const urlSetOrigins = urlSet.origins;
|
||||||
|
const developmentOrigins = isProduction ? [] : ['ws:'];
|
||||||
|
|
||||||
|
return async (
|
||||||
|
context: InputContext,
|
||||||
|
next: NextFunction<InputContext>,
|
||||||
|
{ response, request }: HttpContext
|
||||||
|
) => {
|
||||||
|
const requestPath = context.request.url.pathname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Applied rules:
|
||||||
|
* - crossOriginOpenerPolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#cross-origin-opener-policy-coop
|
||||||
|
* - crossOriginResourcePolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#cross-origin-resource-policy-corp
|
||||||
|
* - crossOriginEmbedderPolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#cross-origin-embedder-policy-coep
|
||||||
|
* - hidePoweredBy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-powered-by
|
||||||
|
* - hsts: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#strict-transport-security-hsts
|
||||||
|
* - ieNoOpen: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-download-options
|
||||||
|
* - noSniff: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-content-type-options
|
||||||
|
* - permittedCrossDomainPolicies: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-permitted-cross-domain-policies
|
||||||
|
* - referrerPolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#referrer-policy
|
||||||
|
* - xssFilter: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-xss-protection
|
||||||
|
* - originAgentCluster: https://whatpr.org/html/6214/origin.html#origin-keyed-agent-clusters
|
||||||
|
* - frameguard: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-frame-options
|
||||||
|
*/
|
||||||
|
|
||||||
|
const basicSecurityHeaderSettings: HelmetOptions = {
|
||||||
|
contentSecurityPolicy: false, // Exclusively set for console app only
|
||||||
|
expectCt: false, // Not recommended, will be deprecated by modern browsers
|
||||||
|
dnsPrefetchControl: false,
|
||||||
|
referrerPolicy: {
|
||||||
|
policy: 'strict-origin-when-cross-origin',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requestPath.startsWith('/api')) {
|
||||||
|
// FrameOptions: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-frame-options
|
||||||
|
|
||||||
|
await helmetPromise(basicSecurityHeaderSettings, request, response);
|
||||||
|
|
||||||
|
return next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For cloud console
|
||||||
|
// ContentSecurityPolicy: https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
|
||||||
|
await helmetPromise(
|
||||||
|
{
|
||||||
|
...basicSecurityHeaderSettings,
|
||||||
|
frameguard: false,
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
useDefaults: true,
|
||||||
|
// Temporary set to report only to avoid breaking the app
|
||||||
|
reportOnly: true,
|
||||||
|
directives: {
|
||||||
|
'upgrade-insecure-requests': null,
|
||||||
|
imgSrc: ["'self'", 'data:', 'https:'],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-eval'", "'unsafe-inline'"],
|
||||||
|
connectSrc: [
|
||||||
|
"'self'",
|
||||||
|
...adminOrigins,
|
||||||
|
...cloudOrigins,
|
||||||
|
...urlSetOrigins,
|
||||||
|
...developmentOrigins,
|
||||||
|
],
|
||||||
|
frameSrc: ["'self'", ...urlSetOrigins],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
|
return next(context);
|
||||||
|
};
|
||||||
|
}
|
|
@ -51,6 +51,7 @@
|
||||||
"find-up": "^6.3.0",
|
"find-up": "^6.3.0",
|
||||||
"got": "^12.5.3",
|
"got": "^12.5.3",
|
||||||
"hash-wasm": "^4.9.0",
|
"hash-wasm": "^4.9.0",
|
||||||
|
"helmet": "^6.0.1",
|
||||||
"i18next": "^21.8.16",
|
"i18next": "^21.8.16",
|
||||||
"iconv-lite": "0.6.3",
|
"iconv-lite": "0.6.3",
|
||||||
"jose": "^4.11.0",
|
"jose": "^4.11.0",
|
||||||
|
@ -59,7 +60,6 @@
|
||||||
"koa-body": "^5.0.0",
|
"koa-body": "^5.0.0",
|
||||||
"koa-compose": "^4.1.0",
|
"koa-compose": "^4.1.0",
|
||||||
"koa-compress": "^5.1.0",
|
"koa-compress": "^5.1.0",
|
||||||
"koa-helmet": "^7.0.2",
|
|
||||||
"koa-logger": "^3.2.1",
|
"koa-logger": "^3.2.1",
|
||||||
"koa-mount": "^4.0.0",
|
"koa-mount": "^4.0.0",
|
||||||
"koa-proxies": "^0.12.1",
|
"koa-proxies": "^0.12.1",
|
||||||
|
|
|
@ -1,24 +1,35 @@
|
||||||
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
import { defaultTenantId } from '@logto/schemas';
|
import { defaultTenantId } from '@logto/schemas';
|
||||||
|
import helmet, { type HelmetOptions } from 'helmet';
|
||||||
import type { MiddlewareType } from 'koa';
|
import type { MiddlewareType } from 'koa';
|
||||||
import helmet from 'koa-helmet';
|
|
||||||
|
|
||||||
import { EnvSet, AdminApps, getTenantEndpoint } from '#src/env-set/index.js';
|
import { EnvSet, AdminApps, getTenantEndpoint } from '#src/env-set/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply security headers to the response using koa-helmet
|
* Apply security headers to the response using helmet
|
||||||
* @see https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html for recommended headers
|
* @see https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html for recommended headers
|
||||||
* @see https://helmetjs.github.io/ for more details
|
* @see https://helmetjs.github.io/ for more details
|
||||||
* @returns koa middleware
|
* @returns koa middleware
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const helmetPromise = async (
|
||||||
|
settings: HelmetOptions,
|
||||||
|
request: IncomingMessage,
|
||||||
|
response: ServerResponse
|
||||||
|
) =>
|
||||||
|
promisify((callback) => {
|
||||||
|
helmet(settings)(request, response, (error) => {
|
||||||
|
// Make TS happy
|
||||||
|
callback(error, null);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
||||||
mountedApps: string[],
|
mountedApps: string[],
|
||||||
tenantId: string
|
tenantId: string
|
||||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||||
type Middleware = MiddlewareType<StateT, ContextT, ResponseBodyT>;
|
|
||||||
|
|
||||||
type HelmetOptions = Parameters<typeof helmet>[0];
|
|
||||||
|
|
||||||
const { isProduction, isCloud, isMultiTenancy, adminUrlSet, cloudUrlSet } = EnvSet.values;
|
const { isProduction, isCloud, isMultiTenancy, adminUrlSet, cloudUrlSet } = EnvSet.values;
|
||||||
|
|
||||||
const adminOrigins = adminUrlSet.origins;
|
const adminOrigins = adminUrlSet.origins;
|
||||||
|
@ -66,7 +77,9 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
||||||
// Temporary set to report only to avoid breaking the app
|
// Temporary set to report only to avoid breaking the app
|
||||||
reportOnly: true,
|
reportOnly: true,
|
||||||
directives: {
|
directives: {
|
||||||
|
'upgrade-insecure-requests': null,
|
||||||
imgSrc: ["'self'", 'data:', 'https:'],
|
imgSrc: ["'self'", 'data:', 'https:'],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-eval'", "'unsafe-inline'"],
|
||||||
connectSrc: ["'self'", ...adminOrigins, ...cloudOrigins, ...developmentOrigins],
|
connectSrc: ["'self'", ...adminOrigins, ...cloudOrigins, ...developmentOrigins],
|
||||||
// WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe
|
// WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe
|
||||||
frameSrc: ["'self'", 'https:'],
|
frameSrc: ["'self'", 'https:'],
|
||||||
|
@ -85,7 +98,9 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
||||||
// Temporary set to report only to avoid breaking the app
|
// Temporary set to report only to avoid breaking the app
|
||||||
reportOnly: true,
|
reportOnly: true,
|
||||||
directives: {
|
directives: {
|
||||||
|
'upgrade-insecure-requests': null,
|
||||||
imgSrc: ["'self'", 'data:', 'https:'],
|
imgSrc: ["'self'", 'data:', 'https:'],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-eval'", "'unsafe-inline'"],
|
||||||
connectSrc: [
|
connectSrc: [
|
||||||
"'self'",
|
"'self'",
|
||||||
tenantEndpointOrigin,
|
tenantEndpointOrigin,
|
||||||
|
@ -99,26 +114,30 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildHelmetMiddleware: (options: HelmetOptions) => Middleware = (options) =>
|
|
||||||
helmet(options);
|
|
||||||
|
|
||||||
return async (ctx, next) => {
|
return async (ctx, next) => {
|
||||||
const requestPath = ctx.request.path;
|
const { request, req, res } = ctx;
|
||||||
|
const requestPath = request.path;
|
||||||
|
|
||||||
// Admin Console
|
// Admin Console
|
||||||
if (
|
if (
|
||||||
requestPath.startsWith(`/${AdminApps.Console}`) ||
|
requestPath.startsWith(`/${AdminApps.Console}`) ||
|
||||||
requestPath.startsWith(`/${AdminApps.Welcome}`)
|
requestPath.startsWith(`/${AdminApps.Welcome}`)
|
||||||
) {
|
) {
|
||||||
return buildHelmetMiddleware(consoleSecurityHeaderSettings)(ctx, next);
|
await helmetPromise(consoleSecurityHeaderSettings, req, res);
|
||||||
|
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route has been handled by one of mounted apps
|
// Route has been handled by one of mounted apps
|
||||||
if (mountedApps.some((app) => app !== '' && requestPath.startsWith(`/${app}`))) {
|
if (mountedApps.some((app) => app !== '' && requestPath.startsWith(`/${app}`))) {
|
||||||
return buildHelmetMiddleware(basicSecurityHeaderSettings)(ctx, next);
|
await helmetPromise(basicSecurityHeaderSettings, req, res);
|
||||||
|
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main flow UI
|
// Main flow UI
|
||||||
return buildHelmetMiddleware(mainFlowUiSecurityHeaderSettings)(ctx, next);
|
await helmetPromise(mainFlowUiSecurityHeaderSettings, req, res);
|
||||||
|
|
||||||
|
return next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -262,6 +262,9 @@ importers:
|
||||||
find-up:
|
find-up:
|
||||||
specifier: ^6.3.0
|
specifier: ^6.3.0
|
||||||
version: 6.3.0
|
version: 6.3.0
|
||||||
|
helmet:
|
||||||
|
specifier: ^6.0.1
|
||||||
|
version: 6.0.1
|
||||||
http-proxy:
|
http-proxy:
|
||||||
specifier: ^1.18.1
|
specifier: ^1.18.1
|
||||||
version: 1.18.1
|
version: 1.18.1
|
||||||
|
@ -3080,6 +3083,9 @@ importers:
|
||||||
hash-wasm:
|
hash-wasm:
|
||||||
specifier: ^4.9.0
|
specifier: ^4.9.0
|
||||||
version: 4.9.0
|
version: 4.9.0
|
||||||
|
helmet:
|
||||||
|
specifier: ^6.0.1
|
||||||
|
version: 6.0.1
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^21.8.16
|
specifier: ^21.8.16
|
||||||
version: 21.8.16
|
version: 21.8.16
|
||||||
|
@ -3104,9 +3110,6 @@ importers:
|
||||||
koa-compress:
|
koa-compress:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
koa-helmet:
|
|
||||||
specifier: ^7.0.2
|
|
||||||
version: 7.0.2
|
|
||||||
koa-logger:
|
koa-logger:
|
||||||
specifier: ^3.2.1
|
specifier: ^3.2.1
|
||||||
version: 3.2.1
|
version: 3.2.1
|
||||||
|
@ -13730,13 +13733,6 @@ packages:
|
||||||
co: 4.6.0
|
co: 4.6.0
|
||||||
koa-compose: 4.1.0
|
koa-compose: 4.1.0
|
||||||
|
|
||||||
/koa-helmet@7.0.2:
|
|
||||||
resolution: {integrity: sha512-AvzS6VuEfFgbAm0mTUnkk/BpMarMcs5A56g+f0sfrJ6m63wII48d2GDrnUQGp0Nj+RR950vNtgqXm9UJSe7GOg==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
dependencies:
|
|
||||||
helmet: 6.0.1
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/koa-is-json@1.0.0:
|
/koa-is-json@1.0.0:
|
||||||
resolution: {integrity: sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==}
|
resolution: {integrity: sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
Loading…
Reference in a new issue