diff --git a/.changeset/calm-clouds-load.md b/.changeset/calm-clouds-load.md new file mode 100644 index 000000000000..586e7867eb72 --- /dev/null +++ b/.changeset/calm-clouds-load.md @@ -0,0 +1,5 @@ +--- +"@logto/core": patch +--- + +fix self-hosted Console entry points when requests are served by the Experience fallback diff --git a/packages/core/src/middleware/koa-security-headers.test.ts b/packages/core/src/middleware/koa-security-headers.test.ts index afd05a855f64..5297d7f36650 100644 --- a/packages/core/src/middleware/koa-security-headers.test.ts +++ b/packages/core/src/middleware/koa-security-headers.test.ts @@ -8,11 +8,12 @@ import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-contex const { jest } = import.meta; const { mockEsmWithActual } = createMockUtils(jest); +const mockEnvSetValues = new GlobalValues(); await mockEsmWithActual('#src/env-set/index.js', () => ({ EnvSet: { get values() { - return new GlobalValues(); + return mockEnvSetValues; }, }, AdminApps: { Console: 'console', Welcome: 'welcome' }, @@ -188,3 +189,51 @@ describe('koaSecurityHeaders() middleware — experience CSP', () => { expect(queries.signInExperiences.findDefaultSignInExperience).not.toHaveBeenCalled(); }); }); + +describe('koaSecurityHeaders() middleware — production admin CSP selection', () => { + const { isProduction } = mockEnvSetValues; + + beforeEach(() => { + // eslint-disable-next-line @silverhand/fp/no-mutating-assign -- Toggle production mode for CSP selection tests. + Object.assign(mockEnvSetValues, { isProduction: true }); + }); + + afterEach(() => { + // eslint-disable-next-line @silverhand/fp/no-mutating-assign -- Restore the shared environment after each test. + Object.assign(mockEnvSetValues, { isProduction }); + }); + + it.each([ + { path: '/console', mountedApp: 'console' }, + { path: '/welcome', mountedApp: 'welcome' }, + ])('uses the Console CSP for mounted $path routes', async ({ path, mountedApp }) => { + const run = koaSecurityHeaders([mountedApp], 'default'); + const ctx = createMockContext({ method: 'GET', url: path }); + + await run(ctx, koaNoop); + + const scriptSource = getCspDirective(ctx, 'script-src'); + + expect(scriptSource).toContain("'self'"); + expect(scriptSource).toContain('https://cdn.jsdelivr.net/'); + expect(scriptSource).toContain('blob:'); + expect(scriptSource).not.toContain("'unsafe-inline'"); + }); + + it.each([{ path: '/console' }, { path: '/welcome' }])( + 'uses the Experience CSP for unmounted $path routes', + async ({ path }) => { + const run = koaSecurityHeaders([], 'default'); + const ctx = createMockContext({ method: 'GET', url: path }); + + await run(ctx, koaNoop); + + const scriptSource = getCspDirective(ctx, 'script-src'); + + expect(scriptSource).toContain("'self'"); + expect(scriptSource).toContain("'unsafe-inline'"); + expect(scriptSource).not.toContain('https://cdn.jsdelivr.net/'); + expect(scriptSource).not.toContain('blob:'); + } + ); +}); diff --git a/packages/core/src/middleware/koa-security-headers.ts b/packages/core/src/middleware/koa-security-headers.ts index 35de53803ea8..cd691b6e3bc9 100644 --- a/packages/core/src/middleware/koa-security-headers.ts +++ b/packages/core/src/middleware/koa-security-headers.ts @@ -298,8 +298,9 @@ export default function koaSecurityHeaders( // Admin Console if ( - requestPath.startsWith(`/${AdminApps.Console}`) || - requestPath.startsWith(`/${AdminApps.Welcome}`) + (mountedApps.includes(AdminApps.Console) && + requestPath.startsWith(`/${AdminApps.Console}`)) || + (mountedApps.includes(AdminApps.Welcome) && requestPath.startsWith(`/${AdminApps.Welcome}`)) ) { await helmetPromise(consoleSecurityHeaderSettings, req, res);