feat: accept a base64-encoded Cloud UI snippet (#13245)
## 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 `</body>` 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 `<script` markup > - 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 `<script` as an injection attempt and block it. Verified against Railway's GraphQL API, which sits behind Cloudflare: a variable value with a bare `<script></script>` returns HTTP 403 before authentication, and JSON unicode escapes (`<script`) do not bypass the block. The snippet is exactly the kind of value that always contains a script tag, so such pipelines cannot deliver it at all. **Proposed behavior** A new optional `PAPERCLIP_CLOUD_UI_SNIPPET_B64` carries the same snippet as standard base64 of the UTF-8 HTML. The server decodes it and injects the result through the existing path. The plain variable wins when both are set. Whitespace and line wrapping in the value are tolerated. A value that is not canonical base64, or that decodes to blank, is ignored instead of injected as garbage. **Reason and benefit** Operators can deliver the snippet through WAF-fronted APIs without requesting firewall exceptions. Base64 contains no markup, so the firewall has nothing to match. Existing deployments see no behavior change. **Breaking changes** None. The new variable is optional. The existing variable is unchanged and takes precedence. ## What Changed - `server/src/cloud-ui-snippet.ts`: resolve the snippet from `PAPERCLIP_CLOUD_UI_SNIPPET` first, then from base64-decoded `PAPERCLIP_CLOUD_UI_SNIPPET_B64`; ignore non-canonical or blank-decoding values. - `server/src/__tests__/cloud-ui-snippet.test.ts`: cover decode-and-inject, plain-wins precedence, whitespace tolerance, invalid/blank values, and the self-hosted no-op. - `doc/cloud-ui-snippet.md`: document the variant, how to produce the value, and the ignore rules. ## Verification - `pnpm vitest run server/src/__tests__/cloud-ui-snippet.test.ts server/src/__tests__/static-index-html.test.ts` — 13 tests pass. - `tsc --noEmit` in `server/` passes. - Manual check: on a Cloud-managed instance, set `PAPERCLIP_CLOUD_UI_SNIPPET_B64="$(base64 < snippet.html)"`, restart, open `/`, and confirm the decoded snippet appears before `</body>`. 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
This commit is contained in:
parent
bc68312327
commit
778ac33308
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { injectCloudUiSnippet } from "../cloud-ui-snippet.js";
|
|||
|
||||
const html = '<html><body><div id="root"></div></body></html>';
|
||||
const snippet = '<script src="https://example.com/widget.js"></script>';
|
||||
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("</body>", `${snippet}\n</body>`));
|
||||
});
|
||||
|
||||
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("</body>", `${snippet}\n</body>`));
|
||||
});
|
||||
|
||||
it("prefers the plain snippet when both variables are set", () => {
|
||||
const other = Buffer.from("<script>other()</script>", "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("<b>x</b>", "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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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</body>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue