paperclipai/paperclip · error

devUiUrl must target localhost

Error message

devUiUrl must target localhost

What it means

Returned as HTTP 400 by the plugin UI dev proxy (server/src/routes/plugin-ui-static.ts:359). Even with a valid http/https URL, the proxy only forwards to literal loopback hostnames: 'localhost', '127.0.0.1', '::1', or '[::1]'. The check runs on the *constructed* target URL hostname, so both a non-loopback devUiUrl and any path trick that changes the hostname are rejected.

Source

Thrown at server/src/routes/plugin-ui-static.ts:359

            res.status(400).json({ error: "devUiUrl must use http or https protocol" });
            return;
          }

          // Dev proxy is restricted to loopback addresses only.
          // Validate the *constructed* targetUrl hostname (not the base) to
          // catch any path-based override that slipped past the checks above.
          const devHost = targetUrl.hostname;
          const isLoopback =
            devHost === "localhost" ||
            devHost === "127.0.0.1" ||
            devHost === "::1" ||
            devHost === "[::1]";
          if (!isLoopback) {
            log.warn(
              { pluginId: plugin.id, devUiUrl, host: devHost },
              "plugin-ui-static: devUiUrl must target localhost, rejecting proxy",
            );
            res.status(400).json({ error: "devUiUrl must target localhost" });
            return;
          }

          log.debug(
            { pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href },
            "plugin-ui-static: proxying to devUiUrl",
          );

          try {
            const controller = new AbortController();
            const timeout = setTimeout(() => controller.abort(), 10_000);
            try {
              const upstream = await fetch(targetUrl.href, { signal: controller.signal });
              if (!upstream.ok) {
                res.status(upstream.status).json({
                  error: `Dev server returned ${upstream.status}`,
                });
                return;

View on GitHub (pinned to 120ae5428f)

Solutions

  1. Change devUiUrl to 'http://localhost:<port>/' or 'http://127.0.0.1:<port>/' — these are the only accepted hostnames
  2. Make sure the dev server actually listens on loopback (vite dev --host localhost or default behavior)
  3. If the dev server runs in a container or another host, port-forward it to loopback (e.g. kubectl port-forward, ssh -L) and point devUiUrl at the local forwarded port

Example fix

# before
{ "devUiUrl": "http://0.0.0.0:5173/" }

# after
{ "devUiUrl": "http://127.0.0.1:5173/" }
Defensive patterns

Strategy: validation

Validate before calling

const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
const isLoopbackDevUiUrl = (u: string): boolean => {
  try { return LOOPBACK_HOSTS.has(new URL(u).hostname); } catch { return false; }
};

Type guard

const isLoopbackUrl = (u: string): boolean => {
  try { return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(new URL(u).hostname); } catch { return false; }
};

Prevention

When it happens

Trigger: devUiUrl set to 'http://0.0.0.0:5173' (hostname '0.0.0.0' is not in the allowlist), 'http://192.168.1.20:5173', or an /etc/hosts alias like 'http://myplugin.localhost:5173' — none match the literal loopback strings, so any proxied asset request 400s. Also fires when the requested file path itself is crafted to change the hostname.

Common situations: Dev server bound to all interfaces and reached via the machine's LAN IP; using 0.0.0.0 as a 'same machine' shorthand; hostnames like *.localhost that browsers resolve but this literal comparison rejects; containerized setups where the plugin dev server is on another network namespace.

Related errors


AI-assisted analysis of paperclipai/paperclip@120ae5428f (2026-08-18). Data as JSON: /api/errors/c9d0a1ef7622a422. Report an issue: GitHub.