derive the request origin safely, and honor X-Forwarded-Proto / X-Forwarded-Host on Bun (#1091) 8ce50392
This affects both runtimes. Upgrade if your app derives any absolute URL.
On the node shell the origin was built by resolving the request target against the proxy headers, and four inputs a client controls could poison it. The first three applied to node only; the fourth to both runtimes:
A request target beginning with // was read as an authority, not a path. GET //evil.com/x produced an origin of http://evil.com and silently rewrote the path to /x, so a different route matched than the one requested.
X-Forwarded-Proto was honored for any scheme. A value of javascript produced an origin of the literal string null, so every absolute URL the app derived became null/....
A malformed X-Forwarded-Host (an unparseable authority, or an out-of-range port) threw Invalid URL, which surfaced as a 500 on the fetch path.
An absolute-form request line supplied the origin outright. GET http://evil.com/x HTTP/1.1 is reported verbatim as the request url by both node's HTTP server and Bun.serve, so it resolved to an origin of http://evil.com with the path rewritten to /x. An unproxied app was exposed to this directly.
The origin is now assembled by assigning the parts rather than resolving, the forwarded scheme is restricted to http and https, and an unparseable forwarded authority is ignored rather than raising.
Separately, on the Bun shell the forwarded headers were not honored at all, so behind a TLS-terminating proxy every absolute URL came out with an http:// origin. This shipped: webjs.dev runs on Bun behind Cloudflare and served <meta property="og:image" content="http://webjs.dev/public/og.png">. The blast radius is every absolute URL an app derives, so OAuth callbacks, canonical tags and sitemap entries were affected too, not just OG tags.
Both entry points now funnel through one readForwarded and one resolveOrigin, so the header and origin decisions cannot drift between the runtimes. What each shell RECEIVES still differs, and deliberately so: node gets an origin-form request target it treats as a path, Bun gets a url its own parser already resolved, so an absolute-form request line still routes to a different path on each. The origin, which is the security-relevant part, is decided in one place for both.
The correction is applied to the Request itself rather than threaded alongside it, because a route.ts handler reads req.url directly. The rebuild is skipped entirely when no forwarded header changes the origin, so an unproxied app keeps the no-clone hot path, and the WebSocket upgrade path gets the same treatment.
Embedding note: the forwarded correction belongs to startServer, which owns the listener. If you embed WebJs via createRequestHandler, you hand it the Request and it derives absolute URLs from that url, so you must rebuild the Request with the original scheme and host yourself. The same applies to the trusted client IP.
load a root middleware.ts, not only middleware.js (#1101) e07ea3a1
The root middleware lookup was the single literal middleware.js, so an app whose root middleware was written in TypeScript never had it loaded. There was no error and no warning: a missing middleware looks exactly like an app that has none, which is why this went unnoticed even though examples/blog ships a root middleware.ts that had never run.
TypeScript is the documented default for an app, the scaffold writes middleware.ts for the api template, and every other routing convention matches on the file stem. This was the one lookup that did not. It now resolves .ts, .js, .mts, and .mjs, with .ts first.
On upgrade, a root middleware that has never executed starts executing. That is the point of the fix, but it is a real behaviour change on a patch bump, so check what yours does before deploying. An app scaffolded with --template api ships a root middleware.ts applying cors() with a placeholder origin allow-list, which becomes live (including the OPTIONS preflight short-circuit); a hand-written one gating auth or issuing redirects goes from dormant to enforcing.
point the framework's own error messages at webjs.dev/docs (#1101) e07ea3a1
The documentation moved onto the main domain, and two thrown errors still linked the old host: the stub raised when a server-only module is imported from the browser, and the strip failure raised on non-erasable TypeScript. A URL baked into an error is the one thing a published version can never take back, so the sooner the message is right the fewer installs carry a stale one. The old host redirects permanently, so neither was broken.