@webjsdev/server 0.8.58 → 0.8.60
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/index.d.ts +125 -12
- package/index.js +2 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +438 -70
- package/src/actions.js +10 -2
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/component-elision.js +75 -16
- package/src/component-scanner.js +82 -26
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +151 -33
- package/src/elision-differential.js +101 -0
- package/src/elision-report.js +133 -30
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +666 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/js-scan.js +41 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +93 -17
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/forwarded.js
CHANGED
|
@@ -12,13 +12,38 @@
|
|
|
12
12
|
* breaks OG / og:image tags, OAuth callback URLs, and any user code
|
|
13
13
|
* that builds absolute URLs.
|
|
14
14
|
*
|
|
15
|
-
* Threat model:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* Threat model: the two headers have DIFFERENT trust properties, so do
|
|
16
|
+
* not reason about them as one.
|
|
17
|
+
*
|
|
18
|
+
* `X-Forwarded-Proto` is written by the proxy that terminates TLS, which
|
|
19
|
+
* OVERWRITES whatever the client sent. In WebJs's typical deployment
|
|
20
|
+
* topology the container's HTTP port is only reachable through that
|
|
21
|
+
* trusted edge, so the value this code reads is the edge's, not the
|
|
22
|
+
* client's. The guarantee is the overwrite, not the topology: an edge
|
|
23
|
+
* that APPENDS instead would put the client's entry first in the chain,
|
|
24
|
+
* and the comma rule below takes the first entry.
|
|
25
|
+
*
|
|
26
|
+
* `X-Forwarded-Host` is NOT covered by that argument. Cloudflare and
|
|
27
|
+
* Railway FORWARD a client-supplied `X-Forwarded-Host` rather than
|
|
28
|
+
* overwriting it, so going through the trusted proxy is exactly how a
|
|
29
|
+
* hostile value arrives. Treat a forged forwarded host as reachable in
|
|
30
|
+
* the NORMAL topology, not just on a directly-exposed container.
|
|
31
|
+
* Consequence, and the rule to apply to your own code: anything SHARED
|
|
32
|
+
* that is derived from the resulting origin must be KEYED by that origin
|
|
33
|
+
* rather than assumed constant. That is what `html-cache.js` does, folding
|
|
34
|
+
* `url.origin` into every cache key (#1097), after a single request
|
|
35
|
+
* carrying a hostile host could poison a `revalidate` page for every
|
|
36
|
+
* later visitor.
|
|
37
|
+
*
|
|
38
|
+
* `WEBJS_NO_TRUST_PROXY=1` makes THIS module ignore both headers and
|
|
39
|
+
* fall back to the raw `Host` header and the `http://` default. It is
|
|
40
|
+
* the remedy for a directly-exposed container, and it also narrows the
|
|
41
|
+
* forwarded-host exposure above, so it is not only a bare-VM concern.
|
|
42
|
+
* That switch is `trustProxy()` below, and it is now the ONE place the
|
|
43
|
+
* posture is decided: `csrf.js`'s `requestHost` used to read
|
|
44
|
+
* `x-forwarded-host` without consulting it, which is what #1104
|
|
45
|
+
* centralized. See `trustProxy()` for the full consumer list and for the
|
|
46
|
+
* direction rule that resolves it against the per-call `trustProxy` option.
|
|
22
47
|
*
|
|
23
48
|
* Header semantics:
|
|
24
49
|
* - `X-Forwarded-Host` / `X-Forwarded-Proto` can be a comma-separated
|
|
@@ -163,16 +188,49 @@ export function applyForwarded(url, headers) {
|
|
|
163
188
|
return origin;
|
|
164
189
|
}
|
|
165
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Is a reverse proxy in front of this process trusted to speak for the client?
|
|
193
|
+
*
|
|
194
|
+
* The ONE place the ORIGIN posture is decided (#1104): every reader of the
|
|
195
|
+
* forwarded HOST and PROTO goes through this, namely the URL rewrite here, the
|
|
196
|
+
* HSTS scheme gate in `headers.js`, and the CSRF host resolution in `csrf.js`.
|
|
197
|
+
* It used to be three separate reads with two different answers, so
|
|
198
|
+
* `WEBJS_NO_TRUST_PROXY=1` turned off two of them and an operator could not
|
|
199
|
+
* tell from any single file whether their app trusted forwarded headers.
|
|
200
|
+
*
|
|
201
|
+
* SCOPE: `clientIp` in `rate-limit.js` is a consumer too (#1254), so the switch
|
|
202
|
+
* now covers every forwarded-header read in the package, the client-IP ones
|
|
203
|
+
* (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`) included. The name
|
|
204
|
+
* collision is still real: `trustProxy()` here is env-backed and package-wide,
|
|
205
|
+
* `trustProxy` there is a per-call boolean. They point the same direction, and
|
|
206
|
+
* the DIRECTION RULE is what resolves them. This switch only ever SUBTRACTS
|
|
207
|
+
* trust; the per-call option can only add it while this switch is unset. So
|
|
208
|
+
* `rateLimit({ trustProxy: true })` under `WEBJS_NO_TRUST_PROXY=1` resolves to
|
|
209
|
+
* the framework-stamped peer, because an env switch that two subsystems honour
|
|
210
|
+
* and one ignores cannot tell an operator their posture, and because the
|
|
211
|
+
* failure mode of an over-firing override is a shared bucket (visible,
|
|
212
|
+
* self-inflicted) rather than a limiter keyed on a header the client chooses.
|
|
213
|
+
*
|
|
214
|
+
* Read from `process.env` at CALL time, never cached at boot, so a test (and a
|
|
215
|
+
* runtime that mutates its own env) can toggle it per case.
|
|
216
|
+
*
|
|
217
|
+
* @returns {boolean}
|
|
218
|
+
*/
|
|
219
|
+
export function trustProxy() {
|
|
220
|
+
return process.env.WEBJS_NO_TRUST_PROXY !== '1';
|
|
221
|
+
}
|
|
222
|
+
|
|
166
223
|
/**
|
|
167
224
|
* Read the forwarded host / proto through a header getter, honoring the
|
|
168
|
-
* `
|
|
169
|
-
* comma-chain
|
|
225
|
+
* `trustProxy()` opt-out. The one place the HEADER SHAPE rules (the
|
|
226
|
+
* comma-chain, the scheme allowlist) live, shared by the node and Bun entry
|
|
227
|
+
* points above.
|
|
170
228
|
*
|
|
171
229
|
* @param {(name: string) => string | string[] | undefined | null} getHeader
|
|
172
230
|
* @returns {{ host: string | null, proto: string | null }}
|
|
173
231
|
*/
|
|
174
232
|
function readForwarded(getHeader) {
|
|
175
|
-
if (
|
|
233
|
+
if (!trustProxy()) return { host: null, proto: null };
|
|
176
234
|
return {
|
|
177
235
|
host: firstHeaderValue(getHeader('x-forwarded-host')),
|
|
178
236
|
proto: normalizeProto(firstHeaderValue(getHeader('x-forwarded-proto'))),
|
package/src/headers.js
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* touch the response pipeline again.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import { trustProxy } from './forwarded.js';
|
|
33
|
+
|
|
32
34
|
/**
|
|
33
35
|
* Baseline security headers, as literal name -> value pairs. Set only
|
|
34
36
|
* when absent, so an app override always wins. HSTS is NOT here: it is
|
|
@@ -190,18 +192,18 @@ export function applySecurityHeaders(res, ctx) {
|
|
|
190
192
|
|
|
191
193
|
/**
|
|
192
194
|
* Detect whether the original client request was HTTPS, from a web
|
|
193
|
-
* `Request` (the shape `handle()` works with).
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
195
|
+
* `Request` (the shape `handle()` works with). Reads `X-Forwarded-Proto`
|
|
196
|
+
* only when `trustProxy()` says a proxy speaks for the client; otherwise
|
|
197
|
+
* falls back to the request URL's scheme. Never trusts the header blindly.
|
|
198
|
+
*
|
|
199
|
+
* The posture comes from `forwarded.js` rather than an inline `process.env`
|
|
200
|
+
* read (#1104), so this gate cannot drift from the URL rewrite it shadows.
|
|
198
201
|
*
|
|
199
202
|
* @param {Request} req
|
|
200
203
|
* @returns {boolean}
|
|
201
204
|
*/
|
|
202
205
|
export function webRequestIsHttps(req) {
|
|
203
|
-
|
|
204
|
-
if (trust) {
|
|
206
|
+
if (trustProxy()) {
|
|
205
207
|
const proto = req.headers.get('x-forwarded-proto');
|
|
206
208
|
if (proto) return proto.split(',')[0].trim().toLowerCase() === 'https';
|
|
207
209
|
}
|
package/src/html-cache.js
CHANGED
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
import { getStore } from './cache.js';
|
|
28
28
|
import { STREAM_MARKER } from './conditional-get.js';
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
|
-
import { publishedBuildId } from './importmap.js';
|
|
31
|
-
import {
|
|
30
|
+
import { publishedBuildId, basePath } from './importmap.js';
|
|
31
|
+
import { stripBasePath } from './base-path.js';
|
|
32
|
+
import { dynamicAccessed, getRequest } from './context.js';
|
|
32
33
|
|
|
33
34
|
/** Namespace prefix for every cached-HTML key, so a flush can target it. */
|
|
34
35
|
const KEY_PREFIX = 'webjs:html:';
|
|
@@ -109,11 +110,37 @@ export function readRevalidate(pageModule) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/**
|
|
112
|
-
* The
|
|
113
|
-
* `
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* The origin component of a cache key. `url.origin` is already the normalized
|
|
114
|
+
* lowercase `scheme://host[:port]` form for an http(s) URL; a non-special
|
|
115
|
+
* scheme serializes to the literal string `null`, which is a fine constant key
|
|
116
|
+
* component (such a URL never reaches a cacheable page render anyway).
|
|
116
117
|
*
|
|
118
|
+
* @param {URL} url
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
function originKey(url) {
|
|
122
|
+
return url.origin;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The cache key for a request: the ORIGIN plus the full path + search string,
|
|
127
|
+
* since both `ctx.url.origin` and `searchParams` change page output.
|
|
128
|
+
* Normalized to path + sorted query so `?a=1&b=2` and `?b=2&a=1` share an
|
|
129
|
+
* entry. Four discriminators are folded into the namespace:
|
|
130
|
+
*
|
|
131
|
+
* - the origin (#1097). This cache is SHARED across visitors, and `ctx.url`
|
|
132
|
+
* is built from `X-Forwarded-Host` / `X-Forwarded-Proto`, which neither
|
|
133
|
+
* Cloudflare nor Railway strips from a client request. Without the origin
|
|
134
|
+
* in the key, one request carrying `X-Forwarded-Host: evil.example` baked
|
|
135
|
+
* an attacker-chosen origin into the shared body, and every later visitor
|
|
136
|
+
* to that path was served it until the entry expired: a poisoned
|
|
137
|
+
* `og:image`, canonical link, OAuth callback URL, or absolute asset URL.
|
|
138
|
+
* With the origin in the key, a clean request resolves its origin from the
|
|
139
|
+
* proxy-set `Host` and reads its OWN entry, so the hostile body is
|
|
140
|
+
* unreachable from any origin an attacker does not already control. A
|
|
141
|
+
* single-host deploy has exactly one origin and so exactly one key per URL,
|
|
142
|
+
* unchanged hit rate; a genuine multi-host deploy gets correctly separated
|
|
143
|
+
* entries instead of cross-serving one host's HTML to another;
|
|
117
144
|
* - the in-process generation, so `revalidateAll()` (a generation bump)
|
|
118
145
|
* makes every prior key unreachable in one step;
|
|
119
146
|
* - the published build id (the importmap fingerprint), so a NEW DEPLOY
|
|
@@ -143,7 +170,7 @@ export function htmlCacheKey(url) {
|
|
|
143
170
|
// the importmap-only build id misses. Empty (dev / no fingerprint) collapses
|
|
144
171
|
// to the prior key shape, so an unconfigured app is byte-identical.
|
|
145
172
|
const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
|
|
146
|
-
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${url.pathname}${search}`;
|
|
173
|
+
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${originKey(url)}:${url.pathname}${search}`;
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
/**
|
|
@@ -221,17 +248,59 @@ export function isCacheableResponse(res, guards = {}) {
|
|
|
221
248
|
* ONLY the no-query entry; pass the exact `path?query` to target a
|
|
222
249
|
* specific query variant, or call `revalidateAll()` to clear everything.
|
|
223
250
|
*
|
|
224
|
-
*
|
|
251
|
+
* ORIGIN RESOLUTION (#1097). Cache keys carry the request's origin, so a bare
|
|
252
|
+
* path does not name one entry, it names one per origin the path was cached
|
|
253
|
+
* under. Two forms resolve it:
|
|
254
|
+
*
|
|
255
|
+
* 1. The CURRENT request's origin, when one is ambient. A mutation runs inside
|
|
256
|
+
* the request of the user who triggered it, and that user is on the same
|
|
257
|
+
* origin as the pages being evicted, so a bare path is exact for the
|
|
258
|
+
* ordinary "server action writes, then evicts" flow, which is every
|
|
259
|
+
* in-repo example and everything the docs show.
|
|
260
|
+
* 2. An ABSOLUTE url (`https://app.example/blog`) names its origin outright.
|
|
261
|
+
* This is the form for a caller with NO ambient request: a cron job, a
|
|
262
|
+
* queue worker, an `instrumentation.js` timer. Under `webjs.basePath` pass
|
|
263
|
+
* the PUBLIC url and the mount prefix is stripped for you, so it keys the
|
|
264
|
+
* same entry the write stored.
|
|
265
|
+
*
|
|
266
|
+
* A bare path with no ambient request cannot be resolved and WARNS ONCE naming
|
|
267
|
+
* the path, rather than silently evicting nothing.
|
|
268
|
+
*
|
|
269
|
+
* There is deliberately no process-local registry of "origins seen so far"
|
|
270
|
+
* standing in for case 2. The origin comes from `X-Forwarded-Host`, which is
|
|
271
|
+
* client-supplied and forwarded rather than stripped (the whole premise of
|
|
272
|
+
* #1097), so such a registry is attacker-writable: a flood of hostile hosts
|
|
273
|
+
* against a cold process either fills a bounded registry before the real origin
|
|
274
|
+
* is ever seen, or grows an unbounded one. Either way eviction silently stops
|
|
275
|
+
* working, which is worse than the explicit contract above.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} path e.g. '/blog', '/blog?page=2', or 'https://app.example/blog'
|
|
225
278
|
* @returns {Promise<void>}
|
|
226
279
|
*/
|
|
227
280
|
export async function revalidatePath(path) {
|
|
228
281
|
if (typeof path !== 'string' || !path) return;
|
|
229
|
-
// Build the same normalized key readHtmlCache / writeHtmlCache produce.
|
|
230
282
|
let url;
|
|
231
|
-
|
|
232
|
-
url = new URL(path
|
|
233
|
-
|
|
234
|
-
|
|
283
|
+
if (/^https?:\/\//i.test(path)) {
|
|
284
|
+
try { url = new URL(path); } catch { return; }
|
|
285
|
+
// An absolute url is the PUBLIC one, so under `webjs.basePath` it carries
|
|
286
|
+
// the mount prefix. Cache entries key on the app-root-relative path (the
|
|
287
|
+
// write strips it in dev.js for exactly this reason), so strip it here too
|
|
288
|
+
// or the caller who was told to use this form, the one with no ambient
|
|
289
|
+
// request, computes a key nothing was ever stored under. A path that is
|
|
290
|
+
// not under the base path is left alone, matching the write's behaviour.
|
|
291
|
+
const base = basePath();
|
|
292
|
+
if (base) {
|
|
293
|
+
const stripped = stripBasePath(url.pathname, base);
|
|
294
|
+
if (stripped !== null) url.pathname = stripped;
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
const origin = ambientOrigin();
|
|
298
|
+
if (!origin) {
|
|
299
|
+
warnUnresolvedOriginOnce(path);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Build the same normalized key readHtmlCache / writeHtmlCache produce.
|
|
303
|
+
try { url = new URL(path, origin); } catch { return; }
|
|
235
304
|
}
|
|
236
305
|
try {
|
|
237
306
|
await getStore().delete(htmlCacheKey(url));
|
|
@@ -240,6 +309,41 @@ export async function revalidatePath(path) {
|
|
|
240
309
|
}
|
|
241
310
|
}
|
|
242
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The current request's origin, or '' when there is no ambient request. Never
|
|
314
|
+
* throws: an eviction helper must not take down the mutation that called it.
|
|
315
|
+
*
|
|
316
|
+
* @returns {string}
|
|
317
|
+
*/
|
|
318
|
+
function ambientOrigin() {
|
|
319
|
+
try {
|
|
320
|
+
const req = getRequest();
|
|
321
|
+
return req ? new URL(req.url).origin : '';
|
|
322
|
+
} catch {
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Paths already warned about an unresolvable bare-path eviction, so the warning
|
|
329
|
+
* fires once per offending path rather than once per invocation.
|
|
330
|
+
* @type {Set<string>}
|
|
331
|
+
*/
|
|
332
|
+
const _warnedUnresolvedPaths = new Set();
|
|
333
|
+
|
|
334
|
+
/** @param {string} path */
|
|
335
|
+
function warnUnresolvedOriginOnce(path) {
|
|
336
|
+
if (_warnedUnresolvedPaths.has(path)) return;
|
|
337
|
+
_warnedUnresolvedPaths.add(path);
|
|
338
|
+
console.warn(
|
|
339
|
+
`[webjs] revalidatePath(${JSON.stringify(path)}) evicted nothing: HTML cache ` +
|
|
340
|
+
`keys carry the request origin, and this call has no ambient request to take ` +
|
|
341
|
+
`one from (a cron job, a queue worker, a boot-time timer). Pass an absolute ` +
|
|
342
|
+
`url instead, e.g. revalidatePath('https://your-host${path.startsWith('/') ? path : '/' + path}'), ` +
|
|
343
|
+
`or call it from a server action, where the triggering request supplies the origin.`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
243
347
|
/**
|
|
244
348
|
* Response-funnel step (#241): if the response carries the HTML_CACHE_MARKER
|
|
245
349
|
* (the SSR opted it into caching), re-check every guard against the FINAL
|
package/src/js-scan.js
CHANGED
|
@@ -632,28 +632,46 @@ export function matchClosingParenthesis(s, start) {
|
|
|
632
632
|
* `}` inside `'…'`, `"…"`, or backtick templates don't decrement depth.
|
|
633
633
|
* Returns -1 if no balanced brace is found.
|
|
634
634
|
*
|
|
635
|
+
* A template hole is a CODE context nested inside a template, not a brace in
|
|
636
|
+
* the enclosing block, so it gets its own frame on the stack: `${` pushes,
|
|
637
|
+
* and the `}` that returns that frame to depth zero pops back into the
|
|
638
|
+
* template rather than counting toward the block being matched. An earlier
|
|
639
|
+
* version incremented the outer depth at `${` and then never decremented it
|
|
640
|
+
* (the closing `}` arrived while still in template state), so depth could
|
|
641
|
+
* never return to zero and a class body holding `` html`…${x}…` `` was
|
|
642
|
+
* unmatchable. Every caller passes a masked source in which holes are already
|
|
643
|
+
* blanked, so the bug is invisible until one passes raw source.
|
|
644
|
+
*
|
|
635
645
|
* @param {string} s
|
|
636
646
|
* @param {number} start
|
|
637
647
|
*/
|
|
638
648
|
export function matchClosingBrace(s, start) {
|
|
639
|
-
|
|
649
|
+
// Innermost first. `tpl` frames are template literals (no brace counting);
|
|
650
|
+
// `!tpl` frames are code, each with its own depth.
|
|
651
|
+
/** @type {Array<{ tpl: boolean, depth: number }>} */
|
|
652
|
+
const stack = [{ tpl: false, depth: 1 }];
|
|
640
653
|
let i = start;
|
|
641
|
-
let str = ''; // '', "'", '"', or backtick
|
|
642
654
|
while (i < s.length) {
|
|
655
|
+
const top = stack[stack.length - 1];
|
|
643
656
|
const c = s[i];
|
|
644
|
-
if (
|
|
657
|
+
if (top.tpl) {
|
|
645
658
|
if (c === '\\') { i += 2; continue; }
|
|
646
|
-
if (c ===
|
|
647
|
-
|
|
648
|
-
// template hole, count its closing `}` toward our brace depth.
|
|
649
|
-
depth++;
|
|
650
|
-
i += 2;
|
|
651
|
-
continue;
|
|
652
|
-
}
|
|
659
|
+
if (c === '`') { stack.pop(); i++; continue; }
|
|
660
|
+
if (c === '$' && s[i + 1] === '{') { stack.push({ tpl: false, depth: 1 }); i += 2; continue; }
|
|
653
661
|
i++;
|
|
654
662
|
continue;
|
|
655
663
|
}
|
|
656
|
-
if (c === "'" || c === '"'
|
|
664
|
+
if (c === "'" || c === '"') {
|
|
665
|
+
i++;
|
|
666
|
+
while (i < s.length) {
|
|
667
|
+
if (s[i] === '\\') { i += 2; continue; }
|
|
668
|
+
const d = s[i];
|
|
669
|
+
i++;
|
|
670
|
+
if (d === c || d === '\n') break; // closed, or unterminated at EOL
|
|
671
|
+
}
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (c === '`') { stack.push({ tpl: true, depth: 0 }); i++; continue; }
|
|
657
675
|
if (c === '/' && s[i + 1] === '/') { // line comment
|
|
658
676
|
while (i < s.length && s[i] !== '\n') i++;
|
|
659
677
|
continue;
|
|
@@ -664,8 +682,18 @@ export function matchClosingBrace(s, start) {
|
|
|
664
682
|
i += 2;
|
|
665
683
|
continue;
|
|
666
684
|
}
|
|
667
|
-
if (c === '{') depth++;
|
|
668
|
-
|
|
685
|
+
if (c === '{') { top.depth++; i++; continue; }
|
|
686
|
+
if (c === '}') {
|
|
687
|
+
top.depth--;
|
|
688
|
+
// Depth zero closes this frame: the outermost one is the answer, an inner
|
|
689
|
+
// one is a template hole ending and hands control back to its template.
|
|
690
|
+
if (top.depth === 0) {
|
|
691
|
+
if (stack.length === 1) return i;
|
|
692
|
+
stack.pop();
|
|
693
|
+
}
|
|
694
|
+
i++;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
669
697
|
i++;
|
|
670
698
|
}
|
|
671
699
|
return -1;
|
package/src/listener-bun.js
CHANGED
|
@@ -154,7 +154,7 @@ export function startBunListener(ctx) {
|
|
|
154
154
|
const shutdown = makeShutdown({ closeServer, hub, logger });
|
|
155
155
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
156
156
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
157
|
-
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
157
|
+
installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
|
|
158
158
|
|
|
159
159
|
return {
|
|
160
160
|
server,
|
package/src/listener-core.js
CHANGED
|
@@ -383,7 +383,7 @@ export class SseHub {
|
|
|
383
383
|
* Install once-only process error handlers. Idempotent across multiple
|
|
384
384
|
* `startServer` calls in the same process. Runtime-neutral (plain `process.on`).
|
|
385
385
|
* @param {import('./logger.js').Logger} logger
|
|
386
|
-
* @param {() => void} onFatal
|
|
386
|
+
* @param {() => void} onFatal Starts the FATAL shutdown, which exits non-zero.
|
|
387
387
|
*/
|
|
388
388
|
export function installProcessHandlers(logger, onFatal) {
|
|
389
389
|
if (/** @type any */ (globalThis).__webjsProcHandlers) return;
|
|
@@ -405,12 +405,37 @@ export function installProcessHandlers(logger, onFatal) {
|
|
|
405
405
|
* `closeServer()` thunk that resolves once the server has stopped accepting
|
|
406
406
|
* connections (node `server.close`, Bun `server.stop(true)`). Closes the SSE hub,
|
|
407
407
|
* then drains, then exits; hard-exits after 10s if the drain hangs.
|
|
408
|
+
*
|
|
409
|
+
* Exit code, in full. Three things can force a 1, and only their absence
|
|
410
|
+
* leaves a 0:
|
|
411
|
+
* - a drain that FAILS, so a rejected `closeServer()` and a drain still
|
|
412
|
+
* hanging at the 10s deadline both exit 1 whatever started them;
|
|
413
|
+
* - a FATAL start (the `uncaughtException` handler calling `onFatal`), which
|
|
414
|
+
* exits 1 even when the drain is perfectly clean;
|
|
415
|
+
* - a FATAL arriving MID-DRAIN, which upgrades a shutdown an operator signal
|
|
416
|
+
* had already begun (see the upgrade-only note on `code` below).
|
|
417
|
+
* So 0 does NOT mean "a signal started it", it means an operator asked for the
|
|
418
|
+
* stop AND it drained cleanly AND nothing crashed on the way out. A supervisor
|
|
419
|
+
* reads only this code (systemd `Restart=on-failure`, Docker, Railway, and a CI
|
|
420
|
+
* step running a `test/bun/*.mjs` proof script, whose failed top-level assertion
|
|
421
|
+
* arrives here as an uncaught exception, #1092), so every one of those has to
|
|
422
|
+
* come back non-zero.
|
|
408
423
|
* @param {{ closeServer: () => Promise<unknown>, hub: SseHub, logger: import('./logger.js').Logger }} opts
|
|
409
|
-
* @returns {(signal: string) => void}
|
|
424
|
+
* @returns {(signal: string, opts?: { fatal?: boolean }) => void}
|
|
410
425
|
*/
|
|
411
426
|
export function makeShutdown({ closeServer, hub, logger }) {
|
|
412
427
|
let shuttingDown = false;
|
|
413
|
-
|
|
428
|
+
// Read at EXIT time, not at entry, so a fatal arriving mid-drain still counts.
|
|
429
|
+
// The drain window is up to 10s wide and in-flight requests are still running
|
|
430
|
+
// in it, so an uncaught exception landing there is an ordinary event, not an
|
|
431
|
+
// exotic race. Because the re-entrancy guard below drops that second call, a
|
|
432
|
+
// code captured at entry would stay 0 and the crashed process would report a
|
|
433
|
+
// successful stop, which is the exact failure this whole mechanism exists to
|
|
434
|
+
// prevent. Upgrade only (1 never falls back to 0), so a signal arriving during
|
|
435
|
+
// a fatal shutdown cannot launder the crash into a success either.
|
|
436
|
+
let code = 0;
|
|
437
|
+
return (signal, { fatal = false } = {}) => {
|
|
438
|
+
if (fatal) code = 1;
|
|
414
439
|
if (shuttingDown) return;
|
|
415
440
|
shuttingDown = true;
|
|
416
441
|
logger.info(`received ${signal}, shutting down`);
|
|
@@ -420,11 +445,16 @@ export function makeShutdown({ closeServer, hub, logger }) {
|
|
|
420
445
|
process.exit(1);
|
|
421
446
|
}, 10_000);
|
|
422
447
|
if (typeof hard.unref === 'function') hard.unref();
|
|
448
|
+
// Disarm before exiting. In production `process.exit` ends everything so this
|
|
449
|
+
// is redundant, but it keeps the deadline from outliving the decision it was
|
|
450
|
+
// guarding, which is what let a stubbed-exit test leave a live timer armed to
|
|
451
|
+
// kill an unrelated process 10s later.
|
|
452
|
+
const done = (c) => { clearTimeout(hard); process.exit(c); };
|
|
423
453
|
Promise.resolve()
|
|
424
454
|
.then(closeServer)
|
|
425
455
|
.then(
|
|
426
|
-
() => { logger.info('bye');
|
|
427
|
-
(err) => { logger.error('server close error', { err: String(err) });
|
|
456
|
+
() => { logger.info('bye'); done(code); },
|
|
457
|
+
(err) => { logger.error('server close error', { err: String(err) }); done(1); },
|
|
428
458
|
);
|
|
429
459
|
};
|
|
430
460
|
}
|
package/src/rate-limit.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { getStore } from './cache.js';
|
|
26
|
+
// Aliased on purpose. `rateLimit()` binds a local `const trustProxy` from its
|
|
27
|
+
// own options, which would shadow a same-named import inside that function and
|
|
28
|
+
// leave the middleware path on the pre-override behaviour while the direct
|
|
29
|
+
// `clientIp` path changed. `forwarded.js` imports nothing, so this adds no cycle.
|
|
30
|
+
import { trustProxy as proxyIsTrusted } from './forwarded.js';
|
|
31
|
+
|
|
32
|
+
/** Module-scope latch so the override warns once per process, never per request. */
|
|
33
|
+
let warnedProxyOverride = false;
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
36
|
* @param {{
|
|
@@ -126,8 +134,8 @@ function trustedRemoteIp(req) {
|
|
|
126
134
|
|
|
127
135
|
/**
|
|
128
136
|
* Carry the trusted remote IP from `src` to a freshly-rebuilt `dst` Request
|
|
129
|
-
* (#756). When the framework re-wraps a request (the
|
|
130
|
-
* `
|
|
137
|
+
* (#756). When the framework re-wraps a request (the form-submission body rebuild
|
|
138
|
+
* in `form-dispatch.js`), the out-of-band WeakMap key is a new object, so the trusted
|
|
131
139
|
* IP would be lost and `clientIp(dst)` would fall back to the (spoofable) header
|
|
132
140
|
* the rebuild copied over. Propagating the trusted value via the WeakMap keeps
|
|
133
141
|
* `dst` authoritative on BOTH runtimes (it reads `trustedRemoteIp(src)`, which is
|
|
@@ -167,12 +175,32 @@ export function propagateTrustedRemoteIp(src, dst) {
|
|
|
167
175
|
* trust-proxy reintroduces the spoofability this option exists to
|
|
168
176
|
* defend against.
|
|
169
177
|
*
|
|
178
|
+
* `WEBJS_NO_TRUST_PROXY=1` OVERRIDES `trustProxy: true` (#1254). The env
|
|
179
|
+
* switch is the operator's statement about the deployment topology and it can
|
|
180
|
+
* only ever SUBTRACT trust, so when it is set this call falls back to the
|
|
181
|
+
* default path above: the framework-stamped peer, or `_anon_` when there is
|
|
182
|
+
* none, and never a forwarded-IP header. The override logs once per process.
|
|
183
|
+
* An app that genuinely sits behind a trusted proxy must unset the flag; the
|
|
184
|
+
* two settings answer the same question about one socket and one topology, so
|
|
185
|
+
* leaving them in disagreement buckets every visitor behind that proxy onto
|
|
186
|
+
* one key.
|
|
187
|
+
*
|
|
170
188
|
* @param {Request} req
|
|
171
189
|
* @param {{ trustProxy?: boolean }} [opts]
|
|
172
190
|
* @returns {string}
|
|
173
191
|
*/
|
|
174
192
|
export function clientIp(req, opts = {}) {
|
|
175
|
-
if (opts.trustProxy === true) {
|
|
193
|
+
if (opts.trustProxy === true && !proxyIsTrusted() && !warnedProxyOverride) {
|
|
194
|
+
warnedProxyOverride = true;
|
|
195
|
+
// No request data in this line, ever. It must not become a log of
|
|
196
|
+
// client-supplied header values.
|
|
197
|
+
console.warn(
|
|
198
|
+
'[webjs] WEBJS_NO_TRUST_PROXY=1 overrides trustProxy: true; ' +
|
|
199
|
+
'forwarded-IP headers are ignored and the framework-stamped peer is used. ' +
|
|
200
|
+
'Unset the env var if a trusted proxy really is in front of this process.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (opts.trustProxy === true && proxyIsTrusted()) {
|
|
176
204
|
return (
|
|
177
205
|
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
|
178
206
|
req.headers.get('cf-connecting-ip') ||
|
package/src/route-types.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Design choices:
|
|
12
12
|
* - PAGES ONLY. A `route.{js,ts}` handler is an API endpoint, not a
|
|
13
13
|
* navigable HTML page, so its path is excluded from the navigable `Route`
|
|
14
|
-
* union. Pages (including
|
|
14
|
+
* union. Pages (including ones hosting a bound form) are what a valid href points
|
|
15
15
|
* at.
|
|
16
16
|
* - The route KEY is the literal pattern (`/blog/[slug]`), derived from the
|
|
17
17
|
* page's directory with route groups `(group)` stripped and `_private`
|