diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md index 4787f3b9e0..2d0b591888 100644 --- a/doc/cloud-ui-snippet.md +++ b/doc/cloud-ui-snippet.md @@ -1,83 +1,17 @@ -# Cloud UI snippet +# Retired Cloud UI snippet settings -Cloud operators can set `PAPERCLIP_CLOUD_UI_SNIPPET` to an HTML snippet. -The server inserts it before `` in static and Vite-served UI pages. -It requires the existing Cloud-managed instance signal. Self-hosted instances -ignore this setting. No snippet is enabled by default. +`PAPERCLIP_CLOUD_UI_SNIPPET` and `PAPERCLIP_CLOUD_UI_SNIPPET_B64` are no longer +read by the server. They do not inject HTML or JavaScript into static or dev +pages. This removes an operator-controlled executable HTML path from the app. +Existing ordinary branding and plugin UI contributions are unchanged. -This is trusted deployment configuration, not user input. It executes in the -application origin and is visible to every browser that receives the UI shell. -Do not include secrets or customer data. Restart the app after changing it. -Operators must review scripts and any required CSP changes before deployment. +Before upgrading an installation that uses either setting, move the integration +to a trusted plugin using the supported [plugin UI slots](plugins/PLUGIN_AUTHORING_GUIDE.md). +Plugin UI runs as trusted same-origin code; a plugin is not a sandbox for +untrusted JavaScript. Remove the retired settings from the operator's desired +configuration and verify any provision/restart/wake mechanism cannot restore them. +Refresh existing browser tabs to unload previously injected code. -## Base64 variant - -Delivery pipelines that write env vars through provider APIs can sit behind -web application firewalls that reject values containing raw script markup. -`PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet through them as -standard base64 of the UTF-8 HTML: - -```sh -PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)" -``` - -Whitespace and line wrapping in the value are tolerated. A value that is not -canonical padded base64 of UTF-8 text, or that decodes to blank, is ignored — -if the widget does not appear, check that the value round-trips through -`base64 -d`. A present `PAPERCLIP_CLOUD_UI_SNIPPET` always wins, blank -included: clearing the plain variable to blank disables injection even while -a base64 value is still deployed. Everything else about the snippet is -unchanged. - -Base64 does not defeat every firewall. Some decode the value before matching, -so they reject a base64 snippet whose decoded bytes still contain script -markup. Deliver a bare script body (below) through one of these. - -## Bare script body - -Set the value to the script body alone — the JavaScript with no surrounding -``, which would close the wrapper early. Because the value carries no -` -(function(d) { - var script = d.createElement('script'); - script.src = 'https://chat.cdn-plain.com/index.js'; - script.onload = function() { Plain.init({ appId: 'YOUR_CHAT_APP_ID' }); }; - d.head.appendChild(script); -})(document); - -``` - -No signing secret or Plain API key is required. No Paperclip customer identity -or organization data is passed. Plain manages the anonymous browser session; -there is no Paperclip account-switch integration. Ask users for identifying -information when needed. The existing feedback flag remains unchanged. - -Docs: [Plain chat](https://www.plain.com/docs/product/channels/chat). - -## Verification and rollback - -On staging, open `/`, `/index.html`, and an organization dashboard directly. -Confirm the bubble appears and a test message reaches Plain. Verify the support -reply returns. On a self-hosted instance, confirm no snippet or widget is loaded. -Unset the snippet and restart to remove it on the next page load. Existing open -tabs retain the widget until refreshed. No production deployment is implied. +For rollback, retain the prior image and configuration outside source control. +An older image can still use these settings. Do not activate the old integration +and its replacement together. diff --git a/server/src/__tests__/cloud-ui-snippet.test.ts b/server/src/__tests__/cloud-ui-snippet.test.ts deleted file mode 100644 index 484b8343cb..0000000000 --- a/server/src/__tests__/cloud-ui-snippet.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { injectCloudUiSnippet } from "../cloud-ui-snippet.js"; - -const html = '
'; -const snippet = ''; -const encoded = Buffer.from(snippet, "utf-8").toString("base64"); - -describe("Cloud UI snippet", () => { - it("leaves self-hosted HTML unchanged even when a snippet is configured", () => { - expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET: snippet })).toBe(html); - expect(injectCloudUiSnippet(html, { PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded })).toBe(html); - }); - - it.each([ - { PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "test-token" }, - { PAPERCLIP_MANAGED_CONFIG: "{}" }, - ])("injects only on a configured Cloud instance: %j", (cloud) => { - expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: snippet })) - .toBe(html.replace("", `${snippet}\n`)); - expect(injectCloudUiSnippet(html, cloud)).toBe(html); - expect(injectCloudUiSnippet(html, { ...cloud, PAPERCLIP_CLOUD_UI_SNIPPET: " " })).toBe(html); - }); - - it("preserves literal replacement tokens in operator JavaScript", () => { - const script = ''; - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: script, - }); - expect(result).toContain(script); - expect(result).not.toContain("test-token"); - }); - - it("decodes a base64 snippet on a Cloud instance", () => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html.replace("", `${snippet}\n`)); - }); - - it("tolerates whitespace and line wrapping in the base64 value", () => { - const wrapped = ` ${encoded.slice(0, 20)}\n${encoded.slice(20)}\n`; - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: wrapped, - })).toBe(html.replace("", `${snippet}\n`)); - }); - - it("wraps a bare script body that carries no markup in a `; - expect(injectCloudUiSnippet(html, { PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: body })) - .toBe(html.replace("", `${wrapped}\n`)); - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: Buffer.from(body, "utf-8").toString("base64"), - })).toBe(html.replace("", `${wrapped}\n`)); - }); - - it("preserves literal replacement tokens when wrapping a bare script body", () => { - const body = 'console.log("$&", "$`", "$\'");'; - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET: body, - }); - expect(result).toContain(``); - }); - - it("prefers the plain snippet when both variables are set", () => { - const other = Buffer.from("", "utf-8").toString("base64"); - const result = injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: snippet, - PAPERCLIP_CLOUD_UI_SNIPPET_B64: other, - }); - expect(result).toContain(snippet); - expect(result).not.toContain("other()"); - }); - - it("treats a blank plain variable as disabled even when a base64 value is set", () => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: " ", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html); - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", - PAPERCLIP_CLOUD_UI_SNIPPET: "", - PAPERCLIP_CLOUD_UI_SNIPPET_B64: encoded, - })).toBe(html); - }); - - it.each([ - { label: "invalid characters", value: "!!!not-base64!!!" }, - { label: "wrong length", value: "abcde" }, - { label: "unpadded", value: Buffer.from("x", "utf-8").toString("base64").replace(/=+$/, "") }, - { label: "carrying nonzero padding bits", value: "PB==" }, - { label: "not valid UTF-8 once decoded", value: "/w==" }, - { label: "blank once decoded", value: Buffer.from(" \n ", "utf-8").toString("base64") }, - { label: "blank", value: " " }, - ])("ignores a base64 value that is $label", ({ value }) => { - expect(injectCloudUiSnippet(html, { - PAPERCLIP_MANAGED_CONFIG: "{}", PAPERCLIP_CLOUD_UI_SNIPPET_B64: value, - })).toBe(html); - }); -}); diff --git a/server/src/__tests__/static-index-html.test.ts b/server/src/__tests__/static-index-html.test.ts index 9d21f8dfca..baddb97a76 100644 --- a/server/src/__tests__/static-index-html.test.ts +++ b/server/src/__tests__/static-index-html.test.ts @@ -16,16 +16,19 @@ describe("static SPA fallback HTML", () => { } }); - it("includes the operator snippet only in Cloud-served static HTML", () => { + it("ignores retired snippet settings in managed and self-hosted static HTML", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-html-")); tempDirs.push(dir); fs.writeFileSync(path.join(dir, "index.html"), "App"); vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", ''); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET_B64", Buffer.from('').toString("base64")); vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js"); vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); - expect(readBrandedStaticIndexHtml(dir)).toContain('chat.js">\n'); + expect(readBrandedStaticIndexHtml(dir)).not.toContain("chat.js"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", undefined); + expect(readBrandedStaticIndexHtml(dir)).not.toContain("legacy.js"); }); it("serves the current index.html instead of reusing stale asset hashes", async () => { diff --git a/server/src/__tests__/vite-html-renderer.test.ts b/server/src/__tests__/vite-html-renderer.test.ts index 46b065f913..937c371cc6 100644 --- a/server/src/__tests__/vite-html-renderer.test.ts +++ b/server/src/__tests__/vite-html-renderer.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyUiBranding } from "../ui-branding.js"; import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js"; function createWatcher() { @@ -27,11 +28,37 @@ describe("createCachedViteHtmlRenderer", () => { const tempDirs: string[] = []; afterEach(() => { + vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); + it("ignores retired snippet settings in branded development HTML", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); + tempDirs.push(tempDir); + fs.writeFileSync(path.join(tempDir, "index.html"), "App"); + vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", "{}"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", ''); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET_B64", Buffer.from('').toString("base64")); + const renderer = createCachedViteHtmlRenderer({ + vite: { watcher: createWatcher(), transformIndexHtml: async (_url, html) => html }, + uiRoot: tempDir, + brandHtml: applyUiBranding, + }); + try { + const html = await renderer.render("/"); + expect(html).toContain("App"); + expect(html).not.toContain("legacy-plain.js"); + expect(html).not.toContain("legacy-encoded.js"); + vi.stubEnv("PAPERCLIP_CLOUD_UI_SNIPPET", undefined); + + expect(await renderer.render("/issues")).not.toContain("legacy-encoded.js"); + } finally { + renderer.dispose(); + } + }); + it("caches the branded template until index.html changes while transforming every request", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-")); tempDirs.push(tempDir); diff --git a/server/src/app.ts b/server/src/app.ts index 13b56381da..439350c09b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -120,7 +120,6 @@ import { adapterRoutes } from "./routes/adapters.js"; import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js"; import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; -import { injectCloudUiSnippet } from "./cloud-ui-snippet.js"; import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { staticUiCacheControl } from "./static-ui-cache.js"; import { applyUiBranding } from "./ui-branding.js"; @@ -1092,7 +1091,7 @@ export async function createApp( viteHtmlRenderer = createCachedViteHtmlRenderer({ vite, uiRoot, - brandHtml: (html) => injectCloudUiSnippet(applyUiBranding(html)), + brandHtml: applyUiBranding, }); const renderViteHtml = viteHtmlRenderer; diff --git a/server/src/cloud-ui-snippet.ts b/server/src/cloud-ui-snippet.ts deleted file mode 100644 index 2d3e39454f..0000000000 --- a/server/src/cloud-ui-snippet.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { isCloudManagedInstance, type CloudInstanceEnv } from "./services/cloud-instance.js"; - -/** Trusted operator HTML only. This content is public and runs in the app origin. */ -export function injectCloudUiSnippet(html: string, env: CloudInstanceEnv = process.env): string { - const snippet = resolveCloudUiSnippet(env); - if (!isCloudManagedInstance(env) || !snippet) return html; - return html.replace(/<\/body>/i, () => `${snippet}\n`); -} - -/** - * A present plain variable always wins — blank included, so clearing it to - * blank disables injection even when a base64 value is still deployed. The - * base64 variant exists because delivery pipelines that write env vars - * through provider APIs can sit behind web application firewalls that - * reject values containing raw script markup; base64 carries the same - * snippet through them unchanged. - */ -function resolveCloudUiSnippet(env: CloudInstanceEnv): string | null { - const plain = env.PAPERCLIP_CLOUD_UI_SNIPPET; - if (plain !== undefined) return asInjectableMarkup(plain); - const encoded = env.PAPERCLIP_CLOUD_UI_SNIPPET_B64?.replace(/\s+/g, ""); - if (!encoded) return null; - const decoded = decodeBase64(encoded); - return decoded !== null ? asInjectableMarkup(decoded) : null; -} - -/** - * A configured value that already looks like markup (it starts with `<`) is - * injected verbatim, preserving the original bytes. A value that does not is - * treated as a bare script body and wrapped in a ``; -} - -/** - * A value that is not canonical, padded base64 of valid UTF-8 is ignored - * rather than injected as garbage: the round trip rejects stray padding - * bits, and the fatal decoder rejects byte sequences that are not UTF-8. - */ -function decodeBase64(encoded: string): string | null { - if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return null; - const bytes = Buffer.from(encoded, "base64"); - if (bytes.toString("base64") !== encoded) return null; - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return null; - } -} diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts index 7bbc16f4c1..13fa592c09 100644 --- a/server/src/static-index-html.ts +++ b/server/src/static-index-html.ts @@ -1,8 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { injectCloudUiSnippet } from "./cloud-ui-snippet.js"; import { applyUiBranding } from "./ui-branding.js"; export function readBrandedStaticIndexHtml(uiDist: string): string { - return injectCloudUiSnippet(applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"))); + return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); }