Warn when routine webhook URLs may not be publicly reachable (#13684)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-19 12:24:06 -05:00
1 parent b70641f23f
commit da257c3069
11 files changed
+169 -6

No files matched your search

+2
View File
@@ -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.
+2
View File
@@ -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.
@@ -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);
@@ -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 (
<div className="space-y-4">
<WebhookUrlWarning url={trigger.webhookUrl ?? ""} />
{secret && (github || trigger.signingMode === "bearer") && (
<AgentInstructions
value={webhookAgentInstructions(
@@ -19,6 +19,7 @@ import { Label } from "@/components/ui/label";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { cn } from "@/lib/utils";
import { AgentInstructions, CopyField } from "./WebhookFields";
import { WebhookUrlWarning } from "./WebhookUrlWarning";
export type TriggerDraft = {
kind: "choose" | "schedule" | "webhook";
@@ -228,6 +229,7 @@ export function RoutineTriggerWizard({
<h1 className="text-xl font-bold">{title}</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{!schedule && draft.step > 0 && <WebhookUrlWarning url={webhookUrl} />}
{draft.step === 0 && (
<fieldset className="space-y-3">
<legend className="sr-only">Trigger type</legend>
@@ -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 <InlineBanner tone="warning" title={warning.title}>
<div className="space-y-2">
<p>{warning.message}</p>
<p>You can continue for local or private-network use. For public senders, use a publicly reachable HTTPS URL.</p>
<a className="underline underline-offset-4" href="https://docs.paperclip.ing/reference/deploy/https/" target="_blank" rel="noopener noreferrer">Learn how to set up HTTPS and public access</a>
</div>
</InlineBanner>;
}
+43
View File
@@ -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);
});
});
+37
View File
@@ -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";
}
+7
View File
@@ -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 */}
{/* ============================================================ */}
<Section title="Webhook URL warnings">
<div className="space-y-3">
{["http://localhost:3100", "https://paperclip.internal", "https://paperclip.example-tailnet.ts.net", "http://paperclip.example.com", "not-a-url"].map((url) => <WebhookUrlWarning key={url} url={url} />)}
</div>
</Section>
<Section title="Inline Banner">
<p className="text-sm text-muted-foreground">
Token-backed full-width notice (<span className="font-mono">brandBanner</span> tones). Use{" "}
+7 -6
View File
@@ -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 (
@@ -55,3 +55,16 @@ export const Activity: Story = {
await expect(canvas.getByRole("navigation", { name: "Routine navigation" })).toBeVisible();
},
};
const warningSetup: NonNullable<Story["play"]> = 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 };