Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .changeset/gentle-otters-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@logto/core": patch
---

fix a flash of built-in styles on the hosted sign-in experience when custom CSS is configured

Custom CSS was injected on the client via react-helmet, which mutates `<head>` asynchronously after the page had already painted with the built-in styles. The server-rendered experience HTML now inlines the configured custom CSS into `<head>`, so it is part of the cascade on the first paint. The `</style>` sequence in custom CSS is escaped so it cannot terminate the style element early, and the SSR data embedded in the inline `<script>` is now serialized with HTML-significant characters escaped to prevent script breakout.
162 changes: 157 additions & 5 deletions packages/core/src/middleware/koa-experience-ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,163 @@ describe('koaExperienceSsr()', () => {
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);
expect(next).toHaveBeenCalledTimes(1);
expect(ctx.body).not.toContain(ssrPlaceholder);
expect(ctx.body).toContain(
`const logtoSsr=Object.freeze(${JSON.stringify({
signInExperience: { data: mockSignInExperience },
phrases: { lng: 'en', data: phrases },
})});`
expect(ctx.body).toContain('const logtoSsr=Object.freeze(');

// Extract and parse the injected JSON rather than comparing against a bare `JSON.stringify`, which
// would diverge from `serializeSsrData`'s `<`/`>`/`&` escaping the moment the mock gains such a char.
// Anchor on the trailing `);` so the greedy capture stops at the genuine `Object.freeze(...)` close.
const serialized = /Object\.freeze\((?<json>.+)\);/.exec(ctx.body)?.groups?.json;
expect(serialized).toBeTruthy();
expect(JSON.parse(serialized!)).toEqual({
signInExperience: { data: mockSignInExperience },
phrases: { lng: 'en', data: phrases },
});
Comment on lines +75 to +83
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4cc1db3. Anchored the capture on the trailing ); (/Object\.freeze\((?<json>.+)\);/) so the greedy match stops at the genuine Object.freeze(...) close, and added expect(serialized).toBeTruthy() before the non-null assertion so a regex miss fails with a clear message instead of an opaque JSON.parse(undefined). Applied the same to the other extraction site for consistency.

});

it('should inline custom CSS into the <head> when present', async () => {
(tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce(
{ ...mockSignInExperience, customCss: '.foo { color: red; }' }
);

const ctx = {
...baseCtx,
path: '/',
body: `<head><script>const logtoSsr=${ssrPlaceholder};</script></head>`,
};
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);

expect(ctx.body).toContain('<style data-custom-css>.foo { color: red; }</style></head>');
});

it('should escape the `</style>` sequence in custom CSS to avoid breaking out of the tag', async () => {
(tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce(
{
...mockSignInExperience,
customCss: 'body::before { content: "</style>"; }',
}
);

const ctx = {
...baseCtx,
path: '/',
body: `<head><script>const logtoSsr=${ssrPlaceholder};</script></head>`,
};
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);

// The dangerous `</style` becomes `<\/style`, which the HTML parser cannot treat as an end tag.
expect(ctx.body).toContain('content: "<\\/style>"');
});

// The regex matches the `</style` prefix without requiring the closing `>`, so every end-tag variant
// the HTML parser accepts — uppercase, or whitespace before `>` — is defused the same way.
it.each(['</STYLE>', '</style >', '</style\n>'])(
'should escape the `%s` end-tag variant in custom CSS',
async (variant) => {
(
tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock
).mockResolvedValueOnce({
...mockSignInExperience,
customCss: `body::before { content: "${variant}"; }`,
});

const ctx = {
...baseCtx,
path: '/',
body: `<head><script>const logtoSsr=${ssrPlaceholder};</script></head>`,
};
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);

// `</style`/`</STYLE` is broken to `<\/...`; the literal text after it (space/newline/`>`) is intact.
expect(ctx.body).toContain(`content: "${variant.replace(/<\/(style)/i, '<\\/$1')}"`);
expect(ctx.body).not.toMatch(/content: "<\/(?:style|STYLE)/);
}
);

it('should escape characters in the SSR JSON so embedded data cannot break out of the <script>', async () => {
(tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce(
{ ...mockSignInExperience, customContent: { '/sign-in': '</script>' } }
);

const ctx = {
...baseCtx,
path: '/',
body: `<head><script>const logtoSsr=${ssrPlaceholder};</script></head>`,
};
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);

// The `</script>` carried in the SSR data must be emitted as `\u003c/script\u003e`,
// never as a literal tag that would close the inline `window.logtoSsr` <script> early. Asserting the
// escaped form (rather than counting `</script>` occurrences) is robust to the served template adding
// its own <script> tags.
expect(ctx.body).toContain('\\u003c/script\\u003e');
});

