From 764d0dd5acee5e27351c9e6ca3472d6a78ce9e1a Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 10 Apr 2023 10:23:07 +0800 Subject: [PATCH] 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 --- packages/cloud/package.json | 1 + packages/cloud/src/index.ts | 2 + .../src/middleware/with-security-headers.ts | 110 ++++++++++++++++++ packages/core/package.json | 2 +- .../src/middleware/koa-security-headers.ts | 45 ++++--- pnpm-lock.yaml | 16 +-- 6 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 packages/cloud/src/middleware/with-security-headers.ts diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 681a0b720..23c1a9736 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -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", diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 4a3e13589..4729f17cd 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -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', diff --git a/packages/cloud/src/middleware/with-security-headers.ts b/packages/cloud/src/middleware/with-security-headers.ts new file mode 100644 index 000000000..3f531cc83 --- /dev/null +++ b/packages/cloud/src/middleware/with-security-headers.ts @@ -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() { + 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, + { 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); + }; +} diff --git a/packages/core/package.json b/packages/core/package.json index fed439cc9..a1879b61a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/middleware/koa-security-headers.ts b/packages/core/src/middleware/koa-security-headers.ts index 4e81b5578..9d78be95b 100644 --- a/packages/core/src/middleware/koa-security-headers.ts +++ b/packages/core/src/middleware/koa-security-headers.ts @@ -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( mountedApps: string[], tenantId: string ): MiddlewareType { - type Middleware = MiddlewareType; - - type HelmetOptions = Parameters[0]; - const { isProduction, isCloud, isMultiTenancy, adminUrlSet, cloudUrlSet } = EnvSet.values; const adminOrigins = adminUrlSet.origins; @@ -66,7 +77,9 @@ export default function koaSecurityHeaders( // 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( // 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( }, }; - 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(); }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb4a08542..2dbffddee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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