From 1c431e7a59f79559569af6464f71f6f09d13d769 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 3 Apr 2023 10:24:50 +0800 Subject: [PATCH] feat(core): apply standard security headers 1/2 (#3590) * feat(core): add some basic security headers add some basic security headers * chore(core): add some comments add some comments * chore(core): update the refererPolicy configs update the refererPolicy configs * chore(core): update helmet middleware update helmet middleware * feat(core): add csp headers to the mainflow and ac http requests 2/2 (#3613) * feat(core): add csp headers to the mainflow requests add csp headers to the mainflow requests * chore(core): add ui and console security headers add ui and console security headers * fix(core): remove unused middleware remove unused middleware * fix(ui): set terms iframe sandbox set terms iframe sandbox allow same origin * fix(core): update security headers middleware update security headers middleware * chore(core): add changesets * chore(core): address rebase conflict address rebase conflict --- .changeset-staged/shiny-crabs-wink.md | 21 ++ .../ImageWithErrorFallback/index.tsx | 9 +- .../components/GithubRawImage/index.tsx | 1 + .../Uploader/ImageUploader/index.tsx | 2 +- packages/core/package.json | 1 + .../src/middleware/koa-security-headers.ts | 124 ++++++++++ packages/core/src/tenants/Tenant.ts | 4 + packages/shared/src/env/UrlSet.test.ts | 11 + packages/shared/src/env/UrlSet.ts | 4 + .../IframeModalProvider/IframeModal/index.tsx | 3 +- .../src/components/BrandingHeader/index.tsx | 2 +- .../components/Button/SocialLinkButton.tsx | 9 +- .../ui/src/containers/SocialLanding/index.tsx | 1 + packages/ui/src/pages/Consent/index.tsx | 4 +- pnpm-lock.yaml | 233 +++++++++++------- 15 files changed, 330 insertions(+), 99 deletions(-) create mode 100644 .changeset-staged/shiny-crabs-wink.md create mode 100644 packages/core/src/middleware/koa-security-headers.ts diff --git a/.changeset-staged/shiny-crabs-wink.md b/.changeset-staged/shiny-crabs-wink.md new file mode 100644 index 000000000..6f166ee6c --- /dev/null +++ b/.changeset-staged/shiny-crabs-wink.md @@ -0,0 +1,21 @@ +--- +"@logto/console": patch +"@logto/core": patch +"@logto/shared": patch +"@logto/ui": patch +--- + +Apply security headers + +Apply security headers to logto http request response using (helmetjs)[https://helmetjs.github.io/]. + +[x] crossOriginOpenerPolicy +[x] crossOriginEmbedderPolicy +[x] crossOriginResourcePolicy +[x] hidePoweredBy +[x] hsts +[x] ieNoOpen +[x] noSniff +[x] referrerPolicy +[x] xssFilter +[x] Content-Security-Policy diff --git a/packages/console/src/components/ImageWithErrorFallback/index.tsx b/packages/console/src/components/ImageWithErrorFallback/index.tsx index 9cd18b1a0..25f21a72b 100644 --- a/packages/console/src/components/ImageWithErrorFallback/index.tsx +++ b/packages/console/src/components/ImageWithErrorFallback/index.tsx @@ -39,7 +39,14 @@ function ImageWithErrorFallback({ return (
- {alt} + {alt}
); } diff --git a/packages/console/src/components/Markdown/components/GithubRawImage/index.tsx b/packages/console/src/components/Markdown/components/GithubRawImage/index.tsx index 08774860b..e1c42ed5f 100644 --- a/packages/console/src/components/Markdown/components/GithubRawImage/index.tsx +++ b/packages/console/src/components/Markdown/components/GithubRawImage/index.tsx @@ -27,6 +27,7 @@ function GithubRawImage({ src, alt }: HTMLProps) { src={`${githubRawUrlPrefix}${src}`} alt={alt} width={`${width}px`} + crossOrigin="anonymous" onLoad={onLoad} /> ); diff --git a/packages/console/src/components/Uploader/ImageUploader/index.tsx b/packages/console/src/components/Uploader/ImageUploader/index.tsx index 6d8d68c62..ed3533d85 100644 --- a/packages/console/src/components/Uploader/ImageUploader/index.tsx +++ b/packages/console/src/components/Uploader/ImageUploader/index.tsx @@ -26,7 +26,7 @@ export type Props = Omit & { function ImageUploader({ name, value, onDelete, ...rest }: Props) { return value ? (
- {name} + {name} { diff --git a/packages/core/package.json b/packages/core/package.json index 4af5da523..75b175649 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,6 +56,7 @@ "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 new file mode 100644 index 000000000..4e81b5578 --- /dev/null +++ b/packages/core/src/middleware/koa-security-headers.ts @@ -0,0 +1,124 @@ +import { defaultTenantId } from '@logto/schemas'; +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 + * @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 + */ + +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; + const cloudOrigins = isCloud ? cloudUrlSet.origins : []; + const tenantEndpointOrigin = getTenantEndpoint( + isMultiTenancy ? tenantId : defaultTenantId, + EnvSet.values + ).origin; + const developmentOrigins = isProduction ? [] : ['ws:']; + + /** + * 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 + */ + + const basicSecurityHeaderSettings: HelmetOptions = { + contentSecurityPolicy: false, // Exclusively set per app + expectCt: false, // Not recommended, will be deprecated by modern browsers + dnsPrefetchControl: false, + referrerPolicy: { + policy: 'strict-origin-when-cross-origin', + }, + }; + + const mainFlowUiSecurityHeaderSettings: HelmetOptions = { + ...basicSecurityHeaderSettings, + // WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe + frameguard: false, + // Alow loaded by console preview iframe + crossOriginResourcePolicy: { + policy: 'cross-origin', + }, + contentSecurityPolicy: { + useDefaults: true, + // Temporary set to report only to avoid breaking the app + reportOnly: true, + directives: { + imgSrc: ["'self'", 'data:', 'https:'], + connectSrc: ["'self'", ...adminOrigins, ...cloudOrigins, ...developmentOrigins], + // WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe + frameSrc: ["'self'", 'https:'], + // Alow loaded by console preview iframe + frameAncestors: ["'self'", ...adminOrigins, ...cloudOrigins], + }, + }, + }; + + const consoleSecurityHeaderSettings: HelmetOptions = { + ...basicSecurityHeaderSettings, + // Guarded by CSP header bellow + frameguard: false, + contentSecurityPolicy: { + useDefaults: true, + // Temporary set to report only to avoid breaking the app + reportOnly: true, + directives: { + imgSrc: ["'self'", 'data:', 'https:'], + connectSrc: [ + "'self'", + tenantEndpointOrigin, + ...adminOrigins, + ...cloudOrigins, + ...developmentOrigins, + ], + // Allow Main Flow origin loaded in preview iframe + frameSrc: ["'self'", tenantEndpointOrigin], + }, + }, + }; + + const buildHelmetMiddleware: (options: HelmetOptions) => Middleware = (options) => + helmet(options); + + return async (ctx, next) => { + const requestPath = ctx.request.path; + + // Admin Console + if ( + requestPath.startsWith(`/${AdminApps.Console}`) || + requestPath.startsWith(`/${AdminApps.Welcome}`) + ) { + return buildHelmetMiddleware(consoleSecurityHeaderSettings)(ctx, next); + } + + // Route has been handled by one of mounted apps + if (mountedApps.some((app) => app !== '' && requestPath.startsWith(`/${app}`))) { + return buildHelmetMiddleware(basicSecurityHeaderSettings)(ctx, next); + } + + // Main flow UI + return buildHelmetMiddleware(mainFlowUiSecurityHeaderSettings)(ctx, next); + }; +} diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index 07c55e098..c01edd0c8 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -14,6 +14,7 @@ import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy. import koaErrorHandler from '#src/middleware/koa-error-handler.js'; import koaI18next from '#src/middleware/koa-i18next.js'; import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js'; +import koaSecurityHeaders from '#src/middleware/koa-security-headers.js'; import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js'; import koaSpaProxy from '#src/middleware/koa-spa-proxy.js'; import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js'; @@ -69,6 +70,7 @@ export default class Tenant implements TenantContext { app.use(koaConnectorErrorHandler()); app.use(koaI18next()); app.use(koaCompress()); + app.use(koaSecurityHeaders(mountedApps, id)); // Mount OIDC const provider = initOidc(id, envSet, queries, libraries); @@ -82,6 +84,7 @@ export default class Tenant implements TenantContext { libraries, envSet, }; + // Mount APIs app.use(mount('/api', initApis(tenantContext))); @@ -126,6 +129,7 @@ export default class Tenant implements TenantContext { this.provider = provider; const { isPathBasedMultiTenancy, adminUrlSet } = EnvSet.values; + this.run = isPathBasedMultiTenancy && // If admin URL Set is specified, consider that URL first diff --git a/packages/shared/src/env/UrlSet.test.ts b/packages/shared/src/env/UrlSet.test.ts index 37a2536b6..2348ddc2a 100644 --- a/packages/shared/src/env/UrlSet.test.ts +++ b/packages/shared/src/env/UrlSet.test.ts @@ -20,6 +20,10 @@ describe('UrlSet', () => { new URL('https://localhost:3001'), new URL('https://logto.mock'), ]); + expect(set1.origins).toStrictEqual([ + new URL('https://localhost:3001').origin, + new URL('https://logto.mock').origin, + ]); expect(set1.port).toEqual(3001); expect(set1.localhostUrl).toEqual(new URL('https://localhost:3001')); expect(set1.endpoint).toEqual(new URL('https://logto.mock')); @@ -30,6 +34,10 @@ describe('UrlSet', () => { new URL('http://localhost:3002/'), new URL('https://admin.logto.mock/'), ]); + expect(set2.origins).toStrictEqual([ + new URL('http://localhost:3002/').origin, + new URL('https://admin.logto.mock/').origin, + ]); expect(set2.port).toEqual(3002); expect(set2.localhostUrl).toEqual(new URL('http://localhost:3002')); expect(set2.endpoint).toEqual(new URL('https://admin.logto.mock')); @@ -44,6 +52,7 @@ describe('UrlSet', () => { const set1 = new UrlSet(false, 3001); expect(set1.deduplicated()).toStrictEqual([new URL('http://localhost:3001/')]); + expect(set1.origins).toStrictEqual([new URL('http://localhost:3001/').origin]); expect(set1.port).toEqual(3001); expect(set1.localhostUrl).toEqual(new URL('http://localhost:3001')); expect(set1.endpoint).toEqual(new URL('http://localhost:3001')); @@ -59,6 +68,7 @@ describe('UrlSet', () => { const set1 = new UrlSet(true, 3001); expect(set1.deduplicated()).toStrictEqual([new URL('https://logto.mock/logto')]); + expect(set1.origins).toStrictEqual([new URL('https://logto.mock/logto').origin]); expect(() => set1.port).toThrowError('Localhost has been disabled in this URL Set.'); expect(() => set1.localhostUrl).toThrowError('Localhost has been disabled in this URL Set.'); expect(set1.endpoint).toEqual(new URL('https://logto.mock/logto')); @@ -74,6 +84,7 @@ describe('UrlSet', () => { const set1 = new UrlSet(false, 3002, 'ADMIN_'); expect(set1.deduplicated()).toStrictEqual([]); + expect(set1.origins).toStrictEqual([]); expect(() => set1.port).toThrowError('Localhost has been disabled in this URL Set.'); expect(() => set1.localhostUrl).toThrowError('Localhost has been disabled in this URL Set.'); expect(() => set1.endpoint).toThrowError('No available endpoint in this URL Set.'); diff --git a/packages/shared/src/env/UrlSet.ts b/packages/shared/src/env/UrlSet.ts index fd269f3ac..a8fa29ef7 100644 --- a/packages/shared/src/env/UrlSet.ts +++ b/packages/shared/src/env/UrlSet.ts @@ -39,6 +39,10 @@ export default class UrlSet { ).map((value) => new URL(value)); } + public get origins(): string[] { + return this.deduplicated().map((url) => url.origin); + } + public get port(): number { if (this.isLocalhostDisabled) { throw new Error('Localhost has been disabled in this URL Set.'); diff --git a/packages/ui/src/Providers/IframeModalProvider/IframeModal/index.tsx b/packages/ui/src/Providers/IframeModalProvider/IframeModal/index.tsx index 5754277a5..3d50e461b 100644 --- a/packages/ui/src/Providers/IframeModalProvider/IframeModal/index.tsx +++ b/packages/ui/src/Providers/IframeModalProvider/IframeModal/index.tsx @@ -52,8 +52,7 @@ const IframeModal = ({ className, title = '', href = '', onClose }: ModalProps)