Skip to content

πŸ› BUG: wrangler dev corrupts external hostnames in _redirects Location headers (boundary-less substring host rewrite in ProxyWorker)Β #14577

Description

@singhaniket-hue

Which Cloudflare product(s) does this pertain to?

Wrangler

What versions are you using?

Reproduced identically on wrangler 4.86.0 (Node.js 20.20.2) and wrangler 4.107.0 β€” latest at time of filing (Node.js 22.23.1). npm 10.8.2, Windows 11 Pro (10.0.26200). The defect is in the ProxyWorker template JS, so it should be OS-independent.

Describe the Bug

Observed behavior

For an assets-only Worker with routes configured, wrangler dev rewrites the hostname inside Location headers produced by _redirects rules whose destination is an absolute URL on a different hostname that merely contains the route hostname as a substring β€” most commonly, any subdomain of the same zone. The result is a nonsense hostname.

With a route on example.com/* and the rule

/go https://books.example.com/read/ch01 302

local dev returns:

$ curl -sI http://127.0.0.1:8788/go
HTTP/1.1 302 Found
Location: https://books.127.0.0.1:8788/read/ch01

Status and path are correct; the hostname is mangled (books.example.com β†’ books.127.0.0.1:8788). It is not even limited to subdomains β€” any hostname containing the route host as a substring is corrupted:

/tricky https://myexample.com/path 302   β†’   Location: https://my127.0.0.1:8788/path

Expected behavior

Absolute redirect destinations pointing at other hostnames should pass through untouched β€” the local dev server does not serve books.example.com, so mapping that host to the local address can never be correct. The deployed Worker serves the correct Location: https://books.example.com/read/ch01. (Real-world case: our production assets-only Worker on duvera.app/* redirects /books/<slug> β†’ https://books.duvera.app/read/…; correct in production, corrupted under wrangler dev, which breaks local verification of the redirect map.)

Reproduction

Three files, no server code:

wrangler.toml

name = "redirects-repro"
compatibility_date = "2026-04-26"
workers_dev = false

routes = [
  { pattern = "example.com/*", zone_name = "example.com" },
]

[assets]
directory = "./public"

public/_redirects

/go https://books.example.com/read/ch01 302
/apex https://example.com/somewhere 302
/external https://unrelated-domain.org/path 302
/tricky https://myexample.com/path 302

public/index.html β€” any content.

Run npx wrangler dev --port 8788, then curl -sI each path:

rule destination Location returned by wrangler dev verdict
https://books.example.com/read/ch01 https://books.127.0.0.1:8788/read/ch01 ❌ subdomain of route zone mangled
https://myexample.com/path https://my127.0.0.1:8788/path ❌ substring-containing host mangled
https://example.com/somewhere (= route host) https://127.0.0.1:8788/somewhere ⚠️ host mapping presumably intended, but the scheme stays https while the dev server is plain HTTP, so following it fails
https://unrelated-domain.org/path https://unrelated-domain.org/path βœ… untouched

(Output identical on 4.86.0/Node 20 and 4.107.0/Node 22.)

Two extra data points that localize the defect to the dev proxy's inner↔outer URL mapping (not the asset-worker redirects engine, whose output is correct):

  • Removing routes from wrangler.toml makes every Location pass through correctly.
  • The substitution target tracks the incoming Host header:
    $ curl -sI http://127.0.0.1:8788/go -H "Host: something-else.dev"
    Location: https://books.something-else.dev/read/ch01
    

Root cause

rewriteUrlRelatedHeaders() in packages/wrangler/templates/startDevWorker/ProxyWorker.ts (L348–L358), applied to every response header on the response path (L161 β€” rewriteUrlRelatedHeaders(res.headers, innerUrl, outerUrl)):

headers.forEach((value, key) => {
	if (typeof value === "string" && value.includes(from.host)) {
		headers.set(
			key,
			value.replaceAll(from.origin, to.origin).replaceAll(from.host, to.host)
		);
	}
});

value.includes(from.host) / value.replaceAll(from.host, to.host) is a boundary-less substring replace. With from.host = "example.com" (inferred from routes) and to.host = "127.0.0.1:8788":

  • books.example.com β†’ books.127.0.0.1:8788 (subdomain matched as suffix)
  • myexample.com β†’ my127.0.0.1:8788 (no left-boundary check at all)

It also explains row 3 of the table: .replaceAll(from.origin, to.origin) never matches these header values (in local dev from.origin is http://example.com while the header says https://example.com), so only the host-level replace fires and the https: scheme survives on a plain-HTTP dev address.

Suggested fix

Rewrite only when a URL actually parsed out of the header value has a host exactly equal to from.host, and rewrite scheme and host together β€” e.g. extract URL candidates with a regex, and for each candidate new URL(candidate), replace it with the rewritten URL only if url.host === from.host. That keeps the intended behavior (mapping the route/inferred host back to the local address, as needed for headers like Location pointing at the emulated host) while leaving books.example.com, myexample.com, etc. alone, and fixes the stale-scheme artifact as a side effect.

Related issues (checked β€” none cover this)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Untriaged

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions