diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md index 0e38ac2e..ad0514e6 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -31,6 +31,8 @@ The command does not need a profile. It serves your dev server over HTTPS with a Published apps block inline scripts, and so does the Local preview by default. Some dev servers inject one, such as React's Fast Refresh preamble, and the app stays blank without it. Pass `--allow-inline-scripts` to allow them: the command adds a hash of each inline script to the page's `script-src`. Either way it prints a warning for each inline script the first time it sees it. Move any inline script of your own into a separate file before publishing. +The command also adds a small Celonis script as the first element of each HTML page, allowed by its hash. It reports to the Celonis preview that the page loaded, how many inline scripts the preview blocks, and which errors stopped the app, so the Local preview can show what to fix. + ## Print Token Print a fresh token that only grants read access to the Celonis SDK, e.g. to authenticate against the npm registry. diff --git a/src/commands/sdk/preview-app-reporter.ts b/src/commands/sdk/preview-app-reporter.ts new file mode 100644 index 00000000..83dead2b --- /dev/null +++ b/src/commands/sdk/preview-app-reporter.ts @@ -0,0 +1,83 @@ +import { createHash } from "node:crypto"; + +/** + * Copied from the value of `SANDBOX_APP_REPORTER` in ems-frontend `libs/common/fragments/sandbox/src/lib/app-reporter.ts`. + * The preview injects it into every proxied HTML page and allows it by {@link APP_REPORTER_HASH}, so the Celonis + * preview can tell that the page loaded and why the app stopped. Its report messages are the contract with + * ems-frontend; the spec checks them. + */ +export const APP_REPORTER_SCRIPT = `(function sandboxAppReporter(reportType, configId, installObserver) { + if (parent === window) return () => void 0; + const post = (report) => parent.postMessage(Object.assign({ type: reportType }, report), "*"); + const text = (value) => value.slice(0, 500); + let blockedInlineScripts = 0; + try { + const config = JSON.parse(document.getElementById(configId)?.textContent ?? "{}"); + const count = config.blockedInlineScripts; + if (typeof count === "number" && Number.isInteger(count) && count >= 0) blockedInlineScripts = count; + } catch { + } + post({ kind: "loaded", blockedInlineScripts }); + let problems = 0; + const problem = (report) => { + if (problems++ < 10) post(report); + }; + return installObserver({ + onError: ({ name, message }) => problem({ kind: "error", name: text(name), message: text(message) }), + onViolation: ({ directive, blocked }) => problem({ kind: "csp", directive: text(directive), blocked: text(blocked) }) + }); +})("ems-fragment-app-report-v1", "ems-fragment-app-report-config", function installErrorObserver(options) { + let active = true; + const describe = (error, fallback) => { + const fields = typeof error === "object" && error !== null ? error : {}; + return { + name: typeof fields.name === "string" && fields.name ? fields.name : "Error", + message: typeof fields.message === "string" && fields.message ? fields.message : String(fallback ?? "") + }; + }; + const reportError = (event) => { + if (active) options.onError(describe(event.error, event.message)); + }; + const reportRejection = (event) => { + if (active) options.onError(describe(event.reason, event.reason)); + }; + const reportViolation = (event) => { + if (active) options.onViolation({ directive: event.effectiveDirective, blocked: event.blockedURI }); + }; + window.addEventListener("error", reportError); + window.addEventListener("unhandledrejection", reportRejection); + window.addEventListener("securitypolicyviolation", reportViolation); + return () => { + if (!active) return; + active = false; + window.removeEventListener("error", reportError); + window.removeEventListener("unhandledrejection", reportRejection); + window.removeEventListener("securitypolicyviolation", reportViolation); + }; +});`; + +/** Id of the element the reporter reads `{blockedInlineScripts}` from. */ +export const APP_REPORTER_CONFIG_ID = "ems-fragment-app-report-config"; + +/** `'sha256-'` of {@link APP_REPORTER_SCRIPT}, ready for a CSP source list. */ +export const APP_REPORTER_HASH = `'sha256-${createHash("sha256").update(APP_REPORTER_SCRIPT, "utf8").digest("base64")}'`; + +const HEAD_TAG = /]*>/i; +const HTML_TAG = /]*>/i; + +/** + * Inserts the reporter and its config as the first elements of the page's ``, so it runs before any app + * script. Without a `` it goes right after ``, and without either at the very start. + */ +export function injectAppReporter(html: string, blockedInlineScripts: number): string { + const config = JSON.stringify({ blockedInlineScripts }); + const reporter = + `` + + ``; + const tag = HEAD_TAG.exec(html) ?? HTML_TAG.exec(html); + if (!tag) { + return reporter + html; + } + const end = tag.index + tag[0].length; + return html.slice(0, end) + reporter + html.slice(end); +} diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index cca68f30..53b7cc30 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -5,6 +5,7 @@ import { Duplex } from "node:stream"; import * as tls from "node:tls"; import { FatalError, logger } from "../../core/utils/logger"; import { PreviewCertificate } from "./preview-certificate"; +import { APP_REPORTER_HASH, injectAppReporter } from "./preview-app-reporter"; import { allowInlineScripts, inlineScriptHashes } from "./preview-inline-scripts"; export interface PreviewProxyOptions { @@ -90,14 +91,16 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise script.hash)), + ...(csp === undefined ? {} : { + "content-security-policy": allowInlineScripts(csp, [ + APP_REPORTER_HASH, + ...(allowInline ? scripts.map(script => script.hash) : []), + ]), }), }); res.end(body); diff --git a/tests/commands/sdk/preview-app-reporter.spec.ts b/tests/commands/sdk/preview-app-reporter.spec.ts new file mode 100644 index 00000000..acc3ec20 --- /dev/null +++ b/tests/commands/sdk/preview-app-reporter.spec.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; +import { + APP_REPORTER_CONFIG_ID, + APP_REPORTER_HASH, + APP_REPORTER_SCRIPT, + injectAppReporter, +} from "../../../src/commands/sdk/preview-app-reporter"; + +const injected = (count: number) => + `` + + ``; + +describe("Preview app reporter", () => { + it("Should hash its exact text for the preview's script-src", () => { + expect(APP_REPORTER_HASH).toBe( + `'sha256-${createHash("sha256").update(APP_REPORTER_SCRIPT).digest("base64")}'`, + ); + }); + + it("Should post the report messages the Celonis preview expects", () => { + // If this fails, copy SANDBOX_APP_REPORTER from ems-frontend again; the messages are the contract. + const parent = { postMessage: jest.fn() }; + const listeners = new Map void>(); + const window = { + addEventListener: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener), + removeEventListener: (name: string) => listeners.delete(name), + }; + const document = { + getElementById: (id: string) => + id === APP_REPORTER_CONFIG_ID ? { textContent: "{\"blockedInlineScripts\":2}" } : null, + }; + new Function("window", "parent", "document", APP_REPORTER_SCRIPT)(window, parent, document); + + listeners.get("error")({ message: "Uncaught ", error: { name: "SecurityError", message: "Denied" } }); + listeners.get("unhandledrejection")({ reason: "x".repeat(600) }); + listeners.get("securitypolicyviolation")({ effectiveDirective: "connect-src", blockedURI: "https://api.example" }); + + expect(parent.postMessage.mock.calls.map(([message]) => message)).toEqual([ + { type: "ems-fragment-app-report-v1", kind: "loaded", blockedInlineScripts: 2 }, + { type: "ems-fragment-app-report-v1", kind: "error", name: "SecurityError", message: "Denied" }, + { type: "ems-fragment-app-report-v1", kind: "error", name: "Error", message: "x".repeat(500) }, + { type: "ems-fragment-app-report-v1", kind: "csp", directive: "connect-src", blocked: "https://api.example" }, + ]); + }); + + it("Should insert the reporter and its config as the first elements of ", () => { + const html = ""; + + expect(injectAppReporter(html, 2)).toBe( + `${injected(2)}`, + ); + }); + + it("Should insert the reporter after when the page has no ", () => { + expect(injectAppReporter("App", 0)).toBe( + `${injected(0)}App`, + ); + }); + + it("Should insert the reporter at the start of a page without and ", () => { + expect(injectAppReporter("
App
", 1)).toBe(`${injected(1)}
App
`); + }); +}); diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts index 8d85b8c1..a28a9538 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -4,6 +4,8 @@ import * as https from "node:https"; import * as net from "node:net"; import { Duplex } from "node:stream"; import { loadOrCreatePreviewCertificate, PreviewCertificate } from "../../../src/commands/sdk/preview-certificate"; +import { APP_REPORTER_HASH, injectAppReporter } from "../../../src/commands/sdk/preview-app-reporter"; +import { allowInlineScripts } from "../../../src/commands/sdk/preview-inline-scripts"; import { LOCAL_PREVIEW_HEADERS } from "../../../src/commands/sdk/preview-headers"; import { PreviewProxy, PreviewProxyOptions, startPreviewProxy } from "../../../src/commands/sdk/preview-proxy"; import { loggingTestTransport } from "../../jest.setup"; @@ -143,16 +145,19 @@ describe("Preview proxy", () => { const response = await send(proxy, "/"); - expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); + expect(response.headers["content-security-policy"]) + .toBe(allowInlineScripts(LOCAL_PREVIEW_HEADERS["content-security-policy"], [APP_REPORTER_HASH])); expect(response.headers["content-security-policy-report-only"]).toBeUndefined(); expect(response.headers["x-frame-options"]).toBeUndefined(); expect(response.headers["access-control-allow-origin"]).toBe("*"); expect(response.headers["content-type"]).toBe("text/html"); for (const [name, value] of Object.entries(LOCAL_PREVIEW_HEADERS)) { expect(headerCount(response.rawHeaders, name)).toBe(1); - expect(response.headers[name]).toBe(value); + if (name !== "content-security-policy") { + expect(response.headers[name]).toBe(value); + } } - expect(response.body).toBe(""); + expect(response.body).toBe(injectAppReporter("", 0)); }); it("Should add the preview headers to responses without any headers of their own", async () => { @@ -328,9 +333,11 @@ describe("Preview proxy", () => { const second = await send(proxy, "/index.html"); for (const response of [first, second]) { - expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); - expect(response.body).toBe(reactPage); - expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(reactPage))); + expect(response.headers["content-security-policy"]) + .toBe(allowInlineScripts(LOCAL_PREVIEW_HEADERS["content-security-policy"], [APP_REPORTER_HASH])); + expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH]); + expect(response.body).toBe(injectAppReporter(reactPage, 1)); + expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(injectAppReporter(reactPage, 1)))); } expect(warnings()).toEqual([ `Blocking an inline script in /index.html: "${reactPreamble}". Inline scripts are disabled for published apps. ` + @@ -347,11 +354,11 @@ describe("Preview proxy", () => { const second = await send(proxy, "/index.html"); for (const response of [first, second]) { - expect(scriptSrc(response)).toEqual(["'self'", sha256(inline)]); + expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH, sha256(inline)]); expect((response.headers["content-security-policy"] as string).replace(` ${sha256(inline)}`, "")) - .toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); - expect(response.body).toBe(html); - expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(html))); + .toBe(allowInlineScripts(LOCAL_PREVIEW_HEADERS["content-security-policy"], [APP_REPORTER_HASH])); + expect(response.body).toBe(injectAppReporter(html, 0)); + expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(injectAppReporter(html, 0)))); expect(response.headers["content-type"]).toBe("text/html; charset=utf-8"); } expect(warnings()).toEqual([ @@ -370,8 +377,8 @@ describe("Preview proxy", () => { const response = await send(proxy, "/"); - expect(scriptSrc(response)).toEqual(["'self'", sha256("a()")]); - expect(response.body).toBe(""); + expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH, sha256("a()")]); + expect(response.body).toBe(injectAppReporter("", 0)); expect(response.headers["content-length"]).toBeUndefined(); }); @@ -431,7 +438,7 @@ describe("Preview proxy", () => { const next = await send(proxy, "/"); expect(next.status).toBe(200); - expect(next.body).toBe(""); + expect(next.body).toBe(injectAppReporter("", 0)); expect(warnings()).toEqual([]); });