it('should produce SSR JSON that still parses back to the original data after escaping', async () => {
(tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce(
{ ...mockSignInExperience, customContent: { '/sign-in': '<a>&</a>' } }
);

const ctx = {
...baseCtx,
path: '/',
body: `<head><script>const logtoSsr=${ssrPlaceholder};</script></head>`,
};
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);

// The `\uXXXX` escapes must decode back to the original characters when parsed, so the escaping is
// safe (no data corruption) while still preventing tag breakout.
const serialized = /Object\.freeze\((?<json>.+)\);/.exec(ctx.body)?.groups?.json;
expect(serialized).toBeTruthy();

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test parses known JSON
const parsed = JSON.parse(serialized!);
expect(parsed.signInExperience.data.customContent['/sign-in']).toBe('<a>&</a>');
});

it('should escape U+2028/U+2029 so the inline `Object.freeze(...)` expression stays parseable', async () => {
// U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are valid in JSON but are line
// terminators in a JS string literal, so left literal they would break the embedded expression.
// Build the value from code points so this source file stays pure ASCII.
const original = `a${String.fromCodePoint(0x20_28)}b${String.fromCodePoint(0x20_29)}c`;
(tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce(
{ ...mockSignInExperience, customContent: { '/sign-in': original } }
);

const ctx = {
...baseCtx,
path: '/',
body: `<head><script>const logtoSsr=${ssrPlaceholder};</script></head>`,
};
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);

// The literal separators must not survive in the emitted source, but their escapes must.
expect(ctx.body).not.toContain(String.fromCodePoint(0x20_28));
expect(ctx.body).not.toContain(String.fromCodePoint(0x20_29));
expect(ctx.body).toContain('\\u2028');
expect(ctx.body).toContain('\\u2029');

// The escaped form must still decode back to the original once parsed.
const serialized = /Object\.freeze\((?<json>.+)\);/.exec(ctx.body)?.groups?.json;
expect(serialized).toBeTruthy();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test parses known JSON
const parsed = JSON.parse(serialized!);
expect(parsed.signInExperience.data.customContent['/sign-in']).toBe(original);
});

it('should not inline custom CSS in preview mode', async () => {
(tenant.libraries.signInExperiences.getFullSignInExperience as jest.Mock).mockResolvedValueOnce(
{ ...mockSignInExperience, customCss: '.foo { color: red; }' }
);

const ctx = {
...baseCtx,
path: '/',
query: { preview: 'true' },
body: `<head><script>const logtoSsr=${ssrPlaceholder};</script></head>`,
};
await koaExperienceSsr(tenant.libraries, tenant.queries)(ctx, next);

// Preview is driven live by postMessage + react-helmet; the server must not inline saved CSS.
expect(ctx.body).not.toContain('<style data-custom-css>');
});
});
41 changes: 38 additions & 3 deletions packages/core/src/middleware/koa-experience-ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ import { getExperienceLanguage } from '#src/utils/i18n.js';
import { type WithI18nContext } from './koa-i18next.js';
import { isIndexPath } from './koa-serve-static.js';

/**
* Serialize SSR data for safe embedding inside an inline `<script>`. `JSON.stringify` alone is unsafe:
* a string such as `</script>` in the data (e.g. inside custom CSS or custom content) would close the
* script element early and enable injection. Escaping the HTML-significant characters keeps the values
* identical once parsed by JS, while preventing the HTML parser from recognizing any tag delimiters.
*/
const serializeSsrData = (data: SsrData): string =>
JSON.stringify(data)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026')
// U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are valid inside JSON strings but are
// line terminators in a JavaScript string literal (pre-ES2019). Since this payload is embedded as a
// JS expression (`Object.freeze(...)`), leaving them literal can break parsing in older engines.
// Escape to their `\uXXXX` form, which JS decodes back to the original characters.
.replaceAll('\u2028', '\\u2028') // U+2028 LINE SEPARATOR
.replaceAll('\u2029', '\\u2029'); // U+2029 PARAGRAPH SEPARATOR

