From 778ac33308e720cefd824b38772266a044170e06 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 11 Sep 2026 14:13:11 -0700 Subject: [PATCH] feat: accept a base64-encoded Cloud UI snippet (#13245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip Cloud instances can inject a trusted operator HTML snippet before `` in served UI pages (#13168) > - Operators deliver env vars to managed instances through hosting-provider APIs > - Web application firewalls in front of those APIs reject request bodies that contain raw ` - The snippet value always contains a script tag, so the firewall blocks its delivery every time > - This pull request adds `PAPERCLIP_CLOUD_UI_SNIPPET_B64`, which carries the same snippet as standard base64 > - The benefit is that operators can ship the snippet through WAF-fronted delivery pipelines without firewall exceptions ## Linked Issues or Issue Description Refs #13168 (introduced the Cloud UI snippet). **Subsystem affected** Server UI shell serving: `server/src/cloud-ui-snippet.ts`, used by `static-index-html.ts` and `app.ts`. **Current behavior** `PAPERCLIP_CLOUD_UI_SNIPPET` is the only way to configure the Cloud UI snippet. The value is raw HTML. Delivery pipelines that write env vars through provider APIs can fail to deliver it: web application firewalls classify a request body that contains `` returns HTTP 403 before authentication, and JSON unicode escapes (``. On a self-hosted instance, confirm no snippet is injected. ## Risks Low risk. The injection path and the Cloud-managed gate are unchanged. A malformed base64 value is ignored, so the failure mode is a missing widget, not corrupted HTML. The decoded value is trusted operator configuration, the same trust model as the plain variable. ## Model Used - Claude Fable 5 (Anthropic, `claude-fable-5`) via Claude Code CLI, agentic workflow with tool use (code edits, test runs, and live API verification of the WAF behavior). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- doc/cloud-ui-snippet.md | 19 +++++++ server/src/__tests__/cloud-ui-snippet.test.ts | 53 +++++++++++++++++++ server/src/cloud-ui-snippet.ts | 37 ++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/doc/cloud-ui-snippet.md b/doc/cloud-ui-snippet.md index 28deab517c..e7f0db9ae1 100644 --- a/doc/cloud-ui-snippet.md +++ b/doc/cloud-ui-snippet.md @@ -10,6 +10,25 @@ 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. +## 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. + ## Plain closed beta Set the value to this standard embed, replacing `YOUR_CHAT_APP_ID` with the diff --git a/server/src/__tests__/cloud-ui-snippet.test.ts b/server/src/__tests__/cloud-ui-snippet.test.ts index 8d64f7182b..12c2e0b66f 100644 --- a/server/src/__tests__/cloud-ui-snippet.test.ts +++ b/server/src/__tests__/cloud-ui-snippet.test.ts @@ -3,10 +3,12 @@ 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([ @@ -27,4 +29,55 @@ describe("Cloud UI snippet", () => { 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("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/cloud-ui-snippet.ts b/server/src/cloud-ui-snippet.ts index 41f8732599..65fdb9d8a7 100644 --- a/server/src/cloud-ui-snippet.ts +++ b/server/src/cloud-ui-snippet.ts @@ -2,7 +2,40 @@ import { isCloudManagedInstance, type CloudInstanceEnv } from "./services/cloud- /** 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 = env.PAPERCLIP_CLOUD_UI_SNIPPET; - if (!isCloudManagedInstance(env) || !snippet?.trim()) return html; + 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 plain.trim() ? plain : null; + const encoded = env.PAPERCLIP_CLOUD_UI_SNIPPET_B64?.replace(/\s+/g, ""); + if (!encoded) return null; + const decoded = decodeBase64(encoded); + return decoded?.trim() ? decoded : null; +} + +/** + * 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; + } +}