mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -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",
|
||||
"fetch-retry": "^5.0.4",
|
||||
"find-up": "^6.3.0",
|
||||
"helmet": "^6.0.1",
|
||||
"http-proxy": "^1.18.1",
|
||||
"jose": "^4.11.0",
|
||||
"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: withSpa } = await import('./middleware/with-spa.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 { default: router } = await import('./routes/index.js');
|
||||
|
@ -30,6 +31,7 @@ const { listen } = createServer({
|
|||
.and(withErrorReport())
|
||||
.and(withRequest())
|
||||
.and(anonymousRouter.routes())
|
||||
.and(withSecurityHeaders())
|
||||
.and(
|
||||
withPathname(
|
||||
'/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",
|
||||
"got": "^12.5.3",
|
||||
"hash-wasm": "^4.9.0",
|
||||
"helmet": "^6.0.1",
|
||||
"i18next": "^21.8.16",
|
||||
"iconv-lite": "0.6.3",
|
||||
"jose": "^4.11.0",
|
||||
|
@ -59,7 +60,6 @@
|
|||
"koa-body": "^5.0.0",
|
||||
"koa-compose": "^4.1.0",
|
||||
"koa-compress": "^5.1.0",
|
||||
"koa-helmet": "^7.0.2",
|
||||
"koa-logger": "^3.2.1",
|
||||
"koa-mount": "^4.0.0",
|
||||
"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 helmet, { type HelmetOptions } from 'helmet';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import helmet from 'koa-helmet';
|
||||
|
||||
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://helmetjs.github.io/ for more details
|
||||
* @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>(
|
||||
mountedApps: string[],
|
||||
tenantId: string
|
||||
): 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 adminOrigins = adminUrlSet.origins;
|
||||
|
@ -66,7 +77,9 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
|||
// 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, ...developmentOrigins],
|
||||
// WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe
|
||||
frameSrc: ["'self'", 'https:'],
|
||||
|
@ -85,7 +98,9 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
|||
// 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'",
|
||||
tenantEndpointOrigin,
|
||||
|
@ -99,26 +114,30 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
|
|||
},
|
||||
};
|
||||
|
||||
const buildHelmetMiddleware: (options: HelmetOptions) => Middleware = (options) =>
|
||||
helmet(options);
|
||||
|
||||
return async (ctx, next) => {
|
||||
const requestPath = ctx.request.path;
|
||||
const { request, req, res } = ctx;
|
||||
const requestPath = request.path;
|
||||
|
||||
// Admin Console
|
||||
if (
|
||||
requestPath.startsWith(`/${AdminApps.Console}`) ||
|
||||
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
|
||||
if (mountedApps.some((app) => app !== '' && requestPath.startsWith(`/${app}`))) {
|
||||
return buildHelmetMiddleware(basicSecurityHeaderSettings)(ctx, next);
|
||||
await helmetPromise(basicSecurityHeaderSettings, req, res);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
// Main flow UI
|
||||
return buildHelmetMiddleware(mainFlowUiSecurityHeaderSettings)(ctx, next);
|
||||
await helmetPromise(mainFlowUiSecurityHeaderSettings, req, res);
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -262,6 +262,9 @@ importers:
|
|||
find-up:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
helmet:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
http-proxy:
|
||||
specifier: ^1.18.1
|
||||
version: 1.18.1
|
||||
|
@ -3080,6 +3083,9 @@ importers:
|
|||
hash-wasm:
|
||||
specifier: ^4.9.0
|
||||
version: 4.9.0
|
||||
helmet:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
i18next:
|
||||
specifier: ^21.8.16
|
||||
version: 21.8.16
|
||||
|
@ -3104,9 +3110,6 @@ importers:
|
|||
koa-compress:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0
|
||||
koa-helmet:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
koa-logger:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
|
@ -13730,13 +13733,6 @@ packages:
|
|||
co: 4.6.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:
|
||||
resolution: {integrity: sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==}
|
||||
dev: false
|
||||
|
|
Loading…
Reference in a new issue