/**
* Create a middleware to prefetch the experience data and inject it into the HTML response. Some
* conditions must be met:
Expand Down Expand Up @@ -54,15 +72,32 @@ export default function koaExperienceSsr<StateT, ContextT extends WithI18nContex
const phrases = await libraries.phrases.getPhrases(language);

ctx.set('Content-Language', language);
ctx.body = ctx.body.replace(

// Inline custom CSS into <head> on the first byte so it is in the cascade for the first paint,
// removing the flash before react-helmet injects the same <style> client-side. Done BEFORE the SSR
// placeholder substitution so the `</head>` match can only hit the genuine document head. Skipped in
// preview mode (`?preview=true`), where the console iframe drives styling live via postMessage and
// inlining the *saved* CSS could leak rules being edited. `</style` is defused so admin CSS can't
// terminate the element early (parser sees `<\/style`; the CSS engine unescapes `\/`→`/`). See PR #8917.
const { customCss } = signInExperience;

const htmlWithCss =
customCss && ctx.query.preview !== 'true'
? ctx.body.replace(
'</head>',
`<style data-custom-css>${customCss.replaceAll(/<\/(style)/gi, '<\\/$1')}</style></head>`
)
: ctx.body;
Comment on lines +84 to +90
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentionally leaving this as a literal </head> match. The closing tag comes from our own build template (the Vite/SSR HTML), not from any user-controlled input, so its casing/formatting will not change unexpectedly — and if the template ever did change, the integration test (expect(html).toContain('data-custom-css')) would fail immediately and loudly rather than silently regressing. A case-insensitive regex would add the "replace first match only" footgun without protecting against a realistic failure mode here. Happy to revisit if the template ever becomes dynamic.


ctx.body = htmlWithCss.replace(
ssrPlaceholder,
`Object.freeze(${JSON.stringify({
`Object.freeze(${serializeSsrData({
signInExperience: {
...pick(logtoUiCookie, 'appId', 'organizationId'),
data: signInExperience,
},
phrases: { lng: language, data: phrases },
} satisfies SsrData)})`
})})`
);
};
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { demoAppApplicationId, fullSignInExperienceGuard } from '@logto/schemas';
import { z } from 'zod';

import { demoAppUrl } from '#src/constants.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { demoAppUrl, logtoUrl } from '#src/constants.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
import { Trace } from '#src/ui-helpers/trace.js';
Expand Down Expand Up @@ -108,4 +109,48 @@ describe('server-side rendering', () => {
// Check network requests
await expectTraceNotToHaveWellKnownEndpoints();
});

describe('custom CSS inlining', () => {
// A marker the server adds (`<style data-custom-css>`) but the client-side react-helmet `<style>`
// does not, so its presence in the served HTML proves the CSS was inlined server-side for the first
// paint rather than injected later by the client.
const customCss = '.ssr-custom-css-probe { color: rgb(1, 2, 3); }';

afterEach(async () => {
await updateSignInExperience({ customCss: null });
});

it('should inline custom CSS into the served <head> so it applies on the first paint', async () => {
await updateSignInExperience({ customCss });
const experience = new ExpectExperience(await browser.newPage());
try {
await experience.navigateTo(demoAppUrl.href);

const html = await experience.page.content();
expect(html).toContain('data-custom-css');
expect(html).toContain(customCss);
} finally {
// Close in `finally` so a failed assertion/navigation does not leak the page across the suite.
await experience.page.close();
}
});
Comment on lines +123 to +136
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — fixed in 4cc1db3. Both new custom-CSS tests now run their body in try and await experience.page.close() in finally, so a failed assertion/navigation no longer leaks the page. (The pre-existing tests above also do not close their pages, but that is out of scope for this PR.)


it('should not inline custom CSS in preview mode', async () => {
await updateSignInExperience({ customCss });
const experience = new ExpectExperience(await browser.newPage());
try {
// Hit the experience entry directly with `?preview=true`, mirroring how the console preview
// iframe loads it. Going through the demo app instead would OIDC-redirect to `/sign-in` and
// drop the query param, so the server would never see preview mode.
await experience.navigateTo(new URL('/sign-in?preview=true', logtoUrl).href);

// Preview is driven live by the console iframe via postMessage; the server must not inline saved CSS.
const html = await experience.page.content();
expect(html).not.toContain('data-custom-css');
} finally {
// Close in `finally` so a failed assertion/navigation does not leak the page across the suite.
await experience.page.close();
}
});
});
});
7 changes: 6 additions & 1 deletion packages/integration-tests/src/ui-helpers/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ export class Trace {

async cleanup() {
if (this.tracePath) {
await fs.unlink(this.tracePath);
// Clear the path first so a repeated cleanup (e.g. a later test in the same suite that never
// started a trace) is a no-op instead of unlinking an already-removed file. `force: true` makes
// a missing file a no-op too, while still surfacing real FS errors instead of swallowing them.
const { tracePath } = this;
this.tracePath = undefined;
await fs.rm(tracePath, { force: true });
}
}
}
Loading