Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clouds-load.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/core": patch
---

fix self-hosted Console entry points when requests are served by the Experience fallback
51 changes: 50 additions & 1 deletion packages/core/src/middleware/koa-security-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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:');
}
);
});
6 changes: 4 additions & 2 deletions packages/core/src/middleware/koa-security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,10 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(

// 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}`))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clarify what you actually observed? Specifically: which URL you requested (core endpoint vs. admin endpoint), and what the response looked like — blank page, 404, or the sign-in page. I'd like to make sure we're fixing the same thing.

The reason I ask is that in the scenarios listed in the description, koaSecurityHeaders isn't the last middleware to set the CSP header. When /console is requested on a tenant that doesn't mount the Console (e.g. localhost:3001/console, which resolves to the default tenant), the request continues down to koaExperienceSecurityHeaders in Tenant.ts. That middleware guards on the same mountedApps list, so it doesn't early-return either — it calls helmet again, and since helmet uses res.setHeader, the Console policy is replaced rather than merged.

Stepping through the chain on current master, production mode, mountedApps = the default tenant's list, GET /console:

[1] after koaSecurityHeaders:
    script-src 'self' https://cdn.jsdelivr.net/ blob:

[2] after koaExperienceSecurityHeaders:
    script-src 'self' 'unsafe-inline' 'unsafe-hashes' https://accounts.google.com/gsi/client ...

So the header that reaches the browser already carries 'unsafe-inline', and the inline SSR bootstrap that sets window.logtoSsr isn't blocked. The two conditions are the same predicate over the same list, so this holds by construction: any request where the Console branch matches wrongly is a request the experience middleware rewrites. That was fixed in #8778 when the experience CSP moved into its own middleware.

If you're still hitting the blank page, that would point at something the above doesn't cover, and I'd rather understand that case first before we change the branch here.

One separate note on the original report: forwarding /console to port 3002 isn't enough on its own. Tenant resolution matches on origin, not port, so unless ADMIN_ENDPOINT is set to the externally visible origin, the request falls to the default tenant and gets the sign-in page rather than the Console.

) {
await helmetPromise(consoleSecurityHeaderSettings, req, res);

Expand Down
Loading