From da257c3069192b6a7e4afcdc0f5a4fba1c7a6b3e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:24:06 -0500 Subject: [PATCH] Warn when routine webhook URLs may not be publicly reachable (#13684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Routines can start that work when another app sends a webhook. > - Local and private URLs often cannot receive events from public services. > - HTTPS alone does not make a Tailscale address public. > - This pull request explains these limits during setup and editing. > - Users can still finish setup for senders on their own network. ## Linked Issues or Issue Description Refs #13637. Webhook setup needs a clear warning when the generated URL appears local, private, or unencrypted. The warning must explain how to make the endpoint reachable without blocking private-network use. ## What Changed - Add a shared warning banner to the Connect, Check connection, and Edit webhook views. - Distinguish localhost, private network addresses and domains, HTTP, and Tailscale hostnames. - Explain the difference between Tailscale Serve and Funnel. Link to the Paperclip HTTPS guide. - Add five full-page Storybook examples, design guide examples, and documentation. - Add URL classification tests and a regression test that finishes setup despite the warning. ## Verification - Passed 41 focused URL and trigger-flow tests. - Passed workspace typecheck, workspace build, token gates, and Storybook build. - Browser-tested the Tailscale story through Check connection, Finish setup, and Edit webhook. The warning stays visible and does not block setup. - Open Product / Routines / Webhooks stories 11–15 to review the warning states. - All 54 PR checks passed, including the full test matrix and eight browser shards; two optional Storybook jobs were skipped by workflow policy. - The server supervisor readiness test timed out once in CI, then passed on rerun and locally (6 tests). - The duplicate local full-suite run was stopped after the complete CI matrix passed. - Greptile: 5/5 on the current commit, with no unresolved review comments. ## Risks - URL checks are hints. They do not test DNS, firewall rules, or actual reachability. - A Tailscale hostname can serve either private Serve traffic or public Funnel traffic. The warning explains this uncertainty and permits both. - No API, schema, authentication, or webhook delivery behavior changes. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell execution, and browser testing. The exact deployment model ID and context window size are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/design/COMPONENT-INVENTORY.md | 2 + docs/api/routines.md | 2 + .../editable-sections.test.tsx | 16 +++++++ .../routine-triggers/RoutineTriggers.tsx | 2 + .../routine-triggers/TriggerWizard.tsx | 2 + .../routine-triggers/WebhookUrlWarning.tsx | 38 ++++++++++++++++ ui/src/lib/webhook-url-warning.test.ts | 43 +++++++++++++++++++ ui/src/lib/webhook-url-warning.ts | 37 ++++++++++++++++ ui/src/pages/DesignGuide.tsx | 7 +++ ui/storybook/fixtures/routineWebhooks.tsx | 13 +++--- .../stories/routine-webhooks.stories.tsx | 13 ++++++ 11 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 ui/src/components/routine-triggers/WebhookUrlWarning.tsx create mode 100644 ui/src/lib/webhook-url-warning.test.ts create mode 100644 ui/src/lib/webhook-url-warning.ts diff --git a/doc/design/COMPONENT-INVENTORY.md b/doc/design/COMPONENT-INVENTORY.md index 050e08fa2e..84bdccd4ab 100644 --- a/doc/design/COMPONENT-INVENTORY.md +++ b/doc/design/COMPONENT-INVENTORY.md @@ -428,3 +428,5 @@ Independently addressable examples live under `Connections/In-task connections` ## Shared setup wizard (2026-09-19) `ui/src/components/SetupWizard.tsx` extracts the Slack setup navigation into reusable numbered steps, a portal sidebar, an optional section-menu takeover outlet, and a single-row footer. `SetupWizardSidebarProvider` owns the portal target and takeover lifecycle. Chat exports retain their existing names and defaults for compatibility. The Design Guide demonstrates the components. The production routine trigger wizard and its Storybook previews share the sidebar takeover, navigation, and footer. `routine-triggers/TriggerWizard.tsx`, `TriggerCard.tsx`, and `WebhookFields.tsx` provide the shared trigger setup, compact editable cards, copyable credentials, and agent instructions. + +`routine-triggers/WebhookUrlWarning.tsx` uses `InlineBanner` for non-blocking localhost, private-network, Tailscale, and HTTP guidance. Setup and saved webhook editors share it; the Design Guide shows each warning. URL classification is heuristic, not a public reachability test. diff --git a/docs/api/routines.md b/docs/api/routines.md index b430d74a56..6ac4a2d5b4 100644 --- a/docs/api/routines.md +++ b/docs/api/routines.md @@ -298,3 +298,5 @@ Finish setup with `PATCH /api/routine-triggers/{id}` and `{ "setupPending": fals For compatibility, API-created triggers without `setupPending: true` are immediately live. Completed triggers cannot be returned to setup mode. Checking a previously enabled webhook observes real deliveries and can start the routine; the management UI explains this difference. Trigger cards support removal with Undo. `PATCH` with `{ "archived": true }` excludes a trigger from routine detail and scheduling, and rejects its webhook deliveries. Setting `archived` back to `false` restores the same URL and credentials. `DELETE` remains the permanent deletion API. + +The webhook wizard and saved trigger editor warn about localhost, private-network addresses, Tailscale hostnames, and HTTP URLs without blocking setup. HTTPS does not imply public access: Tailscale Serve is private, while Funnel can expose the same hostname publicly. These warnings inspect the URL only; they do not resolve DNS or test internet reachability. Use [the HTTPS setup guide](https://docs.paperclip.ing/reference/deploy/https/) to configure public access when the sender is outside your network. diff --git a/ui/src/components/routine-sections/editable-sections.test.tsx b/ui/src/components/routine-sections/editable-sections.test.tsx index b599399b30..d1fc38be14 100644 --- a/ui/src/components/routine-sections/editable-sections.test.tsx +++ b/ui/src/components/routine-sections/editable-sections.test.tsx @@ -95,6 +95,22 @@ describe("TriggersSection", () => { expect(container.textContent).not.toContain("one-time-secret"); }); + it("warns about private URLs without blocking webhook setup or completion", async () => { + routine.triggers = [{ id: "trigger-1", kind: "webhook", enabled: true, setupPending: true, signingMode: "bearer", webhookUrl: "https://paperclip.internal/webhook" }] as RoutineTrigger[]; + await render(); + await click("Resume setup"); + expect(container.textContent).toContain("This webhook URL appears to be private"); + expect(container.querySelector('a[href="https://docs.paperclip.ing/reference/deploy/https/"]')).not.toBeNull(); + expect(button("Check connection").disabled).toBe(false); + await click("Check connection"); + expect(container.textContent).toContain("This webhook URL appears to be private"); + expect(button("Finish without checking").disabled).toBe(false); + await click("Finish without checking"); + expect(api.updateTrigger).toHaveBeenCalledWith("trigger-1", { setupPending: false }); + await click("Edit webhook"); + expect(container.textContent).toContain("This webhook URL appears to be private"); + }); + it("shows polled connection results even when routine context is stale", async () => { routine.triggers = [{ id: "trigger-1", kind: "webhook", enabled: true, setupPending: true, signingMode: "bearer", webhookUrl: "https://paperclip.example/webhook" }] as RoutineTrigger[]; await render(undefined, routine); diff --git a/ui/src/components/routine-triggers/RoutineTriggers.tsx b/ui/src/components/routine-triggers/RoutineTriggers.tsx index a0ae876f36..d1c5f25468 100644 --- a/ui/src/components/routine-triggers/RoutineTriggers.tsx +++ b/ui/src/components/routine-triggers/RoutineTriggers.tsx @@ -26,6 +26,7 @@ import { type TriggerDraft, } from "./TriggerWizard"; import { AgentInstructions, CopyField } from "./WebhookFields"; +import { WebhookUrlWarning } from "./WebhookUrlWarning"; function readDraft(key: string): TriggerDraft | null { try { @@ -521,6 +522,7 @@ function WebhookSettings({ checkBaseline !== null && delivery && delivery.receivedAt !== checkBaseline; return (
+ {secret && (github || trigger.signingMode === "bearer") && ( {title}

{subtitle}

+ {!schedule && draft.step > 0 && } {draft.step === 0 && (
Trigger type diff --git a/ui/src/components/routine-triggers/WebhookUrlWarning.tsx b/ui/src/components/routine-triggers/WebhookUrlWarning.tsx new file mode 100644 index 0000000000..4f326df163 --- /dev/null +++ b/ui/src/components/routine-triggers/WebhookUrlWarning.tsx @@ -0,0 +1,38 @@ +import { InlineBanner } from "@/components/InlineBanner"; +import { webhookUrlWarningReason } from "@/lib/webhook-url-warning"; + +const warnings = { + loopback: { + title: "Other apps can’t reach this localhost URL", + message: "This address points back to the machine sending the request. Services such as GitHub can’t use it to reach Paperclip on your computer.", + }, + private: { + title: "This webhook URL appears to be private", + message: "Only senders with access to this network can reach this address. Apps on the public internet, such as GitHub, usually can’t deliver webhooks here.", + }, + tailscale: { + title: "This Tailscale URL may not be public", + message: "Tailscale Serve is private to your tailnet, even with HTTPS. Apps outside your tailnet need Tailscale Funnel or another public HTTPS address. If Funnel is already enabled for this URL, you can continue.", + }, + https: { + title: "Use HTTPS for webhooks from other apps", + message: "This URL uses HTTP. Many apps require HTTPS, and HTTP does not encrypt webhook credentials or payloads.", + }, + invalid: { + title: "Check the webhook URL", + message: "This is not a valid HTTP or HTTPS URL. Your sending app needs a complete address it can reach.", + }, +}; + +export function WebhookUrlWarning({ url }: { url: string }) { + const reason = webhookUrlWarningReason(url); + if (!reason) return null; + const warning = warnings[reason]; + return +
+

{warning.message}

+

You can continue for local or private-network use. For public senders, use a publicly reachable HTTPS URL.

+ Learn how to set up HTTPS and public access +
+
; +} diff --git a/ui/src/lib/webhook-url-warning.test.ts b/ui/src/lib/webhook-url-warning.test.ts new file mode 100644 index 0000000000..f555ef9bb6 --- /dev/null +++ b/ui/src/lib/webhook-url-warning.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { webhookUrlWarningReason } from "./webhook-url-warning"; + +describe("webhookUrlWarningReason", () => { + it.each([ + ["http://localhost:3100/fire", "loopback"], + ["https://app.localhost./fire", "loopback"], + ["http://127.1/fire", "loopback"], + ["http://0x7f000001/fire", "loopback"], + ["https://[::1]/fire", "loopback"], + ["https://[::ffff:127.0.0.1]/fire", "loopback"], + ["http://0.0.0.0/fire", "loopback"], + ["https://10.1.2.3/fire", "private"], + ["https://172.16.0.1/fire", "private"], + ["https://172.31.255.255/fire", "private"], + ["https://192.168.1.2/fire", "private"], + ["https://169.254.1.2/fire", "private"], + ["https://100.64.0.1/fire", "private"], + ["https://100.127.255.255/fire", "private"], + ["https://[fd00::1]/fire", "private"], + ["https://[fc00::1]/fire", "private"], + ["https://[fe80::1]/fire", "private"], + ["https://[::ffff:192.168.1.2]/fire", "private"], + ["https://paperclip/fire", "private"], + ["https://paperclip.internal/fire", "private"], + ["https://paperclip.local/fire", "private"], + ["https://paperclip.home.arpa/fire", "private"], + ["https://paperclip.example-tailnet.ts.net/fire", "tailscale"], + ["http://PAPERCLIP.EXAMPLE-TAILNET.TS.NET./fire", "tailscale"], + ["http://paperclip.example.com/fire", "https"], + ["https://paperclip.example.com/fire", null], + ["https://172.32.0.1/fire", null], + ["https://100.128.0.1/fire", null], + ["https://[2606:4700::1111]/fire", null], + ["https://[::ffff:8.8.8.8]/fire", null], + ["https://ts.net.example.com/fire", null], + ["ftp://paperclip.example.com/fire", "invalid"], + ["not a URL", "invalid"], + ["", null], + ])("classifies %s as %s", (url, expected) => { + expect(webhookUrlWarningReason(url)).toBe(expected); + }); +}); diff --git a/ui/src/lib/webhook-url-warning.ts b/ui/src/lib/webhook-url-warning.ts new file mode 100644 index 0000000000..d88a57494c --- /dev/null +++ b/ui/src/lib/webhook-url-warning.ts @@ -0,0 +1,37 @@ +export type WebhookUrlWarningReason = "loopback" | "private" | "tailscale" | "https" | "invalid"; + +function ipv4Warning(parts: number[]): WebhookUrlWarningReason | null { + const [a, b] = parts; + if (a === 127 || a === 0) return "loopback"; + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) + || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)) return "private"; + return null; +} + +/** URL hints only: DNS, firewall rules, and Tailscale Funnel cannot be inferred here. */ +export function webhookUrlWarningReason(value: string): WebhookUrlWarningReason | null { + if (!value) return null; + let url: URL; + try { url = new URL(value); } catch { return "invalid"; } + if (url.protocol !== "http:" && url.protocol !== "https:") return "invalid"; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); + if (host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "::") return "loopback"; + if (host.includes(":")) { + // URL normalizes IPv4-mapped IPv6 addresses to two hexadecimal words. + const mapped = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/.exec(host); + if (mapped) { + const high = parseInt(mapped[1], 16), low = parseInt(mapped[2], 16); + const warning = ipv4Warning([high >> 8, high & 255, low >> 8, low & 255]); + if (warning) return warning; + } + if (/^(f[cd][\da-f]{2}|fe[89ab][\da-f]|fec[\da-f]):/.test(host)) return "private"; + } else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + const warning = ipv4Warning(host.split(".").map(Number)); + if (warning) return warning; + } else { + if (host.endsWith(".ts.net")) return "tailscale"; + if (!host.includes(".") || /\.(local|internal|lan|home|corp|intranet|private|test)$/.test(host) + || host.endsWith(".home.arpa")) return "private"; + } + return url.protocol === "https:" ? null : "https"; +} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 21a96e05bb..02f7690039 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1,3 +1,4 @@ +import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning"; import { SetupWizardNavigation, SetupWizardFooter } from "../components/SetupWizard"; import { AgentChatPicker } from "@/components/AgentChatPicker"; import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard"; @@ -2348,6 +2349,12 @@ export function DesignGuide() { {/* ============================================================ */} {/* INLINE BANNER + BUILT-IN AGENTS */} {/* ============================================================ */} +
+
+ {["http://localhost:3100", "https://paperclip.internal", "https://paperclip.example-tailnet.ts.net", "http://paperclip.example.com", "not-a-url"].map((url) => )} +
+
+

Token-backed full-width notice (brandBanner tones). Use{" "} diff --git a/ui/storybook/fixtures/routineWebhooks.tsx b/ui/storybook/fixtures/routineWebhooks.tsx index f2a8d9bec5..4e6a5a5f9d 100644 --- a/ui/storybook/fixtures/routineWebhooks.tsx +++ b/ui/storybook/fixtures/routineWebhooks.tsx @@ -14,7 +14,7 @@ import { storybookAgents, storybookIssues } from "./paperclipData"; const now = new Date("2026-09-18T15:00:00Z"); const routineId = "routine-webhook-story"; const companyId = "company-storybook"; -const webhookUrl = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire"; +const defaultWebhookUrl = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire"; const demoSecret = "storybook-demo-secret-not-a-real-credential"; const actorFields = { createdByAgentId: null, createdByUserId: null, @@ -22,7 +22,7 @@ const actorFields = { createdAt: now, updatedAt: now, }; -function webhook(signingMode: string, index = 1): RoutineTrigger { +function webhook(signingMode: string, index = 1, webhookUrl = defaultWebhookUrl): RoutineTrigger { return { ...actorFields, id: `webhook-${index}`, companyId, routineId, kind: "webhook", label: "Deployment completed", enabled: true, @@ -94,12 +94,13 @@ const routineActivity: ActivityEvent[] = [ type Props = { preview?: ReactNode; + webhookUrl?: string; signingMode: "bearer" | "hmac_sha256" | "github_hmac" | "none"; state: "setup" | "credentials" | "configured" | "failure" | "overview" | "list" | "runs" | "activity"; }; /** The actual application shell and route pages, backed by an in-memory API. */ -export function WebhookReview({ signingMode = "bearer", state = "configured", preview }: Props) { +export function WebhookReview({ signingMode = "bearer", state = "configured", preview, webhookUrl = defaultWebhookUrl }: Props) { const navigate = useNavigate(); const { setSelectedCompanyId } = useCompany(); const [ready, setReady] = useState(false); @@ -112,7 +113,7 @@ export function WebhookReview({ signingMode = "bearer", state = "configured", pr lastTriggeredAt: fresh ? null : now, recentRuns: fresh ? [] : [completedRun], triggers: fresh ? [] : [{ - ...webhook(signingMode), + ...webhook(signingMode, 1, webhookUrl), lastWebhookDelivery: { status: state === "failure" ? "rejected" : "received", receivedAt: now.toISOString(), test: false }, lastResult: state === "failure" ? "Failed to create task: no default agent assigned" @@ -149,7 +150,7 @@ export function WebhookReview({ signingMode = "bearer", state = "configured", pr routinesApi.update = async (_id, patch) => (routine = { ...routine, ...patch }); routinesApi.createTrigger = async (_id, input) => { const trigger: RoutineTrigger = { - ...webhook(String(input.signingMode ?? "bearer"), routine.triggers.length + 1), ...input, + ...webhook(String(input.signingMode ?? "bearer"), routine.triggers.length + 1, webhookUrl), ...input, lastFiredAt: null, lastResult: null, }; routine = { ...routine, triggers: [...routine.triggers, trigger] }; @@ -189,7 +190,7 @@ export function WebhookReview({ signingMode = "bearer", state = "configured", pr issuesApi.listCompact = originalCompactList; window.fetch = previousFetch; }; - }, [signingMode, state]); + }, [signingMode, state, webhookUrl]); if (!ready) return null; return ( diff --git a/ui/storybook/stories/routine-webhooks.stories.tsx b/ui/storybook/stories/routine-webhooks.stories.tsx index de09b1deab..c830222662 100644 --- a/ui/storybook/stories/routine-webhooks.stories.tsx +++ b/ui/storybook/stories/routine-webhooks.stories.tsx @@ -55,3 +55,16 @@ export const Activity: Story = { await expect(canvas.getByRole("navigation", { name: "Routine navigation" })).toBeVisible(); }, }; + +const warningSetup: NonNullable = async (context) => { + await Credentials.play!(context); + const canvas = within(context.canvasElement); + await expect(canvas.getByRole("link", { name: "Learn how to set up HTTPS and public access" })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Check connection" })).toBeEnabled(); +}; +const deliveryPath = "/api/routine-triggers/public/0123456789abcdef01234567/fire"; +export const LocalhostWarning: Story = { name: "11 · Localhost warning", args: { state: "credentials", webhookUrl: `http://localhost:3100${deliveryPath}` }, play: warningSetup }; +export const TailscaleWarning: Story = { name: "12 · Tailscale public access warning", args: { state: "credentials", webhookUrl: `https://paperclip.example-tailnet.ts.net${deliveryPath}` }, play: warningSetup }; +export const PrivateNetworkWarning: Story = { name: "13 · Internal domain warning", args: { state: "credentials", webhookUrl: `https://paperclip.internal${deliveryPath}` }, play: warningSetup }; +export const HttpWarning: Story = { name: "14 · HTTP warning", args: { state: "credentials", webhookUrl: `http://paperclip.example.com${deliveryPath}` }, play: warningSetup }; +export const ExistingWebhookWarning: Story = { name: "15 · Existing private webhook warning", args: { webhookUrl: `https://192.168.1.10${deliveryPath}` }, play: openSavedWebhook };