@useswarm/cli
Advanced tools
+26
-11
@@ -418,3 +418,7 @@ import { Command } from "commander"; | ||
| const frontendPort = parsedUrl?.port ? parseInt(parsedUrl.port, 10) : 3000; | ||
| let tunnelPort = frontendPort; | ||
| // The local origin we'll spoof on outbound Origin/Referer headers so the | ||
| // user's CSRF middleware sees a same-origin request. Falls back to the | ||
| // frontend port if --url somehow lacked one. | ||
| const localOrigin = parsedUrl?.origin ?? `http://localhost:${frontendPort}`; | ||
| let backendPort; | ||
| // Pre-flight connectivity check | ||
@@ -432,5 +436,4 @@ const frontendReachable = await checkPort(frontendPort); | ||
| } | ||
| // Start reverse proxy if backend provided | ||
| if (backendUrl) { | ||
| let backendPort = 8080; | ||
| backendPort = 8080; | ||
| if (/^\d+$/.test(backendUrl)) { | ||
@@ -457,6 +460,14 @@ backendPort = parseInt(backendUrl, 10); | ||
| } | ||
| const proxySpinner = ora("Starting reverse proxy...").start(); | ||
| try { | ||
| proxy = await startProxy(frontendPort, backendPort, backendPaths); | ||
| tunnelPort = proxy.port; | ||
| } | ||
| // We now ALWAYS run the local proxy in front of the user's app, even | ||
| // when --backend isn't set. The proxy's only job in the no-backend case | ||
| // is to spoof the Origin/Referer headers from the tunnel host back to | ||
| // the user's local origin, so their CSRF middleware (Better Auth / | ||
| // NextAuth / Django / etc.) accepts the agent's POSTs without the user | ||
| // having to add "*.trycloudflare.com" / "*.ngrok-free.app" to their | ||
| // trustedOrigins. Routing-wise it's a transparent passthrough. | ||
| const proxySpinner = ora("Starting local proxy...").start(); | ||
| try { | ||
| proxy = await startProxy(frontendPort, backendPort ?? frontendPort, backendPaths, localOrigin); | ||
| if (backendPort != null) { | ||
| proxySpinner.succeed(`Reverse proxy started\n` + | ||
@@ -466,8 +477,12 @@ ` Frontend: ${chalk.cyan(`localhost:${frontendPort}`)}\n` + | ||
| } | ||
| catch (err) { | ||
| proxySpinner.fail("Failed to start reverse proxy"); | ||
| console.error(chalk.red(err.message)); | ||
| process.exit(1); | ||
| else { | ||
| proxySpinner.succeed(`Local proxy ready ${chalk.dim(`(spoofing Origin → ${localOrigin})`)}`); | ||
| } | ||
| } | ||
| catch (err) { | ||
| proxySpinner.fail("Failed to start local proxy"); | ||
| console.error(chalk.red(err.message)); | ||
| process.exit(1); | ||
| } | ||
| const tunnelPort = proxy.port; | ||
| const tunnelProvider = opts.tunnelProvider ?? "auto"; | ||
@@ -474,0 +489,0 @@ const providerLabel = tunnelProvider === "auto" ? "" : ` (${tunnelProvider})`; |
@@ -6,2 +6,2 @@ export interface ProxyInfo { | ||
| } | ||
| export declare function startProxy(frontendPort: number, backendPort: number, backendPaths?: string[]): Promise<ProxyInfo>; | ||
| export declare function startProxy(frontendPort: number, backendPort: number, backendPaths?: string[], localOrigin?: string): Promise<ProxyInfo>; |
+125
-2
| import http from "node:http"; | ||
| import zlib from "node:zlib"; | ||
| import { Readable } from "node:stream"; | ||
| import httpProxy from "http-proxy"; | ||
| // Cap on how much of an inbound request body we'll buffer for tunnel-URL | ||
| // rewriting. 5 MB covers every realistic auth/JSON payload while keeping us | ||
| // far away from any "buffer the whole upload" footgun. Anything larger is | ||
| // streamed straight through unmodified. | ||
| const MAX_REQUEST_REWRITE_BYTES = 5 * 1024 * 1024; | ||
| /** True if a content-type is text-shaped enough to safely substring-replace. */ | ||
| function isRewritableRequestBody(contentType) { | ||
| if (!contentType) | ||
| return false; | ||
| const ct = contentType.toLowerCase(); | ||
| return (ct.startsWith("application/json") || | ||
| ct.startsWith("application/x-www-form-urlencoded") || | ||
| ct.startsWith("text/")); | ||
| } | ||
| const DEFAULT_BACKEND_PATH_PREFIXES = ["/api", "/auth", "/graphql", "/trpc"]; | ||
@@ -203,4 +218,45 @@ // ─── HMR / dev-only path prefixes ──────────────────────────────────────────── | ||
| } | ||
| // Rewrite the incoming Origin/Referer headers to look like they came from the | ||
| // user's own local origin. The browser's request URL (and the upstream Host) | ||
| // are not touched — only the headers that CSRF middleware inspects. This makes | ||
| // the agent's tunneled-test traffic indistinguishable from a same-origin | ||
| // request to the user's app, so frameworks like Better Auth / NextAuth / Django | ||
| // accept it without the user needing to add any tunnel-specific entry to their | ||
| // `trustedOrigins` / `CSRF_TRUSTED_ORIGINS` / equivalent. | ||
| function spoofOriginHeaders(headers, localOrigin) { | ||
| if (!localOrigin) | ||
| return; | ||
| let parsedLocal; | ||
| try { | ||
| parsedLocal = new URL(localOrigin); | ||
| } | ||
| catch { | ||
| return; | ||
| } | ||
| if (typeof headers.origin === "string" && headers.origin.length > 0 && headers.origin !== "null") { | ||
| headers.origin = parsedLocal.origin; | ||
| } | ||
| const referer = headers.referer ?? headers.referrer; | ||
| if (typeof referer === "string" && referer.length > 0) { | ||
| try { | ||
| const refUrl = new URL(referer); | ||
| refUrl.protocol = parsedLocal.protocol; | ||
| refUrl.host = parsedLocal.host; | ||
| headers.referer = refUrl.toString(); | ||
| delete headers.referrer; | ||
| } | ||
| catch { | ||
| // Malformed referer — leave it alone. | ||
| } | ||
| } | ||
| } | ||
| // ─── Public API ────────────────────────────────────────────────────────────── | ||
| export async function startProxy(frontendPort, backendPort, backendPaths) { | ||
| export async function startProxy(frontendPort, backendPort, backendPaths, | ||
| // Origin to spoof on incoming requests. Set this to the user's original local | ||
| // URL (e.g., "http://localhost:3000") so Better Auth / NextAuth / Django CSRF | ||
| // / any same-origin-style check on the user's app sees its own configured | ||
| // origin and accepts the agent's POSTs — instead of seeing the tunnel host | ||
| // (`xyz.trycloudflare.com`) and rejecting with "Invalid origin". The end user | ||
| // doesn't need to know about tunnel hostnames or touch their auth config. | ||
| localOrigin) { | ||
| let tunnelUrl; | ||
@@ -410,3 +466,69 @@ const BACKEND_PATH_PREFIXES = backendPaths | ||
| req.headers["x-forwarded-host"] = req.headers["host"] || ""; | ||
| proxy.web(req, res, { target }); | ||
| spoofOriginHeaders(req.headers, localOrigin); | ||
| // Rewrite tunnel-URL occurrences inside the request body back to the user's | ||
| // local origin. Frontends frequently embed `window.location.origin` into | ||
| // POST payloads (Better Auth / NextAuth `callbackURL`, redirect URLs, OAuth | ||
| // state, etc.) and any framework-side trustedOrigins check on those values | ||
| // would reject the tunnel host the same way it would reject a tunnel-host | ||
| // Origin header. The proxy already does the inverse rewrite on responses | ||
| // (localhost → tunnel) so the round-trip stays consistent. | ||
| const method = (req.method ?? "GET").toUpperCase(); | ||
| const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; | ||
| const shouldRewriteBody = hasBody && tunnelUrl && localOrigin && isRewritableRequestBody(Array.isArray(req.headers["content-type"]) ? req.headers["content-type"][0] : req.headers["content-type"]); | ||
| if (!shouldRewriteBody) { | ||
| proxy.web(req, res, { target }); | ||
| return; | ||
| } | ||
| const chunks = []; | ||
| let total = 0; | ||
| let exceeded = false; | ||
| req.on("data", (chunk) => { | ||
| if (exceeded) | ||
| return; | ||
| total += chunk.length; | ||
| if (total > MAX_REQUEST_REWRITE_BYTES) { | ||
| // Body too big to rewrite safely. Replay what we buffered as-is and | ||
| // stream the rest straight through — large uploads aren't auth payloads. | ||
| exceeded = true; | ||
| const replay = new Readable({ read() { } }); | ||
| for (const c of chunks) | ||
| replay.push(c); | ||
| replay.push(chunk); | ||
| req.on("data", (rest) => replay.push(rest)); | ||
| req.on("end", () => replay.push(null)); | ||
| req.on("error", (e) => replay.destroy(e)); | ||
| proxy.web(req, res, { target, buffer: replay }); | ||
| chunks.length = 0; | ||
| return; | ||
| } | ||
| chunks.push(chunk); | ||
| }); | ||
| req.on("error", (err) => { | ||
| if (!res.headersSent) | ||
| res.writeHead(400); | ||
| res.end(`Bad request body: ${err.message}`); | ||
| }); | ||
| req.on("end", () => { | ||
| if (exceeded) | ||
| return; | ||
| const original = Buffer.concat(chunks); | ||
| let modified = original; | ||
| try { | ||
| const text = original.toString("utf8"); | ||
| // Replace both the exact tunnel URL and any subdomain/host reference | ||
| // a frontend might have grabbed from window.location.origin. | ||
| const rewritten = text.split(tunnelUrl).join(localOrigin); | ||
| if (rewritten !== text) { | ||
| modified = Buffer.from(rewritten, "utf8"); | ||
| } | ||
| } | ||
| catch { | ||
| // Non-UTF8 body slipped past content-type — forward unchanged. | ||
| } | ||
| // Content-Length must reflect the modified payload size or upstream | ||
| // frameworks will hang waiting for bytes that never arrive. | ||
| req.headers["content-length"] = String(modified.length); | ||
| const buffered = Readable.from(modified); | ||
| proxy.web(req, res, { target, buffer: buffered }); | ||
| }); | ||
| }); | ||
@@ -425,2 +547,3 @@ // ── WebSocket upgrade ───────────────────────────────────────────────────── | ||
| : `http://127.0.0.1:${frontendPort}`; | ||
| spoofOriginHeaders(req.headers, localOrigin); | ||
| proxy.ws(req, socket, head, { target }, (err) => { | ||
@@ -427,0 +550,0 @@ if (err) |
+1
-1
| { | ||
| "name": "@useswarm/cli", | ||
| "version": "0.3.5", | ||
| "version": "0.3.7", | ||
| "description": "Swarm CLI — AI-powered UX testing from your terminal", | ||
@@ -5,0 +5,0 @@ "type": "module", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
102836
7.17%2095
7.05%