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
2 changes: 2 additions & 0 deletions docs/user-guide/sdk-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions src/commands/sdk/preview-app-reporter.ts
Original file line number Diff line number Diff line change
@@ -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-<base64>'` 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 = /<head\b[^>]*>/i;
const HTML_TAG = /<html\b[^>]*>/i;

/**
* Inserts the reporter and its config as the first elements of the page's `<head>`, so it runs before any app
* script. Without a `<head>` it goes right after `<html>`, and without either at the very start.
*/
export function injectAppReporter(html: string, blockedInlineScripts: number): string {
const config = JSON.stringify({ blockedInlineScripts });
const reporter =
`<script type="application/json" id="${APP_REPORTER_CONFIG_ID}">${config}</script>` +
`<script>${APP_REPORTER_SCRIPT}</script>`;
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);
}
14 changes: 10 additions & 4 deletions src/commands/sdk/preview-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -90,23 +91,28 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise<Preview
if (res.destroyed) {
return;
}
const body = Buffer.concat(chunks);
const scripts = inlineScriptHashes(body.toString("utf8"));
const html = Buffer.concat(chunks).toString("utf8");
const scripts = inlineScriptHashes(html);
for (const { hash, excerpt } of scripts) {
if (!warnedInlineScripts.has(hash)) {
warnedInlineScripts.add(hash);
logger.warn(inlineScriptWarning(req.url, excerpt, allowInline));
}
}
// The reporter tells the Celonis preview that the page loaded and which inline scripts it blocks.
const body = Buffer.from(injectAppReporter(html, allowInline ? 0 : scripts.length), "utf8");
if (proxyRes.headers["content-length"] !== undefined) {
responseHeaders["content-length"] = body.length;
}
const csp = headers["content-security-policy"];
res.writeHead(proxyRes.statusCode, {
...responseHeaders,
...headers,
...(csp === undefined || !allowInline ? {} : {
"content-security-policy": allowInlineScripts(csp, scripts.map(script => script.hash)),
...(csp === undefined ? {} : {
"content-security-policy": allowInlineScripts(csp, [
APP_REPORTER_HASH,
...(allowInline ? scripts.map(script => script.hash) : []),
]),
}),
});
res.end(body);
Expand Down
63 changes: 63 additions & 0 deletions tests/commands/sdk/preview-app-reporter.spec.ts
Original file line number Diff line number Diff line change
@@ -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) =>
`<script type="application/json" id="${APP_REPORTER_CONFIG_ID}">{"blockedInlineScripts":${count}}</script>` +
`<script>${APP_REPORTER_SCRIPT}</script>`;

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<string, (event: unknown) => 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 <head>", () => {
const html = "<!doctype html><html lang=\"en\"><HEAD data-x=\"1\"><script>a()</script></HEAD><body></body></html>";

expect(injectAppReporter(html, 2)).toBe(
`<!doctype html><html lang="en"><HEAD data-x="1">${injected(2)}<script>a()</script></HEAD><body></body></html>`,
);
});

it("Should insert the reporter after <html> when the page has no <head>", () => {
expect(injectAppReporter("<html><body>App</body></html>", 0)).toBe(
`<html>${injected(0)}<body>App</body></html>`,
);
});

it("Should insert the reporter at the start of a page without <html> and <head>", () => {
expect(injectAppReporter("<div>App</div>", 1)).toBe(`${injected(1)}<div>App</div>`);
});
});
33 changes: 20 additions & 13 deletions tests/commands/sdk/preview-proxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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("<html></html>");
expect(response.body).toBe(injectAppReporter("<html></html>", 0));
});

it("Should add the preview headers to responses without any headers of their own", async () => {
Expand Down Expand Up @@ -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. ` +
Expand All @@ -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([
Expand All @@ -370,8 +377,8 @@ describe("Preview proxy", () => {

const response = await send(proxy, "/");

expect(scriptSrc(response)).toEqual(["'self'", sha256("a()")]);
expect(response.body).toBe("<html><head><script>a()</script></head></html>");
expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH, sha256("a()")]);
expect(response.body).toBe(injectAppReporter("<html><head><script>a()</script></head></html>", 0));
expect(response.headers["content-length"]).toBeUndefined();
});

Expand Down Expand Up @@ -431,7 +438,7 @@ describe("Preview proxy", () => {
const next = await send(proxy, "/");

expect(next.status).toBe(200);
expect(next.body).toBe("<html></html>");
expect(next.body).toBe(injectAppReporter("<html></html>", 0));
expect(warnings()).toEqual([]);
});

Expand Down
Loading