@webjsdev/server 0.8.55 → 0.8.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/actions.js +1 -1
- package/src/check.js +73 -0
- package/src/dev.js +24 -5
- package/src/forwarded.js +165 -10
- package/src/listener-bun.js +80 -3
- package/src/ssr.js +17 -8
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -267,7 +267,7 @@ export function serveServerOnlyStub(relPath) {
|
|
|
267
267
|
`This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
|
|
268
268
|
`Either add 'use server' at the top of the file to expose its exports as RPC, ` +
|
|
269
269
|
`or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead. ` +
|
|
270
|
-
`Docs: https://
|
|
270
|
+
`Docs: https://webjs.dev/docs/server-actions`;
|
|
271
271
|
return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
|
|
272
272
|
throw new Error(${JSON.stringify(msg)});
|
|
273
273
|
`;
|
package/src/check.js
CHANGED
|
@@ -119,6 +119,11 @@ export const RULES = [
|
|
|
119
119
|
description:
|
|
120
120
|
'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
|
|
121
121
|
},
|
|
122
|
+
{
|
|
123
|
+
name: 'no-shadowed-native-member',
|
|
124
|
+
description:
|
|
125
|
+
'Flags a WebComponent class method whose name collides with a native DOM mutation method that WebJs relies on and INSTRUMENTS on every light-DOM host for the slot API (#1021): append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, replaceChild. A method of the same name is SHADOWED at runtime (the instrumented native method wins), so the component method silently never runs, while TypeScript stays green because a zero/one-arg override is assignable to the native signature. Found dogfooding the stream demo (#248): a component named its button handler `append()`, so clicking Append called the slot-append no-op instead of the handler. Rename the handler to a non-native name (appendRow / prependRow / removeItem). The `render` / lifecycle hooks are MEANT to be overridden and are not flagged; only the native DOM mutation members are.',
|
|
126
|
+
},
|
|
122
127
|
{
|
|
123
128
|
name: 'no-server-import-in-browser-module',
|
|
124
129
|
description:
|
|
@@ -659,6 +664,74 @@ export async function checkConventions(appDir) {
|
|
|
659
664
|
}
|
|
660
665
|
}
|
|
661
666
|
|
|
667
|
+
// --- Rule: no-shadowed-native-member ---
|
|
668
|
+
// A WebComponent method named after a native DOM mutation method WebJs
|
|
669
|
+
// instruments on every light-DOM host for the slot API (#1021) is SHADOWED at
|
|
670
|
+
// runtime (the native/instrumented method wins), so the component method never
|
|
671
|
+
// runs, while TypeScript stays green (a shorter override is assignable). Rename
|
|
672
|
+
// the handler. The lifecycle hooks are meant to be overridden, so only the DOM
|
|
673
|
+
// mutation members are reserved.
|
|
674
|
+
{
|
|
675
|
+
const NATIVE_MEMBERS = [
|
|
676
|
+
'append', 'prepend', 'before', 'after', 'replaceWith', 'replaceChildren', 'remove',
|
|
677
|
+
'appendChild', 'insertBefore', 'removeChild', 'replaceChild',
|
|
678
|
+
];
|
|
679
|
+
for (const { rel, scan } of files) {
|
|
680
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
681
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
682
|
+
// The slot interception installs only on LIGHT-DOM hosts (the shadow
|
|
683
|
+
// branch keeps the true native methods), so a shadow component's method
|
|
684
|
+
// is never shadowed. Skip the whole class.
|
|
685
|
+
if (/static\s+shadow\s*=\s*true/.test(body)) continue;
|
|
686
|
+
// Brace-depth map over the (already string/comment-masked) class body,
|
|
687
|
+
// so only TOP-LEVEL class members flag: a same-named object-literal
|
|
688
|
+
// shorthand or function expression nested inside a method body is a
|
|
689
|
+
// different object's property and shadows nothing.
|
|
690
|
+
const depth = new Int32Array(body.length);
|
|
691
|
+
{
|
|
692
|
+
let d = 0;
|
|
693
|
+
for (let i = 0; i < body.length; i++) {
|
|
694
|
+
if (body[i] === '{') { depth[i] = d; d++; }
|
|
695
|
+
else if (body[i] === '}') { d--; depth[i] = d; }
|
|
696
|
+
else depth[i] = d;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
for (const name of NATIVE_MEMBERS) {
|
|
700
|
+
// Two shadowed-definition shapes, both instance-level: the method /
|
|
701
|
+
// accessor form (`remove() {`, `get remove() {`), and the class-field
|
|
702
|
+
// FUNCTION form (`remove = () => {}` / `= function () {}`), which the
|
|
703
|
+
// connect-time interception install overwrites just the same. A
|
|
704
|
+
// `static` member lives on the constructor and shadows nothing.
|
|
705
|
+
const methodRe = new RegExp(
|
|
706
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(?:async\\s+)?(?:get\\s+|set\\s+)?(${name})\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`,
|
|
707
|
+
'g',
|
|
708
|
+
);
|
|
709
|
+
const fieldRe = new RegExp(
|
|
710
|
+
`(?:^|[\\s;}])((?:static\\s+)?)(${name})\\s*=\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*(?::[^=]*)?=>|[\\w$]+\\s*=>)`,
|
|
711
|
+
'g',
|
|
712
|
+
);
|
|
713
|
+
let flagged = false;
|
|
714
|
+
for (const re of [methodRe, fieldRe]) {
|
|
715
|
+
let m;
|
|
716
|
+
while (!flagged && (m = re.exec(body)) !== null) {
|
|
717
|
+
if (m[1]) continue; // static: not shadowed
|
|
718
|
+
const nameIdx = m.index + m[0].indexOf(m[2]);
|
|
719
|
+
if (depth[nameIdx] !== 0) continue; // nested: another object's property
|
|
720
|
+
flagged = true;
|
|
721
|
+
violations.push({
|
|
722
|
+
rule: 'no-shadowed-native-member',
|
|
723
|
+
file: rel,
|
|
724
|
+
message: `Component member \`${name}\` shadows the native DOM method WebJs instruments for the slot API, so it silently never runs (the native method wins) and TypeScript does not catch it.`,
|
|
725
|
+
fix: `Rename the member to a non-native name (e.g. \`${name}Row()\` / \`${name}Item()\`) and update its call sites.`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
if (flagged) break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
662
735
|
// --- Rule: no-redirect-in-api-route ---
|
|
663
736
|
// `redirect()` from `@webjsdev/core` throws a control-flow signal designed
|
|
664
737
|
// for the SSR page renderer. In a `route.ts` API handler it goes uncaught
|
package/src/dev.js
CHANGED
|
@@ -2259,21 +2259,40 @@ async function runWithSegmentMiddleware(req, files, terminal, dev) {
|
|
|
2259
2259
|
}
|
|
2260
2260
|
|
|
2261
2261
|
/**
|
|
2262
|
-
*
|
|
2262
|
+
* Root-middleware filename candidates, in resolution order.
|
|
2263
|
+
*
|
|
2264
|
+
* Every other routing convention accepts all four extensions (the router
|
|
2265
|
+
* matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
|
|
2266
|
+
* and both the scaffold and the dev supervisor write / watch `middleware.ts`.
|
|
2267
|
+
* This lookup used to be the single literal `middleware.js`, so a root
|
|
2268
|
+
* `middleware.ts` was silently never loaded: no error, no warning, the app
|
|
2269
|
+
* just ran with no global middleware. TypeScript is the documented default
|
|
2270
|
+
* for an app, so that was the common case, and it went unnoticed because the
|
|
2271
|
+
* failure is invisible (a missing middleware looks exactly like an app that
|
|
2272
|
+
* has none). `.ts` is tried first to match the dev supervisor's order.
|
|
2273
|
+
*/
|
|
2274
|
+
const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* Load the optional top-level `middleware.{ts,js,mts,mjs}`.
|
|
2263
2278
|
* @param {string} appDir
|
|
2264
2279
|
* @param {boolean} dev
|
|
2265
2280
|
* @param {import('./logger.js').Logger} logger
|
|
2266
2281
|
*/
|
|
2267
2282
|
async function loadMiddleware(appDir, dev, logger) {
|
|
2268
|
-
|
|
2269
|
-
|
|
2283
|
+
let file = null;
|
|
2284
|
+
for (const name of ROOT_MIDDLEWARE_FILES) {
|
|
2285
|
+
const candidate = join(appDir, name);
|
|
2286
|
+
if (await exists(candidate)) { file = candidate; break; }
|
|
2287
|
+
}
|
|
2288
|
+
if (!file) return null;
|
|
2270
2289
|
const url = pathToFileURL(file).toString();
|
|
2271
2290
|
const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
2272
2291
|
try {
|
|
2273
2292
|
const mod = await import(url + bust);
|
|
2274
2293
|
return typeof mod.default === 'function' ? mod.default : null;
|
|
2275
2294
|
} catch (e) {
|
|
2276
|
-
logger.error('failed to load middleware
|
|
2295
|
+
logger.error('failed to load root middleware', { file, err: String(e) });
|
|
2277
2296
|
return null;
|
|
2278
2297
|
}
|
|
2279
2298
|
}
|
|
@@ -2575,7 +2594,7 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError)
|
|
|
2575
2594
|
`Replace enum / namespace / parameter-property / legacy-decorator / ` +
|
|
2576
2595
|
`import = require constructs with their erasable equivalents. ` +
|
|
2577
2596
|
`Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
|
|
2578
|
-
`Docs: https://
|
|
2597
|
+
`Docs: https://webjs.dev/docs/typescript`
|
|
2579
2598
|
// Prod: terse, no path leak, no Node-message leak (Node's
|
|
2580
2599
|
// message can include source snippets). Operators get the
|
|
2581
2600
|
// detail in server logs above.
|
package/src/forwarded.js
CHANGED
|
@@ -32,23 +32,178 @@
|
|
|
32
32
|
* @returns {URL}
|
|
33
33
|
*/
|
|
34
34
|
export function urlFromRequest(req) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
const { host, proto } = readForwarded((n) => /** @type {any} */ (req.headers)[n]);
|
|
36
|
+
const rawHost = /** @type {string|undefined} */ (req.headers.host) || '';
|
|
37
|
+
const u = resolveOrigin(proto || 'http', host, rawHost);
|
|
38
|
+
// Assign the request target's parts rather than RESOLVING it against `u`.
|
|
39
|
+
// Resolving is unsafe: a request line may carry `//evil.com/x`, a
|
|
40
|
+
// scheme-relative reference, so `new URL('//evil.com/x', 'https://real-host')`
|
|
41
|
+
// resolves to `https://evil.com/x`, handing over the origin AND silently
|
|
42
|
+
// rewriting the path to `/x` so a different route matches. A request target
|
|
43
|
+
// is a path, never a full reference, so it is assigned as one.
|
|
44
|
+
const m = /^([^?#]*)(\?[^#]*)?(#.*)?$/.exec(req.url || '/');
|
|
45
|
+
u.pathname = (m && m[1]) || '/';
|
|
46
|
+
u.search = (m && m[2]) || '';
|
|
47
|
+
u.hash = (m && m[3]) || '';
|
|
48
|
+
return u;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the origin both entry points agree on, as an origin-only URL.
|
|
53
|
+
*
|
|
54
|
+
* Sharing this is what keeps the ORIGIN decision identical across the two
|
|
55
|
+
* shells rather than a coincidence of two similar-looking expressions. The
|
|
56
|
+
* layering matters: start from a known-good placeholder, apply the raw `Host`,
|
|
57
|
+
* then let the forwarded host override it. Each assignment goes through
|
|
58
|
+
* `setHost`, which ignores a value it cannot parse, so a junk header falls back
|
|
59
|
+
* to the previous layer rather than 500ing the request or collapsing to
|
|
60
|
+
* localhost.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} proto http / https. `normalizeProto` guarantees it for a
|
|
63
|
+
* forwarded value; the callers' fallbacks pass the url's own scheme, which is
|
|
64
|
+
* http or https for any request a listener shell can receive.
|
|
65
|
+
* @param {string | null} forwardedHost
|
|
66
|
+
* @param {string} rawHost the `Host` header
|
|
67
|
+
* @returns {URL} an origin-only URL
|
|
68
|
+
*/
|
|
69
|
+
function resolveOrigin(proto, forwardedHost, rawHost) {
|
|
70
|
+
const u = new URL(`${proto}://localhost`);
|
|
71
|
+
if (rawHost) setHost(u, rawHost);
|
|
72
|
+
if (forwardedHost) setHost(u, forwardedHost);
|
|
73
|
+
return u;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Assign an authority, clearing any port the previous layer left behind.
|
|
78
|
+
*
|
|
79
|
+
* The `host` setter only updates the port when the new value CARRIES one, so
|
|
80
|
+
* layering `X-Forwarded-Host: docs.webjs.dev` over `Host: container:3000`
|
|
81
|
+
* otherwise yields `docs.webjs.dev:3000`: the public hostname wearing the
|
|
82
|
+
* internal port. Clearing first makes each layer a full replacement.
|
|
83
|
+
*
|
|
84
|
+
* @param {URL} u
|
|
85
|
+
* @param {string} value
|
|
86
|
+
*/
|
|
87
|
+
function setHost(u, value) {
|
|
88
|
+
let probe;
|
|
89
|
+
try {
|
|
90
|
+
probe = new URL(`${u.protocol}//${value}`);
|
|
91
|
+
} catch {
|
|
92
|
+
// Not a parseable authority (`a b`, `[`, a port over 65535). Leave the
|
|
93
|
+
// previous layer in place rather than throwing, so a junk header is never a
|
|
94
|
+
// 500 on the fetch path or a failed WS handshake.
|
|
95
|
+
return;
|
|
41
96
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
97
|
+
u.hostname = probe.hostname;
|
|
98
|
+
// Assign the port unconditionally, including the empty string, so a value
|
|
99
|
+
// carrying no port clears one the previous layer left behind.
|
|
100
|
+
u.port = probe.port;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Apply the forwarded headers to an ALREADY-PARSED url, for a shell whose
|
|
105
|
+
* request is a web `Request` (Bun) rather than a node `IncomingMessage`.
|
|
106
|
+
*
|
|
107
|
+
* `urlFromRequest` above cannot be reused directly: it reads `req.headers` as a
|
|
108
|
+
* plain node object, while a web `Request` exposes a `Headers` instance whose
|
|
109
|
+
* values come from `.get()`. Reusing it against a `Request` silently reads
|
|
110
|
+
* `undefined` for every header, so it LOOKS wired up while changing nothing.
|
|
111
|
+
* Both entry points funnel through `readForwarded` (the trust switch, the
|
|
112
|
+
* allowed schemes, the comma-chain rule) and `resolveOrigin` (the host
|
|
113
|
+
* layering), so the HEADER and ORIGIN decisions cannot drift. What each shell
|
|
114
|
+
* RECEIVES still differs: node gets an origin-form request target it treats as
|
|
115
|
+
* a path, Bun gets a url its own parser already resolved, so an absolute-form
|
|
116
|
+
* request line routes differently between them. The security-relevant part, the
|
|
117
|
+
* origin, is decided here for both.
|
|
118
|
+
*
|
|
119
|
+
* Returns the SAME URL instance when nothing changes, so the caller can use
|
|
120
|
+
* identity to skip rebuilding a request on the hot path. That fast path also
|
|
121
|
+
* requires the url's own authority to already AGREE with the `Host` header:
|
|
122
|
+
* `Bun.serve` reports an absolute-form request line (`GET http://evil/x`) as
|
|
123
|
+
* the request's url, so returning early on "no forwarded headers" would let an
|
|
124
|
+
* unproxied app hand a client full control of `ctx.url.origin`. When they
|
|
125
|
+
* disagree the origin is rebuilt from `Host` and the client's authority is
|
|
126
|
+
* discarded. A normal request has `url.host === Host`, so the optimization is
|
|
127
|
+
* intact for every real request.
|
|
128
|
+
*
|
|
129
|
+
* The origin is rebuilt through the shared `resolveOrigin`, and the path parts
|
|
130
|
+
* are COPIED onto it rather than re-parsed against it. Re-parsing is unsafe:
|
|
131
|
+
* `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
|
|
132
|
+
* reference, so `new URL('//evil.com/x', 'https://real-host')` resolves to
|
|
133
|
+
* `https://evil.com/x`. An attacker needed only the `X-Forwarded-Proto` every
|
|
134
|
+
* TLS-terminating proxy already sets to take over the origin AND silently
|
|
135
|
+
* change which route matched.
|
|
136
|
+
*
|
|
137
|
+
* The host FALLBACK is the `Host` header, not `url.host`, to match
|
|
138
|
+
* `urlFromRequest` exactly: node builds from the raw header string, so with
|
|
139
|
+
* `Host: webjs.dev:80` + `X-Forwarded-Proto: https` it judges the `:80` against
|
|
140
|
+
* https and keeps it. Falling back to the already-normalized `url.host` dropped
|
|
141
|
+
* it (port 80 is http's default), which made the two shells disagree on the
|
|
142
|
+
* exact proto-only shape this helper exists for.
|
|
143
|
+
*
|
|
144
|
+
* @param {URL} url the url as the local listener saw it
|
|
145
|
+
* @param {Headers} headers the web `Request` headers
|
|
146
|
+
* @returns {URL} the corrected url, or `url` itself when unchanged
|
|
147
|
+
*/
|
|
148
|
+
export function applyForwarded(url, headers) {
|
|
149
|
+
const { host, proto } = readForwarded((n) => headers.get(n));
|
|
150
|
+
const rawHost = headers.get('host') || url.host;
|
|
151
|
+
if (!host && !proto && url.host === rawHost) return url;
|
|
152
|
+
// `url.protocol` carries its trailing colon; the forwarded header does not.
|
|
153
|
+
const origin = resolveOrigin(proto || url.protocol.slice(0, -1), host, rawHost);
|
|
154
|
+
if (origin.host === url.host && origin.protocol === url.protocol) return url;
|
|
155
|
+
// Copy the path across verbatim. Never re-parse it against the new origin:
|
|
156
|
+
// `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
|
|
157
|
+
// reference, so resolving would resolve the authority out of the PATH and
|
|
158
|
+
// hand an attacker the origin (plus a different matched route) using only the
|
|
159
|
+
// `X-Forwarded-Proto` every TLS-terminating proxy already sets.
|
|
160
|
+
origin.pathname = url.pathname;
|
|
161
|
+
origin.search = url.search;
|
|
162
|
+
origin.hash = url.hash;
|
|
163
|
+
return origin;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read the forwarded host / proto through a header getter, honoring the
|
|
168
|
+
* `WEBJS_NO_TRUST_PROXY=1` opt-out. The one place the trust decision and the
|
|
169
|
+
* comma-chain rule live, shared by the node and Bun entry points above.
|
|
170
|
+
*
|
|
171
|
+
* @param {(name: string) => string | string[] | undefined | null} getHeader
|
|
172
|
+
* @returns {{ host: string | null, proto: string | null }}
|
|
173
|
+
*/
|
|
174
|
+
function readForwarded(getHeader) {
|
|
175
|
+
if (process.env.WEBJS_NO_TRUST_PROXY === '1') return { host: null, proto: null };
|
|
176
|
+
return {
|
|
177
|
+
host: firstHeaderValue(getHeader('x-forwarded-host')),
|
|
178
|
+
proto: normalizeProto(firstHeaderValue(getHeader('x-forwarded-proto'))),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Accept only `http` / `https` as a forwarded scheme (case-insensitively; the
|
|
184
|
+
* URL normal form is lowercase, and comparing a raw `HTTPS` against
|
|
185
|
+
* `url.protocol` otherwise misses the no-op case).
|
|
186
|
+
*
|
|
187
|
+
* Anything else is dropped rather than honored. A non-special scheme is the
|
|
188
|
+
* damaging shape: `X-Forwarded-Proto: javascript` produced
|
|
189
|
+
* `javascript://host/path`, whose `origin` is the literal string `null`, so
|
|
190
|
+
* every absolute URL the app derived became `null/...`. A proxy in front of an
|
|
191
|
+
* HTTP server only ever forwards http or https, so an allowlist costs nothing.
|
|
192
|
+
*
|
|
193
|
+
* @param {string | null} p
|
|
194
|
+
* @returns {string | null}
|
|
195
|
+
*/
|
|
196
|
+
function normalizeProto(p) {
|
|
197
|
+
if (!p) return null;
|
|
198
|
+
const v = p.toLowerCase();
|
|
199
|
+
return v === 'http' || v === 'https' ? v : null;
|
|
45
200
|
}
|
|
46
201
|
|
|
47
202
|
/**
|
|
48
203
|
* Pick the first comma-separated value from a header that may be a
|
|
49
204
|
* string, an array of strings, or undefined.
|
|
50
205
|
*
|
|
51
|
-
* @param {string | string[] | undefined} h
|
|
206
|
+
* @param {string | string[] | undefined | null} h
|
|
52
207
|
* @returns {string | null}
|
|
53
208
|
*/
|
|
54
209
|
function firstHeaderValue(h) {
|
package/src/listener-bun.js
CHANGED
|
@@ -45,7 +45,8 @@ import { EventEmitter } from 'node:events';
|
|
|
45
45
|
import { Readable, pipeline } from 'node:stream';
|
|
46
46
|
import { matchApi } from './router.js';
|
|
47
47
|
import { registerClient } from './broadcast.js';
|
|
48
|
-
import { setTrustedRemoteIp } from './rate-limit.js';
|
|
48
|
+
import { setTrustedRemoteIp, propagateTrustedRemoteIp } from './rate-limit.js';
|
|
49
|
+
import { applyForwarded } from './forwarded.js';
|
|
49
50
|
import {
|
|
50
51
|
isCompressible,
|
|
51
52
|
isEventsPath,
|
|
@@ -104,7 +105,7 @@ export function startBunListener(ctx) {
|
|
|
104
105
|
// head-start during SSR compute, not the preloads themselves.
|
|
105
106
|
|
|
106
107
|
stampRemoteIp(req, srv);
|
|
107
|
-
const resp = await app.handle(req);
|
|
108
|
+
const resp = await app.handle(forwardedRequest(req, url));
|
|
108
109
|
return compress ? await maybeCompress(resp, req) : resp;
|
|
109
110
|
} catch (e) {
|
|
110
111
|
logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
|
|
@@ -237,7 +238,11 @@ async function bunUpgrade(req, srv, ctx) {
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
const wrapper = new BunWsAdapter();
|
|
240
|
-
|
|
241
|
+
// Behind a proxy the handshake arrives as plain http on the internal hop, so
|
|
242
|
+
// the handler request carries the ORIGINAL scheme + host too (the node WS path
|
|
243
|
+
// gets this from `urlFromRequest` in `buildRequestFromUpgrade`). Routing above
|
|
244
|
+
// keyed on `url.pathname`, which the origin swap leaves untouched.
|
|
245
|
+
const handlerReq = upgradeRequest(req, applyForwarded(url, req.headers));
|
|
241
246
|
// Stamp the framework-trusted socket IP on the handler request so a `WS`
|
|
242
247
|
// handler's `clientIp(req)` returns the real peer, not a spoofed inbound
|
|
243
248
|
// `x-webjs-remote-ip` (#778). `srv.requestIP` must be queried with Bun's
|
|
@@ -317,6 +322,78 @@ function bunSseResponse(req, hub, app) {
|
|
|
317
322
|
});
|
|
318
323
|
}
|
|
319
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Rebuild the request behind a TLS-terminating reverse proxy so `req.url`
|
|
327
|
+
* carries the ORIGINAL scheme + host (`X-Forwarded-Proto` / `X-Forwarded-Host`).
|
|
328
|
+
*
|
|
329
|
+
* Why the request itself and not just a threaded url: everything downstream
|
|
330
|
+
* (the framework's own `new URL(req.url)` reads AND a user's `route.ts` doing
|
|
331
|
+
* `new URL(req.url).origin`) derives from `req.url`, so correcting only a side
|
|
332
|
+
* channel would fix the framework's absolute URLs and leave app code broken.
|
|
333
|
+
* The node shell gets this for free, since `toWebRequest` builds its `Request`
|
|
334
|
+
* FROM the already-corrected `urlFromRequest(req)`; Bun hands us a `Request`
|
|
335
|
+
* whose url reflects the internal `http://container` view, so the correction
|
|
336
|
+
* has to be applied here for the two shells to agree.
|
|
337
|
+
*
|
|
338
|
+
* Hot path: `applyForwarded` returns the SAME url instance when nothing
|
|
339
|
+
* changes, so an app with no proxy in front (local dev, direct exposure,
|
|
340
|
+
* `WEBJS_NO_TRUST_PROXY=1`) does ZERO extra work and keeps the #756 no-clone
|
|
341
|
+
* behaviour. Behind a proxy this costs one `Request` construction per request,
|
|
342
|
+
* which is still strictly less than the node shell, which constructs one
|
|
343
|
+
* unconditionally. The trusted IP is carried across the rebuild the same way
|
|
344
|
+
* the base-path rewrite in `dev.js` does it (#773), so `clientIp` stays
|
|
345
|
+
* authoritative and the inbound spoofable header is never consulted.
|
|
346
|
+
*
|
|
347
|
+
* @param {Request} req
|
|
348
|
+
* @param {URL} url the url as `Bun.serve` saw it
|
|
349
|
+
* @returns {Request} `req` itself when unchanged, else the corrected request
|
|
350
|
+
*/
|
|
351
|
+
function forwardedRequest(req, url) {
|
|
352
|
+
const fwd = applyForwarded(url, req.headers);
|
|
353
|
+
if (fwd === url) return req;
|
|
354
|
+
// SECURITY (#778): drop any client-supplied `x-webjs-remote-ip` before it
|
|
355
|
+
// rides onto the rebuilt request. `trustedRemoteIp` reads the WeakMap first,
|
|
356
|
+
// so the header cannot win today, but every other rebuild site strips it
|
|
357
|
+
// (`dev.js`'s base-path rewrite, `upgradeRequest` below) and leaving it
|
|
358
|
+
// attached would re-arm the spoof the moment any later code path rebuilds
|
|
359
|
+
// this request without carrying the WeakMap across.
|
|
360
|
+
const headers = new Headers(req.headers);
|
|
361
|
+
headers.delete('x-webjs-remote-ip');
|
|
362
|
+
const next = new Request(fwd, { ...requestInitFrom(req), headers });
|
|
363
|
+
propagateTrustedRemoteIp(req, next);
|
|
364
|
+
return next;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The parts of a `Request` that must survive the rebuild, spelled out because
|
|
369
|
+
* the header strip above needs its own `Headers` and passing the `Request` as
|
|
370
|
+
* the init would ignore them.
|
|
371
|
+
*
|
|
372
|
+
* `duplex: 'half'` is required by the spec whenever a body is present, and
|
|
373
|
+
* `mode: 'navigate'` cannot be passed to the constructor, so it is dropped.
|
|
374
|
+
*
|
|
375
|
+
* @param {Request} req
|
|
376
|
+
*/
|
|
377
|
+
function requestInitFrom(req) {
|
|
378
|
+
/** @type {any} */
|
|
379
|
+
const init = {
|
|
380
|
+
method: req.method,
|
|
381
|
+
signal: req.signal,
|
|
382
|
+
credentials: req.credentials,
|
|
383
|
+
cache: req.cache,
|
|
384
|
+
redirect: req.redirect,
|
|
385
|
+
referrer: req.referrer,
|
|
386
|
+
referrerPolicy: req.referrerPolicy,
|
|
387
|
+
integrity: req.integrity,
|
|
388
|
+
};
|
|
389
|
+
if (req.mode && req.mode !== 'navigate') init.mode = req.mode;
|
|
390
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
391
|
+
init.body = req.body;
|
|
392
|
+
init.duplex = 'half';
|
|
393
|
+
}
|
|
394
|
+
return init;
|
|
395
|
+
}
|
|
396
|
+
|
|
320
397
|
/**
|
|
321
398
|
* Stamp the framework-trusted remote IP from Bun's `server.requestIP`,
|
|
322
399
|
* out-of-band via `setTrustedRemoteIp` (#756). The previous implementation
|
package/src/ssr.js
CHANGED
|
@@ -1124,18 +1124,27 @@ function wrapInDocument(body, opts) {
|
|
|
1124
1124
|
*/
|
|
1125
1125
|
function collectHoistedHeadTags(bodyHtml) {
|
|
1126
1126
|
const tags = [];
|
|
1127
|
-
// <script>…</script> and <style>…</style> are paired; <link …>
|
|
1128
|
-
// A plain HTML comment (<!-- … -->) is consumed but NOT hoisted, so
|
|
1129
|
-
// comment interleaved with head-bound tags (e.g. "<!-- Self-hosted fonts -->"
|
|
1127
|
+
// <script>…</script> and <style>…</style> are paired; <link …> and <meta …>
|
|
1128
|
+
// are void. A plain HTML comment (<!-- … -->) is consumed but NOT hoisted, so
|
|
1129
|
+
// a comment interleaved with head-bound tags (e.g. "<!-- Self-hosted fonts -->"
|
|
1130
1130
|
// between a favicon <link> and the stylesheet <link>) does not terminate
|
|
1131
1131
|
// the leading run and strand the stylesheet in <body>, which caused FOUC
|
|
1132
1132
|
// because a <link rel="stylesheet"> in <body> is not reliably
|
|
1133
|
-
// render-blocking (#406).
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1133
|
+
// render-blocking (#406). A <meta …> is treated the same way for the same
|
|
1134
|
+
// reason: a <meta name="color-scheme"> between the theme <script> and the
|
|
1135
|
+
// stylesheet <link> would otherwise terminate the run and strand the
|
|
1136
|
+
// stylesheet AND a <link rel="icon"> in <body> (a favicon in <body> is
|
|
1137
|
+
// ignored by browsers, so the icon silently never renders). The `(?!/?wj:)`
|
|
1138
|
+
// guard exempts client-router markers (<!--wj:children:…-->,
|
|
1139
|
+
// <!--/wj:children-->) so a layout that renders children directly after its
|
|
1140
|
+
// head tags still terminates the run there rather than swallowing the marker.
|
|
1141
|
+
// The void-tag matchers are QUOTE-AWARE ((?:[^>"']|"[^"]*"|'[^']*')*): a `>`
|
|
1142
|
+
// inside a quoted attribute value (a description meta like
|
|
1143
|
+
// content="Guides > API") must not terminate the tag early, which would hoist
|
|
1144
|
+
// a truncated tag, leak the remainder as visible body text, and strand the
|
|
1145
|
+
// stylesheet (the #406 FOUC this hoist exists to prevent).
|
|
1137
1146
|
const re =
|
|
1138
|
-
/^\s*(<!--(?!\/?wj:)[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
|
|
1147
|
+
/^\s*(<!--(?!\/?wj:)[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b(?:[^>"']|"[^"]*"|'[^']*')*>|<meta\b(?:[^>"']|"[^"]*"|'[^']*')*>)/i;
|
|
1139
1148
|
let remaining = bodyHtml;
|
|
1140
1149
|
// `body` only advances to just-past the LAST hoisted head tag. Comments
|
|
1141
1150
|
// are scanned through (so they don't terminate the run) but a comment that
|