@pylonsync/functions 0.3.297 → 0.3.299
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/dist/ssr-runtime.d.ts +85 -1
- package/package.json +1 -1
- package/src/ssr-runtime.test.ts +133 -0
- package/src/ssr-runtime.ts +352 -23
- package/src/ssr-streaming.test.ts +215 -0
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -56,6 +56,12 @@ export interface RenderRouteMessage {
|
|
|
56
56
|
tenant_id: string | null;
|
|
57
57
|
roles: string[];
|
|
58
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether the request carried a session cookie (presence, not identity) —
|
|
61
|
+
* surfaced as the identity-free `props.session.exists` (Phase 0 auth
|
|
62
|
+
* bucketing). Default false / absent for back-compat.
|
|
63
|
+
*/
|
|
64
|
+
session_present?: boolean;
|
|
59
65
|
/**
|
|
60
66
|
* Initial HTTP status the response controller starts at (default 200).
|
|
61
67
|
* The host sets this to 404 when dispatching a `not-found.tsx` render
|
|
@@ -142,7 +148,40 @@ export declare function makeResponseController(state: ResponseState, defaultRedi
|
|
|
142
148
|
* into one `Set-Cookie` header each (newline is forbidden inside a
|
|
143
149
|
* cookie, so it can't be turned into header injection).
|
|
144
150
|
*/
|
|
145
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Wrap a per-request object so ANY observation — property `get`, `in` (`has`),
|
|
153
|
+
* `Object.keys`/spread/`for…in` (`ownKeys`), or a descriptor probe — calls
|
|
154
|
+
* `onTouch`. Used to mark a render request-specific (vetoing shared caching) the
|
|
155
|
+
* instant it reads auth/headers/cookies. A bare `get` trap misses `in` and
|
|
156
|
+
* `Object.keys`, which would observe the data without tripping the veto.
|
|
157
|
+
* Exported for direct unit testing of that property.
|
|
158
|
+
*/
|
|
159
|
+
export declare function makeReadTrackingProxy(obj: Record<string, unknown> | undefined, onTouch: () => void): Record<string, unknown>;
|
|
160
|
+
/**
|
|
161
|
+
* Deep clone via JSON round-trip — drops functions / proxies / symbols, keeping
|
|
162
|
+
* only JSON-serializable data. Used to SNAPSHOT the bucket-tail props before any
|
|
163
|
+
* page code can mutate them, so a page can't smuggle identity through a nested
|
|
164
|
+
* field of params/searchParams into a shared cache entry. The deep copy is fully
|
|
165
|
+
* independent of the source: a later mutation of the original object can't reach
|
|
166
|
+
* the snapshot.
|
|
167
|
+
*/
|
|
168
|
+
export declare function jsonClone<T>(v: T): T;
|
|
169
|
+
/**
|
|
170
|
+
* Revocable form of {@link makeReadTrackingProxy}. The render path wraps each
|
|
171
|
+
* per-request input (auth / headers / cookies / session) in one of these and
|
|
172
|
+
* REVOKES it once the render is done — so a page that stashed the props object
|
|
173
|
+
* (or `props.auth`) in module-level state and read it on a LATER render gets a
|
|
174
|
+
* hard throw instead of silently reading a prior request's identity WITHOUT
|
|
175
|
+
* tripping this render's read-tracking. Revocation is per-render (each render
|
|
176
|
+
* revokes only its own proxies), so it is sound even when the Bun runner
|
|
177
|
+
* multiplexes concurrent renders. Fail-closed: the stale read throws, the render
|
|
178
|
+
* fails, and nothing is cached.
|
|
179
|
+
*/
|
|
180
|
+
export declare function makeRevocableReadTrackingProxy(obj: Record<string, unknown> | undefined, onTouch: () => void): {
|
|
181
|
+
proxy: Record<string, unknown>;
|
|
182
|
+
revoke: () => void;
|
|
183
|
+
};
|
|
184
|
+
export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>): Record<string, string>;
|
|
146
185
|
/**
|
|
147
186
|
* Phase 1 SSR handler. Resolves the component, renders it via
|
|
148
187
|
* react-dom/server.renderToReadableStream, pumps chunks back to the
|
|
@@ -325,6 +364,9 @@ export declare function buildHydrationTail(args: {
|
|
|
325
364
|
message: string;
|
|
326
365
|
digest?: string;
|
|
327
366
|
};
|
|
367
|
+
bucketAuth?: {
|
|
368
|
+
signedIn: boolean;
|
|
369
|
+
};
|
|
328
370
|
}): string;
|
|
329
371
|
/**
|
|
330
372
|
* A short, non-reversible correlation id for an error — surfaced to the
|
|
@@ -355,6 +397,48 @@ export declare function computeCacheVerdict(args: {
|
|
|
355
397
|
revalidateSecs: number | null;
|
|
356
398
|
forceDynamic: boolean;
|
|
357
399
|
authTouched: boolean;
|
|
400
|
+
dynamicTouched: boolean;
|
|
401
|
+
sessionTouched: boolean;
|
|
402
|
+
cookieCount: number;
|
|
403
|
+
strictPolicies: boolean;
|
|
404
|
+
wantsStream: boolean;
|
|
405
|
+
status: number;
|
|
406
|
+
}): boolean;
|
|
407
|
+
/**
|
|
408
|
+
* PPR Phase 0 — the AUTH-BUCKET verdict: may this render be stored as a SHARED
|
|
409
|
+
* cache entry keyed only on session-cookie PRESENCE (one signed-in shell + one
|
|
410
|
+
* signed-out shell, both IDENTITY-FREE)? Pure, exported, leak-class tested.
|
|
411
|
+
*
|
|
412
|
+
* Like `computeCacheVerdict` EXCEPT `sessionTouched` is ALLOWED (reading
|
|
413
|
+
* `props.session.exists` to render a binary signed-in/out nav is the whole
|
|
414
|
+
* point — that bit is what the bucket keys on, and it's identical across all
|
|
415
|
+
* users in the same bucket). Everything else still vetoes:
|
|
416
|
+
*
|
|
417
|
+
* - `authTouched` — reading real `props.auth` (user_id/tenant_id/roles) makes
|
|
418
|
+
* the output identity-SPECIFIC, not bucket-uniform. This is the load-bearing
|
|
419
|
+
* gate: ANY identity-dependent output requires reading auth, so vetoing on
|
|
420
|
+
* `authTouched` proves a bucketable body depends on nothing but the binary
|
|
421
|
+
* session bit.
|
|
422
|
+
* - `dynamicTouched` — reading request headers/cookies makes it request-specific.
|
|
423
|
+
* - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
|
|
424
|
+
* body/ssrData would vary by identity even without the page reading auth. In
|
|
425
|
+
* the default (non-strict) mode serverData reads bypass the policy gate and
|
|
426
|
+
* return identity-independent rows, so the cached body is safe to share within
|
|
427
|
+
* the bucket. Strict mode therefore must NOT bucket.
|
|
428
|
+
* - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
|
|
429
|
+
* anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
|
|
430
|
+
* safely shared).
|
|
431
|
+
*
|
|
432
|
+
* Requires the explicit `bucketOptIn` (`export const cache = "auth-bucketed"`)
|
|
433
|
+
* AND a TTL via `export const revalidate = N` — fail-closed: no opt-in or no TTL
|
|
434
|
+
* → no bucket.
|
|
435
|
+
*/
|
|
436
|
+
export declare function computeBucketVerdict(args: {
|
|
437
|
+
bucketOptIn: boolean;
|
|
438
|
+
revalidateSecs: number | null;
|
|
439
|
+
forceDynamic: boolean;
|
|
440
|
+
authTouched: boolean;
|
|
441
|
+
dynamicTouched: boolean;
|
|
358
442
|
cookieCount: number;
|
|
359
443
|
strictPolicies: boolean;
|
|
360
444
|
wantsStream: boolean;
|
package/package.json
CHANGED
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
isSafeRedirect,
|
|
15
15
|
asRouteControl,
|
|
16
16
|
PylonRouteControl,
|
|
17
|
+
finalizeHeaders,
|
|
18
|
+
makeResponseController,
|
|
19
|
+
makeReadTrackingProxy,
|
|
20
|
+
makeRevocableReadTrackingProxy,
|
|
21
|
+
jsonClone,
|
|
17
22
|
} from "./ssr-runtime";
|
|
18
23
|
|
|
19
24
|
describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
|
|
@@ -53,6 +58,134 @@ describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () =
|
|
|
53
58
|
test("no public origin + untrusted host → empty (relative, never poisoned)", () => {
|
|
54
59
|
expect(resolveOrigin({ host: "evil.com" })).toBe("");
|
|
55
60
|
});
|
|
61
|
+
|
|
62
|
+
test("off-loopback never honors X-Forwarded-Proto (no downgrade poisoning)", () => {
|
|
63
|
+
// Even on a TRUSTED host, a client-supplied proto must not change the
|
|
64
|
+
// absolute origin — it feeds og:image/canonical and is cache-keyed only by
|
|
65
|
+
// host, so honoring `http` would downgrade the cached URL for everyone.
|
|
66
|
+
// Off-loopback is ALWAYS https.
|
|
67
|
+
expect(
|
|
68
|
+
resolveOrigin({ host: "www.notbehind.com", publicUrl, forwardedProto: "http" }),
|
|
69
|
+
).toBe("https://www.notbehind.com");
|
|
70
|
+
expect(
|
|
71
|
+
resolveOrigin({
|
|
72
|
+
host: "www.notbehind.com",
|
|
73
|
+
publicUrl,
|
|
74
|
+
forwardedProto: "javascript:alert(1)",
|
|
75
|
+
}),
|
|
76
|
+
).toBe("https://www.notbehind.com");
|
|
77
|
+
// Loopback (dev) may be http; an explicit https there is honored.
|
|
78
|
+
expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "http" })).toBe(
|
|
79
|
+
"http://localhost:4321",
|
|
80
|
+
);
|
|
81
|
+
expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "https" })).toBe(
|
|
82
|
+
"https://localhost:4321",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () => {
|
|
88
|
+
test("response.setHeader() rejects the reserved x-pylon-* namespace", () => {
|
|
89
|
+
const state = {
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: {} as Record<string, string>,
|
|
92
|
+
cookies: [] as string[],
|
|
93
|
+
};
|
|
94
|
+
const res = makeResponseController(state);
|
|
95
|
+
// Forging the #277 cache proof from userland must throw, not silently set it.
|
|
96
|
+
expect(() => res.setHeader("x-pylon-cacheable", "300")).toThrow(/reserved/i);
|
|
97
|
+
expect(() => res.setHeader("X-Pylon-Anything", "1")).toThrow(/reserved/i);
|
|
98
|
+
// An ordinary header still works.
|
|
99
|
+
res.setHeader("x-custom", "ok");
|
|
100
|
+
expect(state.headers["x-custom"]).toBe("ok");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("finalizeHeaders: x-pylon-* survives ONLY from the trusted internal channel", () => {
|
|
104
|
+
// The #277 proof must come from the 3rd `internal` arg. A page-set header
|
|
105
|
+
// (state.headers) OR a route-handler header (the 2nd `extra` arg, which
|
|
106
|
+
// ssr-form-runtime fills from user-returned headers) is stripped — so
|
|
107
|
+
// userland can't forge the host-side cache verdict through ANY path.
|
|
108
|
+
const state = {
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: { "x-pylon-cacheable": "999", "x-keep": "yes" } as Record<string, string>,
|
|
111
|
+
cookies: [] as string[],
|
|
112
|
+
};
|
|
113
|
+
const out = finalizeHeaders(
|
|
114
|
+
state,
|
|
115
|
+
{ "x-pylon-cacheable": "888", "x-extra": "e" }, // untrusted extra → stripped
|
|
116
|
+
{ "x-pylon-cacheable": "60" }, // trusted internal → kept
|
|
117
|
+
);
|
|
118
|
+
expect(out["x-pylon-cacheable"]).toBe("60"); // only the trusted value
|
|
119
|
+
expect(out["x-keep"]).toBe("yes");
|
|
120
|
+
expect(out["x-extra"]).toBe("e"); // a non-reserved extra header still merges
|
|
121
|
+
|
|
122
|
+
// No internal proof → NO x-pylon-* survives, from page headers OR extra.
|
|
123
|
+
const out2 = finalizeHeaders(
|
|
124
|
+
{ status: 200, headers: { "x-pylon-cacheable": "999" }, cookies: [] as string[] },
|
|
125
|
+
{ "x-pylon-cacheable": "777" },
|
|
126
|
+
);
|
|
127
|
+
expect(out2["x-pylon-cacheable"]).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("makeReadTrackingProxy trips on get / in / Object.keys / descriptor / spread", () => {
|
|
131
|
+
const probes: Array<(o: any) => unknown> = [
|
|
132
|
+
(o) => o.host,
|
|
133
|
+
(o) => "host" in o,
|
|
134
|
+
(o) => Object.keys(o),
|
|
135
|
+
(o) => Object.getOwnPropertyDescriptor(o, "host"),
|
|
136
|
+
(o) => ({ ...o }),
|
|
137
|
+
];
|
|
138
|
+
for (const probe of probes) {
|
|
139
|
+
let touched = false;
|
|
140
|
+
const p = makeReadTrackingProxy({ host: "x" }, () => {
|
|
141
|
+
touched = true;
|
|
142
|
+
});
|
|
143
|
+
probe(p);
|
|
144
|
+
expect(touched).toBe(true); // a bare `get` trap would miss in/keys
|
|
145
|
+
}
|
|
146
|
+
// No observation → never touched.
|
|
147
|
+
let t = false;
|
|
148
|
+
makeReadTrackingProxy({ host: "x" }, () => {
|
|
149
|
+
t = true;
|
|
150
|
+
});
|
|
151
|
+
expect(t).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("revocable proxy throws after revoke (stale module-stashed props fence)", () => {
|
|
155
|
+
// P0 (codex 2026-06-28): a page that stashes `props` (or `props.auth`) in
|
|
156
|
+
// module-level state and reads it on a LATER render must not silently read a
|
|
157
|
+
// prior request's identity without tripping THIS render's read-tracking. The
|
|
158
|
+
// render path revokes each per-request proxy when the render ends, so any
|
|
159
|
+
// retained reference throws on access — fail-closed.
|
|
160
|
+
const { proxy, revoke } = makeRevocableReadTrackingProxy(
|
|
161
|
+
{ user_id: "alice" },
|
|
162
|
+
() => {},
|
|
163
|
+
);
|
|
164
|
+
expect((proxy as any).user_id).toBe("alice"); // live during the render
|
|
165
|
+
revoke();
|
|
166
|
+
// A stashed reference, read on a later render:
|
|
167
|
+
expect(() => (proxy as any).user_id).toThrow();
|
|
168
|
+
expect(() => "user_id" in proxy).toThrow();
|
|
169
|
+
expect(() => Object.keys(proxy)).toThrow();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("jsonClone snapshot is independent of later source mutation (bucket params fence)", () => {
|
|
173
|
+
// The bucket-tail snapshot (bucketTailBase) is jsonClone'd at render START.
|
|
174
|
+
// The defense rests on the clone being decoupled from the live params object:
|
|
175
|
+
// a page mutating a NESTED field afterwards (props.searchParams.leak =
|
|
176
|
+
// props.auth) can't reach the already-captured snapshot.
|
|
177
|
+
const source: any = { id: "a", nested: { keep: 1 } };
|
|
178
|
+
const snap = jsonClone(source);
|
|
179
|
+
// Simulate the page smuggling identity in after the snapshot was taken.
|
|
180
|
+
source.leak = { user_id: "alice" };
|
|
181
|
+
source.nested.keep = 999;
|
|
182
|
+
expect(snap).toEqual({ id: "a", nested: { keep: 1 } });
|
|
183
|
+
expect((snap as any).leak).toBeUndefined();
|
|
184
|
+
// And it strips non-JSON values (a proxy aliased in would serialize as its
|
|
185
|
+
// target via JSON, but a function/symbol is dropped entirely).
|
|
186
|
+
const stripped = jsonClone({ ok: "v", fn: () => 1, sym: Symbol("x") } as any);
|
|
187
|
+
expect(stripped).toEqual({ ok: "v" });
|
|
188
|
+
});
|
|
56
189
|
});
|
|
57
190
|
|
|
58
191
|
// Pull the JSON out of the `__PYLON_DATA__` <script> a hydration tail emits.
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -75,6 +75,12 @@ export interface RenderRouteMessage {
|
|
|
75
75
|
tenant_id: string | null;
|
|
76
76
|
roles: string[];
|
|
77
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Whether the request carried a session cookie (presence, not identity) —
|
|
80
|
+
* surfaced as the identity-free `props.session.exists` (Phase 0 auth
|
|
81
|
+
* bucketing). Default false / absent for back-compat.
|
|
82
|
+
*/
|
|
83
|
+
session_present?: boolean;
|
|
78
84
|
/**
|
|
79
85
|
* Initial HTTP status the response controller starts at (default 200).
|
|
80
86
|
* The host sets this to 404 when dispatching a `not-found.tsx` render
|
|
@@ -253,6 +259,16 @@ export function makeResponseController(
|
|
|
253
259
|
if (!TOKEN_RE.test(name)) {
|
|
254
260
|
throw new Error(`pylon ssr: invalid header name ${JSON.stringify(name)}`);
|
|
255
261
|
}
|
|
262
|
+
// `x-pylon-*` is a reserved internal namespace — the host trusts headers
|
|
263
|
+
// like the #277 `x-pylon-cacheable` cache proof as runtime-emitted. Letting
|
|
264
|
+
// userland set one would forge the cache verdict (e.g. mark a personalized
|
|
265
|
+
// render shareable). Reject it loudly.
|
|
266
|
+
if (name.toLowerCase().startsWith("x-pylon-")) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`pylon ssr: "x-pylon-*" is a reserved internal header namespace and ` +
|
|
269
|
+
`cannot be set via response.setHeader() (got ${JSON.stringify(name)})`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
256
272
|
assertNoControlChars(value, "header value");
|
|
257
273
|
state.headers[name.toLowerCase()] = value;
|
|
258
274
|
},
|
|
@@ -299,11 +315,93 @@ export function makeResponseController(
|
|
|
299
315
|
* into one `Set-Cookie` header each (newline is forbidden inside a
|
|
300
316
|
* cookie, so it can't be turned into header injection).
|
|
301
317
|
*/
|
|
318
|
+
/**
|
|
319
|
+
* Wrap a per-request object so ANY observation — property `get`, `in` (`has`),
|
|
320
|
+
* `Object.keys`/spread/`for…in` (`ownKeys`), or a descriptor probe — calls
|
|
321
|
+
* `onTouch`. Used to mark a render request-specific (vetoing shared caching) the
|
|
322
|
+
* instant it reads auth/headers/cookies. A bare `get` trap misses `in` and
|
|
323
|
+
* `Object.keys`, which would observe the data without tripping the veto.
|
|
324
|
+
* Exported for direct unit testing of that property.
|
|
325
|
+
*/
|
|
326
|
+
export function makeReadTrackingProxy(
|
|
327
|
+
obj: Record<string, unknown> | undefined,
|
|
328
|
+
onTouch: () => void,
|
|
329
|
+
): Record<string, unknown> {
|
|
330
|
+
return makeRevocableReadTrackingProxy(obj, onTouch).proxy;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Deep clone via JSON round-trip — drops functions / proxies / symbols, keeping
|
|
335
|
+
* only JSON-serializable data. Used to SNAPSHOT the bucket-tail props before any
|
|
336
|
+
* page code can mutate them, so a page can't smuggle identity through a nested
|
|
337
|
+
* field of params/searchParams into a shared cache entry. The deep copy is fully
|
|
338
|
+
* independent of the source: a later mutation of the original object can't reach
|
|
339
|
+
* the snapshot.
|
|
340
|
+
*/
|
|
341
|
+
export function jsonClone<T>(v: T): T {
|
|
342
|
+
return v == null ? v : (JSON.parse(JSON.stringify(v)) as T);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Revocable form of {@link makeReadTrackingProxy}. The render path wraps each
|
|
347
|
+
* per-request input (auth / headers / cookies / session) in one of these and
|
|
348
|
+
* REVOKES it once the render is done — so a page that stashed the props object
|
|
349
|
+
* (or `props.auth`) in module-level state and read it on a LATER render gets a
|
|
350
|
+
* hard throw instead of silently reading a prior request's identity WITHOUT
|
|
351
|
+
* tripping this render's read-tracking. Revocation is per-render (each render
|
|
352
|
+
* revokes only its own proxies), so it is sound even when the Bun runner
|
|
353
|
+
* multiplexes concurrent renders. Fail-closed: the stale read throws, the render
|
|
354
|
+
* fails, and nothing is cached.
|
|
355
|
+
*/
|
|
356
|
+
export function makeRevocableReadTrackingProxy(
|
|
357
|
+
obj: Record<string, unknown> | undefined,
|
|
358
|
+
onTouch: () => void,
|
|
359
|
+
): { proxy: Record<string, unknown>; revoke: () => void } {
|
|
360
|
+
return Proxy.revocable((obj ?? {}) as Record<string, unknown>, {
|
|
361
|
+
get(t, p, r) {
|
|
362
|
+
onTouch();
|
|
363
|
+
return Reflect.get(t, p, r);
|
|
364
|
+
},
|
|
365
|
+
has(t, p) {
|
|
366
|
+
onTouch();
|
|
367
|
+
return Reflect.has(t, p);
|
|
368
|
+
},
|
|
369
|
+
ownKeys(t) {
|
|
370
|
+
onTouch();
|
|
371
|
+
return Reflect.ownKeys(t);
|
|
372
|
+
},
|
|
373
|
+
getOwnPropertyDescriptor(t, p) {
|
|
374
|
+
onTouch();
|
|
375
|
+
return Reflect.getOwnPropertyDescriptor(t, p);
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
302
380
|
export function finalizeHeaders(
|
|
303
381
|
state: ResponseState,
|
|
382
|
+
// UNTRUSTED user headers (page-set `state.headers` and, at the form/data-route
|
|
383
|
+
// call sites, a route handler's returned headers). `x-pylon-*` is stripped.
|
|
304
384
|
extra?: Record<string, string>,
|
|
385
|
+
// TRUSTED runtime headers (e.g. the #277 `x-pylon-cacheable` proof). The ONLY
|
|
386
|
+
// legitimate source of `x-pylon-*`; merged last and never stripped.
|
|
387
|
+
internal?: Record<string, string>,
|
|
305
388
|
): Record<string, string> {
|
|
306
|
-
|
|
389
|
+
// The host TRUSTS `x-pylon-*` headers (cache verdict, etc.). They must come
|
|
390
|
+
// ONLY from `internal` — strip them from BOTH page-set headers AND `extra` so
|
|
391
|
+
// userland can't forge the verdict through any path (setHeader is also
|
|
392
|
+
// rejected at the source, this is defense-in-depth + covers route-handler
|
|
393
|
+
// headers that flow through `extra`).
|
|
394
|
+
const h: Record<string, string> = {};
|
|
395
|
+
const mergeStripped = (src?: Record<string, string>) => {
|
|
396
|
+
if (!src) return;
|
|
397
|
+
for (const [k, v] of Object.entries(src)) {
|
|
398
|
+
if (k.toLowerCase().startsWith("x-pylon-")) continue;
|
|
399
|
+
h[k] = v; // later sources override earlier (page < extra)
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
mergeStripped(state.headers);
|
|
403
|
+
mergeStripped(extra);
|
|
404
|
+
if (internal) Object.assign(h, internal); // trusted, never stripped
|
|
307
405
|
if (!h["content-type"]) h["content-type"] = "text/html; charset=utf-8";
|
|
308
406
|
if (state.cookies.length > 0) {
|
|
309
407
|
// Preserve a set-cookie value set via setHeader() (rare) and join it
|
|
@@ -811,9 +909,17 @@ export function resolveOrigin(opts: {
|
|
|
811
909
|
for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
|
|
812
910
|
const isLoopback = LOOPBACK_HOST.test(host);
|
|
813
911
|
if (isLoopback || allow.has(host)) {
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
912
|
+
// Off-loopback (prod) we ALWAYS use https and never honor the request's
|
|
913
|
+
// X-Forwarded-Proto. The SSR cache is keyed only by host (not proto), so
|
|
914
|
+
// honoring a client-supplied `http` would poison the cached canonical/OG
|
|
915
|
+
// URL with a downgraded scheme for every subsequent visitor. Loopback
|
|
916
|
+
// (dev) may be plain http. (A genuinely non-https prod origin should set
|
|
917
|
+
// PYLON_PUBLIC_URL explicitly, which takes precedence above.)
|
|
918
|
+
const proto = isLoopback
|
|
919
|
+
? opts.forwardedProto === "https"
|
|
920
|
+
? "https"
|
|
921
|
+
: "http"
|
|
922
|
+
: "https";
|
|
817
923
|
return `${proto}://${host}`;
|
|
818
924
|
}
|
|
819
925
|
}
|
|
@@ -1132,6 +1238,15 @@ export function buildHydrationTail(args: {
|
|
|
1132
1238
|
manifestErr: string | null;
|
|
1133
1239
|
kind?: "error" | "not-found";
|
|
1134
1240
|
errorForClient?: { message: string; digest?: string };
|
|
1241
|
+
// PPR Phase 0 (auth-bucketed caching): when set, this render is being stored
|
|
1242
|
+
// as a SHARED cache entry keyed only on session-cookie PRESENCE, so its
|
|
1243
|
+
// hydration tail must carry an IDENTITY-FREE auth — the binary `{ signedIn }`
|
|
1244
|
+
// bit the bucket is keyed on, and NOTHING ELSE (no user_id / tenant_id /
|
|
1245
|
+
// roles / email). Two different signed-in users hitting the same bucketed
|
|
1246
|
+
// page MUST produce a byte-identical tail, or the shared cache replays one
|
|
1247
|
+
// user's identity to another (the #277 leak class, at the body level). The
|
|
1248
|
+
// raw `auth` is replaced AFTER the live-handle strip below.
|
|
1249
|
+
bucketAuth?: { signedIn: boolean };
|
|
1135
1250
|
}): string {
|
|
1136
1251
|
// Strip live, non-serializable handles (serverData / response / reset) + the
|
|
1137
1252
|
// request headers/cookies (SECURITY: never expose the session cookie to
|
|
@@ -1146,7 +1261,28 @@ export function buildHydrationTail(args: {
|
|
|
1146
1261
|
error: _err,
|
|
1147
1262
|
...restProps
|
|
1148
1263
|
} = args.props ?? {};
|
|
1149
|
-
|
|
1264
|
+
let serializableProps: any;
|
|
1265
|
+
if (args.bucketAuth) {
|
|
1266
|
+
// Bucketed render → SHARED entry. Serialize a strict ALLOWLIST of the
|
|
1267
|
+
// framework props that are provably bucket-uniform — never a spread of the
|
|
1268
|
+
// page's props object. A page can alias identity onto a custom key
|
|
1269
|
+
// (`props.leak = props.auth`) without tripping read-tracking; spreading
|
|
1270
|
+
// `restProps` would then serialize that identity into the shared tail. The
|
|
1271
|
+
// allowlisted fields are all path/route-derived (in the cache key) or the
|
|
1272
|
+
// collapsed binary auth bit. (params/searchParams are keyed by pathname; a
|
|
1273
|
+
// bucket request has no query, so searchParams is empty.)
|
|
1274
|
+
serializableProps = {
|
|
1275
|
+
url: restProps.url,
|
|
1276
|
+
params: restProps.params,
|
|
1277
|
+
searchParams: restProps.searchParams,
|
|
1278
|
+
auth: { signedIn: args.bucketAuth.signedIn },
|
|
1279
|
+
session: { exists: args.bucketAuth.signedIn },
|
|
1280
|
+
headers: {},
|
|
1281
|
+
cookies: {},
|
|
1282
|
+
};
|
|
1283
|
+
} else {
|
|
1284
|
+
serializableProps = { ...restProps, headers: {}, cookies: {} };
|
|
1285
|
+
}
|
|
1150
1286
|
if (args.errorForClient) serializableProps.error = args.errorForClient;
|
|
1151
1287
|
const hydrationPayload: any = {
|
|
1152
1288
|
component: args.component,
|
|
@@ -1486,6 +1622,18 @@ export function computeCacheVerdict(args: {
|
|
|
1486
1622
|
revalidateSecs: number | null;
|
|
1487
1623
|
forceDynamic: boolean;
|
|
1488
1624
|
authTouched: boolean;
|
|
1625
|
+
// True when the render read any OTHER per-request input that is not part of
|
|
1626
|
+
// the cache key — request `headers` (incl. non-bucketed ones) or `cookies`
|
|
1627
|
+
// (incl. via generateMetadata). Such a read makes the output request-specific,
|
|
1628
|
+
// so it must veto shared caching exactly like `authTouched`. (Keyed inputs —
|
|
1629
|
+
// pathname, and Host via the host bucket — are handled by the cache key and do
|
|
1630
|
+
// not set this.)
|
|
1631
|
+
dynamicTouched: boolean;
|
|
1632
|
+
// True when the render read `props.session.exists` (Phase 0). For the PLAIN
|
|
1633
|
+
// anonymous cache this is a veto — the output varies by signed-in bucket, and
|
|
1634
|
+
// the anon cache holds one entry for all. (The auth-BUCKET verdict, which keys
|
|
1635
|
+
// the cache on the bucket, permits it; this gate does not.)
|
|
1636
|
+
sessionTouched: boolean;
|
|
1489
1637
|
cookieCount: number;
|
|
1490
1638
|
strictPolicies: boolean;
|
|
1491
1639
|
wantsStream: boolean;
|
|
@@ -1495,6 +1643,61 @@ export function computeCacheVerdict(args: {
|
|
|
1495
1643
|
args.revalidateSecs != null &&
|
|
1496
1644
|
!args.forceDynamic &&
|
|
1497
1645
|
!args.authTouched &&
|
|
1646
|
+
!args.dynamicTouched &&
|
|
1647
|
+
!args.sessionTouched &&
|
|
1648
|
+
args.cookieCount === 0 &&
|
|
1649
|
+
!args.strictPolicies &&
|
|
1650
|
+
!args.wantsStream &&
|
|
1651
|
+
args.status === 200
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* PPR Phase 0 — the AUTH-BUCKET verdict: may this render be stored as a SHARED
|
|
1657
|
+
* cache entry keyed only on session-cookie PRESENCE (one signed-in shell + one
|
|
1658
|
+
* signed-out shell, both IDENTITY-FREE)? Pure, exported, leak-class tested.
|
|
1659
|
+
*
|
|
1660
|
+
* Like `computeCacheVerdict` EXCEPT `sessionTouched` is ALLOWED (reading
|
|
1661
|
+
* `props.session.exists` to render a binary signed-in/out nav is the whole
|
|
1662
|
+
* point — that bit is what the bucket keys on, and it's identical across all
|
|
1663
|
+
* users in the same bucket). Everything else still vetoes:
|
|
1664
|
+
*
|
|
1665
|
+
* - `authTouched` — reading real `props.auth` (user_id/tenant_id/roles) makes
|
|
1666
|
+
* the output identity-SPECIFIC, not bucket-uniform. This is the load-bearing
|
|
1667
|
+
* gate: ANY identity-dependent output requires reading auth, so vetoing on
|
|
1668
|
+
* `authTouched` proves a bucketable body depends on nothing but the binary
|
|
1669
|
+
* session bit.
|
|
1670
|
+
* - `dynamicTouched` — reading request headers/cookies makes it request-specific.
|
|
1671
|
+
* - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
|
|
1672
|
+
* body/ssrData would vary by identity even without the page reading auth. In
|
|
1673
|
+
* the default (non-strict) mode serverData reads bypass the policy gate and
|
|
1674
|
+
* return identity-independent rows, so the cached body is safe to share within
|
|
1675
|
+
* the bucket. Strict mode therefore must NOT bucket.
|
|
1676
|
+
* - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
|
|
1677
|
+
* anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
|
|
1678
|
+
* safely shared).
|
|
1679
|
+
*
|
|
1680
|
+
* Requires the explicit `bucketOptIn` (`export const cache = "auth-bucketed"`)
|
|
1681
|
+
* AND a TTL via `export const revalidate = N` — fail-closed: no opt-in or no TTL
|
|
1682
|
+
* → no bucket.
|
|
1683
|
+
*/
|
|
1684
|
+
export function computeBucketVerdict(args: {
|
|
1685
|
+
bucketOptIn: boolean;
|
|
1686
|
+
revalidateSecs: number | null;
|
|
1687
|
+
forceDynamic: boolean;
|
|
1688
|
+
authTouched: boolean;
|
|
1689
|
+
dynamicTouched: boolean;
|
|
1690
|
+
cookieCount: number;
|
|
1691
|
+
strictPolicies: boolean;
|
|
1692
|
+
wantsStream: boolean;
|
|
1693
|
+
status: number;
|
|
1694
|
+
}): boolean {
|
|
1695
|
+
return (
|
|
1696
|
+
args.bucketOptIn &&
|
|
1697
|
+
args.revalidateSecs != null &&
|
|
1698
|
+
!args.forceDynamic &&
|
|
1699
|
+
!args.authTouched &&
|
|
1700
|
+
!args.dynamicTouched &&
|
|
1498
1701
|
args.cookieCount === 0 &&
|
|
1499
1702
|
!args.strictPolicies &&
|
|
1500
1703
|
!args.wantsStream &&
|
|
@@ -1713,6 +1916,11 @@ export async function handleRenderRoute(
|
|
|
1713
1916
|
let React: any = null;
|
|
1714
1917
|
let renderToReadableStream: any = null;
|
|
1715
1918
|
let props: any = null;
|
|
1919
|
+
// Revoke fns for this render's per-request proxies (auth/headers/cookies/
|
|
1920
|
+
// session). Declared OUT here so the `finally` revokes them on EVERY exit path
|
|
1921
|
+
// (success, redirect/notFound/error boundary, throw) — neutralizing any
|
|
1922
|
+
// module-stashed reference to a prior request's identity.
|
|
1923
|
+
const proxyRevokers: Array<() => void> = [];
|
|
1716
1924
|
// Accumulates the resolved results of every `serverData.*` read the page
|
|
1717
1925
|
// made during render, keyed identically to the client shim. Serialized
|
|
1718
1926
|
// into `__PYLON_DATA__.ssrData` so hydration replays the same values
|
|
@@ -1790,27 +1998,63 @@ export async function handleRenderRoute(
|
|
|
1790
1998
|
);
|
|
1791
1999
|
|
|
1792
2000
|
// #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
|
|
1793
|
-
// if its output is
|
|
1794
|
-
// flips
|
|
1795
|
-
//
|
|
1796
|
-
//
|
|
1797
|
-
//
|
|
1798
|
-
//
|
|
2001
|
+
// if its output is independent of per-request inputs — so wrap each in a
|
|
2002
|
+
// read-tracking Proxy that flips a flag the moment a page/layout/
|
|
2003
|
+
// generateMetadata observes it (get / `in` / Object.keys / probe). The
|
|
2004
|
+
// proxies are REVOKED in the `finally` (never restored to raw onto `props`),
|
|
2005
|
+
// so the live props object never aliases a prior request's identity. The tail
|
|
2006
|
+
// serializes from the raw `msg` values via `tailProps` below.
|
|
2007
|
+
const track = (
|
|
2008
|
+
obj: Record<string, unknown> | undefined,
|
|
2009
|
+
onTouch: () => void,
|
|
2010
|
+
) => {
|
|
2011
|
+
const { proxy, revoke } = makeRevocableReadTrackingProxy(obj, onTouch);
|
|
2012
|
+
proxyRevokers.push(revoke);
|
|
2013
|
+
return proxy;
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// Reading auth at all (even for an anon request) opts the render OUT of
|
|
2017
|
+
// caching, because the output could differ by identity.
|
|
1799
2018
|
let authTouched = false;
|
|
1800
|
-
const authProxy =
|
|
1801
|
-
|
|
2019
|
+
const authProxy = track(
|
|
2020
|
+
msg.auth as Record<string, unknown> | undefined,
|
|
2021
|
+
() => {
|
|
1802
2022
|
authTouched = true;
|
|
1803
|
-
return Reflect.get(target, prop, receiver);
|
|
1804
2023
|
},
|
|
1805
|
-
|
|
2024
|
+
);
|
|
2025
|
+
|
|
2026
|
+
// Same proof for the OTHER per-request inputs that aren't in the cache key:
|
|
2027
|
+
// reading `headers` (any header, incl. via generateMetadata) or `cookies`
|
|
2028
|
+
// makes the output request-specific. Only USER code reads props.headers/
|
|
2029
|
+
// cookies — the framework's metadata path reads `msg.headers` directly.
|
|
2030
|
+
let dynamicTouched = false;
|
|
2031
|
+
const touchProxy = (obj: Record<string, unknown> | undefined) =>
|
|
2032
|
+
track(obj, () => {
|
|
2033
|
+
dynamicTouched = true;
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// Phase 0 (auth bucketing): `props.session.exists` — the identity-FREE
|
|
2037
|
+
// presence bit, so a page can render a binary auth nav WITHOUT reading real
|
|
2038
|
+
// `auth`. Reading it sets `sessionTouched` (separate from authTouched): it
|
|
2039
|
+
// vetoes the plain anonymous cache (the output now varies by signed-in
|
|
2040
|
+
// bucket) but is PERMITTED by the bucket verdict (which keys the cache on the
|
|
2041
|
+
// bucket).
|
|
2042
|
+
let sessionTouched = false;
|
|
2043
|
+
const sessionProxy = track(
|
|
2044
|
+
{ exists: msg.session_present === true },
|
|
2045
|
+
() => {
|
|
2046
|
+
sessionTouched = true;
|
|
2047
|
+
},
|
|
2048
|
+
);
|
|
1806
2049
|
|
|
1807
2050
|
props = {
|
|
1808
2051
|
url: msg.url,
|
|
1809
2052
|
params: msg.params,
|
|
1810
2053
|
searchParams: msg.search_params,
|
|
1811
|
-
headers: msg.headers,
|
|
1812
|
-
cookies: msg.cookies,
|
|
2054
|
+
headers: touchProxy(msg.headers as Record<string, unknown> | undefined),
|
|
2055
|
+
cookies: touchProxy(msg.cookies as Record<string, unknown> | undefined),
|
|
1813
2056
|
auth: authProxy,
|
|
2057
|
+
session: sessionProxy,
|
|
1814
2058
|
// Response controller — a page/layout calls response.setStatus /
|
|
1815
2059
|
// setHeader / setCookie / redirect / notFound to shape the reply.
|
|
1816
2060
|
response,
|
|
@@ -1818,6 +2062,23 @@ export async function handleRenderRoute(
|
|
|
1818
2062
|
serverData,
|
|
1819
2063
|
};
|
|
1820
2064
|
|
|
2065
|
+
// PPR Phase 0: an immutable, pre-render SNAPSHOT of the only props a bucketed
|
|
2066
|
+
// (SHARED) tail may serialize — url/params/searchParams, all path-derived and
|
|
2067
|
+
// in the cache key. Deep-cloned NOW, before any page/generateMetadata code
|
|
2068
|
+
// runs, because `props.params`/`searchParams` ARE the live `msg` objects: a
|
|
2069
|
+
// page could mutate a nested field to smuggle identity (e.g.
|
|
2070
|
+
// `props.searchParams.leak = props.auth`) WITHOUT tripping read-tracking, and
|
|
2071
|
+
// a shallow allowlist would copy that mutation by reference into the shared
|
|
2072
|
+
// tail. Captured only for opted-in pages (zero cost otherwise).
|
|
2073
|
+
const bucketOptIn = (mod as any).cache === "auth-bucketed";
|
|
2074
|
+
const bucketTailBase = bucketOptIn
|
|
2075
|
+
? {
|
|
2076
|
+
url: msg.url,
|
|
2077
|
+
params: jsonClone(msg.params),
|
|
2078
|
+
searchParams: jsonClone(msg.search_params),
|
|
2079
|
+
}
|
|
2080
|
+
: null;
|
|
2081
|
+
|
|
1821
2082
|
// SEO metadata: static `export const metadata` or dynamic
|
|
1822
2083
|
// `export async function generateMetadata(props)`. Awaited before the
|
|
1823
2084
|
// first byte, so keep it to cheap derivations (params → title); heavy
|
|
@@ -1974,18 +2235,60 @@ export async function handleRenderRoute(
|
|
|
1974
2235
|
// `!Loading`) is the gate: a `streaming = true` page has `Loading` null but
|
|
1975
2236
|
// `wantsStream` true, and must still be excluded. Fail-closed. (See
|
|
1976
2237
|
// computeCacheVerdict — pure + unit-tested for the leak class.)
|
|
1977
|
-
const
|
|
2238
|
+
// PPR Phase 0: `bucketOptIn` (`export const cache = "auth-bucketed"`) was
|
|
2239
|
+
// resolved at props-construction time (it gates the immutable bucketTailBase
|
|
2240
|
+
// snapshot). A bucketed render and the plain anon cache are mutually exclusive
|
|
2241
|
+
// — `cacheable` excludes the opt-in so a bucket page never also emits the anon
|
|
2242
|
+
// proof.
|
|
2243
|
+
const cacheable =
|
|
2244
|
+
!bucketOptIn &&
|
|
2245
|
+
computeCacheVerdict({
|
|
2246
|
+
revalidateSecs,
|
|
2247
|
+
forceDynamic,
|
|
2248
|
+
authTouched,
|
|
2249
|
+
dynamicTouched,
|
|
2250
|
+
sessionTouched,
|
|
2251
|
+
cookieCount: responseState.cookies.length,
|
|
2252
|
+
strictPolicies,
|
|
2253
|
+
wantsStream,
|
|
2254
|
+
status: responseState.status,
|
|
2255
|
+
});
|
|
2256
|
+
// The bucket verdict permits `sessionTouched` (reading the binary signed-in
|
|
2257
|
+
// bit) but still vetoes any real-auth read. When true, this render is stored
|
|
2258
|
+
// as a shared, identity-free, session-presence-keyed entry — so its
|
|
2259
|
+
// hydration tail MUST be anonymized (bucketAuth below) and it advertises the
|
|
2260
|
+
// TRUSTED internal `x-pylon-bucket` proof for the host to key + store it.
|
|
2261
|
+
const bucketable = computeBucketVerdict({
|
|
2262
|
+
bucketOptIn,
|
|
1978
2263
|
revalidateSecs,
|
|
1979
2264
|
forceDynamic,
|
|
1980
2265
|
authTouched,
|
|
2266
|
+
dynamicTouched,
|
|
1981
2267
|
cookieCount: responseState.cookies.length,
|
|
1982
2268
|
strictPolicies,
|
|
1983
2269
|
wantsStream,
|
|
1984
2270
|
status: responseState.status,
|
|
1985
2271
|
});
|
|
1986
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
|
|
2272
|
+
// Serialization view of props built from the RAW `msg` values — the live
|
|
2273
|
+
// `props` (with its read-tracking proxies) is NEVER mutated back to raw, so
|
|
2274
|
+
// it can't become a vehicle that leaks a prior request's identity to a later
|
|
2275
|
+
// render (the proxies are revoked in `finally`). headers/cookies are stripped
|
|
2276
|
+
// inside buildHydrationTail; auth uses the raw value (so anon auth stays
|
|
2277
|
+
// falsy/undefined rather than the proxy's empty `{}`). For a BUCKETED render,
|
|
2278
|
+
// url/params/searchParams come from the pre-render `bucketTailBase` snapshot —
|
|
2279
|
+
// NOT the live (page-mutable) props — so a page can't smuggle identity through
|
|
2280
|
+
// a nested field into the shared tail. buildHydrationTail's bucket allowlist
|
|
2281
|
+
// then serializes only these fields + the collapsed { signedIn } bit.
|
|
2282
|
+
const tailProps = props
|
|
2283
|
+
? {
|
|
2284
|
+
...props,
|
|
2285
|
+
auth: msg.auth,
|
|
2286
|
+
headers: msg.headers,
|
|
2287
|
+
cookies: msg.cookies,
|
|
2288
|
+
session: { exists: msg.session_present === true },
|
|
2289
|
+
...(bucketable && bucketTailBase ? bucketTailBase : {}),
|
|
2290
|
+
}
|
|
2291
|
+
: props;
|
|
1989
2292
|
// #278: on a STREAMING render the head commits NOW, before suspended
|
|
1990
2293
|
// subtrees run. Snapshot what's committed so we can detect (after EOF) a
|
|
1991
2294
|
// late response.setStatus/setCookie/setHeader from a suspended subtree that
|
|
@@ -2005,7 +2308,16 @@ export async function handleRenderRoute(
|
|
|
2005
2308
|
status: responseState.status,
|
|
2006
2309
|
headers: finalizeHeaders(
|
|
2007
2310
|
responseState,
|
|
2008
|
-
|
|
2311
|
+
undefined,
|
|
2312
|
+
// The #277 anon proof and the Phase 0 bucket proof both ride the TRUSTED
|
|
2313
|
+
// `internal` channel (never stripped), so userland (page setHeader /
|
|
2314
|
+
// route-handler headers via `extra`) can't forge either. Mutually
|
|
2315
|
+
// exclusive (bucketOptIn splits the two verdicts).
|
|
2316
|
+
bucketable
|
|
2317
|
+
? { "x-pylon-bucket": String(revalidateSecs) }
|
|
2318
|
+
: cacheable
|
|
2319
|
+
? { "x-pylon-cacheable": String(revalidateSecs) }
|
|
2320
|
+
: undefined,
|
|
2009
2321
|
),
|
|
2010
2322
|
});
|
|
2011
2323
|
|
|
@@ -2144,7 +2456,7 @@ export async function handleRenderRoute(
|
|
|
2144
2456
|
const tail = buildHydrationTail({
|
|
2145
2457
|
component: msg.component,
|
|
2146
2458
|
layouts: msg.layouts ?? [],
|
|
2147
|
-
props,
|
|
2459
|
+
props: tailProps,
|
|
2148
2460
|
ssrData: ssrValueCache,
|
|
2149
2461
|
manifestRoute: preloadManifestRoute,
|
|
2150
2462
|
publicPrefix: preloadPublicPrefix,
|
|
@@ -2154,6 +2466,11 @@ export async function handleRenderRoute(
|
|
|
2154
2466
|
? "error"
|
|
2155
2467
|
: "not-found"
|
|
2156
2468
|
: undefined,
|
|
2469
|
+
// A bucketed render is stored shared → its tail must carry ONLY the
|
|
2470
|
+
// binary signed-in bit, never this request's real identity.
|
|
2471
|
+
bucketAuth: bucketable
|
|
2472
|
+
? { signedIn: msg.session_present === true }
|
|
2473
|
+
: undefined,
|
|
2157
2474
|
});
|
|
2158
2475
|
sendChunk(tail);
|
|
2159
2476
|
}
|
|
@@ -2246,5 +2563,17 @@ export async function handleRenderRoute(
|
|
|
2246
2563
|
message:
|
|
2247
2564
|
devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
|
|
2248
2565
|
});
|
|
2566
|
+
} finally {
|
|
2567
|
+
// Revoke this render's per-request proxies. Any reference a page stashed in
|
|
2568
|
+
// module-level state (props or props.auth/headers/cookies/session) now throws
|
|
2569
|
+
// on access from a LATER render — fail-closed, so a prior request's identity
|
|
2570
|
+
// can never silently enter a cached body without tripping read-tracking.
|
|
2571
|
+
for (const revoke of proxyRevokers) {
|
|
2572
|
+
try {
|
|
2573
|
+
revoke();
|
|
2574
|
+
} catch {
|
|
2575
|
+
// best-effort
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2249
2578
|
}
|
|
2250
2579
|
}
|
|
@@ -18,6 +18,7 @@ import React, { Suspense, use } from "react";
|
|
|
18
18
|
import { renderToReadableStream } from "react-dom/server.browser";
|
|
19
19
|
import {
|
|
20
20
|
buildHydrationTail,
|
|
21
|
+
computeBucketVerdict,
|
|
21
22
|
computeCacheVerdict,
|
|
22
23
|
computeRevalidateSecs,
|
|
23
24
|
computeWantsStream,
|
|
@@ -96,6 +97,8 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
|
96
97
|
revalidateSecs: 60 as number | null,
|
|
97
98
|
forceDynamic: false,
|
|
98
99
|
authTouched: false,
|
|
100
|
+
dynamicTouched: false,
|
|
101
|
+
sessionTouched: false,
|
|
99
102
|
cookieCount: 0,
|
|
100
103
|
strictPolicies: false,
|
|
101
104
|
wantsStream: false,
|
|
@@ -110,6 +113,8 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
|
110
113
|
expect(computeCacheVerdict({ ...base, revalidateSecs: null })).toBe(false); // no opt-in
|
|
111
114
|
expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
|
|
112
115
|
expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
|
|
116
|
+
expect(computeCacheVerdict({ ...base, dynamicTouched: true })).toBe(false); // read headers/cookies
|
|
117
|
+
expect(computeCacheVerdict({ ...base, sessionTouched: true })).toBe(false); // read session.exists (anon cache)
|
|
113
118
|
expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
|
|
114
119
|
expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
|
|
115
120
|
expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
|
|
@@ -133,6 +138,8 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
|
133
138
|
revalidateSecs,
|
|
134
139
|
forceDynamic,
|
|
135
140
|
authTouched,
|
|
141
|
+
dynamicTouched: false,
|
|
142
|
+
sessionTouched: false,
|
|
136
143
|
cookieCount,
|
|
137
144
|
strictPolicies,
|
|
138
145
|
wantsStream,
|
|
@@ -148,6 +155,73 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
|
148
155
|
});
|
|
149
156
|
});
|
|
150
157
|
|
|
158
|
+
describe("computeBucketVerdict (PPR Phase 0 — session-presence shared cache)", () => {
|
|
159
|
+
const base = {
|
|
160
|
+
bucketOptIn: true,
|
|
161
|
+
revalidateSecs: 60 as number | null,
|
|
162
|
+
forceDynamic: false,
|
|
163
|
+
authTouched: false,
|
|
164
|
+
dynamicTouched: false,
|
|
165
|
+
cookieCount: 0,
|
|
166
|
+
strictPolicies: false,
|
|
167
|
+
wantsStream: false,
|
|
168
|
+
status: 200,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
test("a clean opted-in bucketed 200 is bucketable", () => {
|
|
172
|
+
expect(computeBucketVerdict(base)).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("sessionTouched is NOT a veto here (the whole point of a bucket)", () => {
|
|
176
|
+
// computeBucketVerdict has no sessionTouched field — reading session.exists
|
|
177
|
+
// is allowed. This test documents the contract by construction: the same
|
|
178
|
+
// base (which a bucket render reaches with sessionTouched=true) buckets.
|
|
179
|
+
expect(computeBucketVerdict(base)).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("no opt-in → never buckets, even when otherwise clean", () => {
|
|
183
|
+
expect(computeBucketVerdict({ ...base, bucketOptIn: false })).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("every identity/safety veto flips it to non-bucketable (fail-closed)", () => {
|
|
187
|
+
expect(computeBucketVerdict({ ...base, revalidateSecs: null })).toBe(false); // no TTL
|
|
188
|
+
expect(computeBucketVerdict({ ...base, forceDynamic: true })).toBe(false);
|
|
189
|
+
expect(computeBucketVerdict({ ...base, authTouched: true })).toBe(false); // read real auth
|
|
190
|
+
expect(computeBucketVerdict({ ...base, dynamicTouched: true })).toBe(false); // headers/cookies
|
|
191
|
+
expect(computeBucketVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
|
|
192
|
+
expect(computeBucketVerdict({ ...base, strictPolicies: true })).toBe(false); // auth-filtered reads
|
|
193
|
+
expect(computeBucketVerdict({ ...base, wantsStream: true })).toBe(false); // streaming head
|
|
194
|
+
expect(computeBucketVerdict({ ...base, status: 404 })).toBe(false);
|
|
195
|
+
expect(computeBucketVerdict({ ...base, status: 307 })).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("LOAD-BEARING: authTouched ALWAYS vetoes regardless of opt-in", () => {
|
|
199
|
+
// The proof that a bucketable body is identity-free: any output that depends
|
|
200
|
+
// on identity must read props.auth, which sets authTouched. So authTouched
|
|
201
|
+
// ⟹ !bucketable over the full cross-product.
|
|
202
|
+
const bools = [false, true];
|
|
203
|
+
for (const bucketOptIn of bools)
|
|
204
|
+
for (const dynamicTouched of bools)
|
|
205
|
+
for (const strictPolicies of bools)
|
|
206
|
+
for (const wantsStream of bools)
|
|
207
|
+
for (const cookieCount of [0, 1])
|
|
208
|
+
for (const status of [200, 404]) {
|
|
209
|
+
const v = computeBucketVerdict({
|
|
210
|
+
bucketOptIn,
|
|
211
|
+
revalidateSecs: 60,
|
|
212
|
+
forceDynamic: false,
|
|
213
|
+
authTouched: true,
|
|
214
|
+
dynamicTouched,
|
|
215
|
+
cookieCount,
|
|
216
|
+
strictPolicies,
|
|
217
|
+
wantsStream,
|
|
218
|
+
status,
|
|
219
|
+
});
|
|
220
|
+
expect(v).toBe(false);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
151
225
|
describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
|
|
152
226
|
const snap = (over: any = {}) => ({
|
|
153
227
|
status: 200,
|
|
@@ -300,3 +374,144 @@ describe("hydration tail ordering (#278: data blob before entry script)", () =>
|
|
|
300
374
|
expect(tail).toContain('"body":"hi"');
|
|
301
375
|
});
|
|
302
376
|
});
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// PPR Phase 0 — bucketed hydration tail must be IDENTITY-FREE
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
// Pull the __PYLON_DATA__ payload back out of a tail. JSON.parse natively
|
|
383
|
+
// decodes the unicode escapes (< etc.) buildHydrationTail applies.
|
|
384
|
+
function parsePylonData(tail: string): any {
|
|
385
|
+
const m = tail.match(
|
|
386
|
+
/<script id="__PYLON_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
|
|
387
|
+
);
|
|
388
|
+
if (!m) throw new Error("no __PYLON_DATA__ blob in tail");
|
|
389
|
+
return JSON.parse(m[1]);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
describe("bucketed hydration tail (PPR Phase 0 leak class)", () => {
|
|
393
|
+
// Two DIFFERENT signed-in identities. A non-bucketed tail serializes each
|
|
394
|
+
// verbatim; a bucketed tail MUST collapse both to `{ signedIn: true }`.
|
|
395
|
+
const alice = {
|
|
396
|
+
user_id: "user_alice",
|
|
397
|
+
tenant_id: "tenant_1",
|
|
398
|
+
roles: ["admin"],
|
|
399
|
+
email: "alice@x.com",
|
|
400
|
+
};
|
|
401
|
+
const bob = {
|
|
402
|
+
user_id: "user_bob",
|
|
403
|
+
tenant_id: "tenant_2",
|
|
404
|
+
roles: ["member"],
|
|
405
|
+
email: "bob@y.com",
|
|
406
|
+
};
|
|
407
|
+
const baseArgs = (auth: any) => ({
|
|
408
|
+
component: "app/page",
|
|
409
|
+
layouts: [],
|
|
410
|
+
props: {
|
|
411
|
+
url: "/",
|
|
412
|
+
params: {},
|
|
413
|
+
searchParams: {},
|
|
414
|
+
auth,
|
|
415
|
+
session: { exists: true },
|
|
416
|
+
},
|
|
417
|
+
ssrData: {},
|
|
418
|
+
manifestRoute: { file: "index.js", imports: [], css: [] },
|
|
419
|
+
publicPrefix: "/_pylon/build/",
|
|
420
|
+
manifestErr: null,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("non-bucketed tail keeps real auth (unchanged legacy behavior)", () => {
|
|
424
|
+
const tail = buildHydrationTail(baseArgs(alice));
|
|
425
|
+
expect(parsePylonData(tail).props.auth).toEqual(alice);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("bucketed signed-in tail carries ONLY { signedIn: true }", () => {
|
|
429
|
+
const tail = buildHydrationTail({
|
|
430
|
+
...baseArgs(alice),
|
|
431
|
+
bucketAuth: { signedIn: true },
|
|
432
|
+
});
|
|
433
|
+
const data = parsePylonData(tail);
|
|
434
|
+
expect(data.props.auth).toEqual({ signedIn: true });
|
|
435
|
+
expect(data.props.session).toEqual({ exists: true });
|
|
436
|
+
// No identity escapes — not in props, not anywhere in the rendered tail.
|
|
437
|
+
expect(tail).not.toContain("user_alice");
|
|
438
|
+
expect(tail).not.toContain("tenant_1");
|
|
439
|
+
expect(tail).not.toContain("alice@x.com");
|
|
440
|
+
expect(tail).not.toContain("admin");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("bucketed signed-out tail carries ONLY { signedIn: false }", () => {
|
|
444
|
+
const tail = buildHydrationTail({
|
|
445
|
+
...baseArgs(null),
|
|
446
|
+
props: {
|
|
447
|
+
url: "/",
|
|
448
|
+
params: {},
|
|
449
|
+
searchParams: {},
|
|
450
|
+
auth: null,
|
|
451
|
+
session: { exists: false },
|
|
452
|
+
},
|
|
453
|
+
bucketAuth: { signedIn: false },
|
|
454
|
+
});
|
|
455
|
+
const data = parsePylonData(tail);
|
|
456
|
+
expect(data.props.auth).toEqual({ signedIn: false });
|
|
457
|
+
expect(data.props.session).toEqual({ exists: false });
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("LEAK INVARIANT: two identities → byte-identical bucketed tail", () => {
|
|
461
|
+
// The load-bearing property. If these two tails differed by a single byte,
|
|
462
|
+
// a shared cache entry would replay alice's identity to bob (or vice-versa).
|
|
463
|
+
const aTail = buildHydrationTail({
|
|
464
|
+
...baseArgs(alice),
|
|
465
|
+
bucketAuth: { signedIn: true },
|
|
466
|
+
});
|
|
467
|
+
const bTail = buildHydrationTail({
|
|
468
|
+
...baseArgs(bob),
|
|
469
|
+
bucketAuth: { signedIn: true },
|
|
470
|
+
});
|
|
471
|
+
expect(aTail).toBe(bTail);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("bucketed tail DROPS page-added props (alias-identity fence, codex #4)", () => {
|
|
475
|
+
// A page can alias identity onto a custom key without tripping read-tracking
|
|
476
|
+
// (`props.leak = props.auth` — props is a plain object, no proxy trap). A
|
|
477
|
+
// bucketed (SHARED) tail must therefore serialize a strict ALLOWLIST of
|
|
478
|
+
// framework props, never a spread — else `leak` would carry one user's
|
|
479
|
+
// identity into every signed-in visitor's cached page.
|
|
480
|
+
const tail = buildHydrationTail({
|
|
481
|
+
...baseArgs(alice),
|
|
482
|
+
props: {
|
|
483
|
+
url: "/",
|
|
484
|
+
params: { id: "x" },
|
|
485
|
+
searchParams: {},
|
|
486
|
+
auth: alice,
|
|
487
|
+
session: { exists: true },
|
|
488
|
+
// Page-aliased identity + a copied cookie bag.
|
|
489
|
+
leak: { uid: alice.user_id },
|
|
490
|
+
sneaky: alice.email,
|
|
491
|
+
},
|
|
492
|
+
bucketAuth: { signedIn: true },
|
|
493
|
+
});
|
|
494
|
+
const data = parsePylonData(tail);
|
|
495
|
+
expect(data.props.auth).toEqual({ signedIn: true });
|
|
496
|
+
// Allowlisted framework fields survive…
|
|
497
|
+
expect(data.props.url).toBe("/");
|
|
498
|
+
expect(data.props.params).toEqual({ id: "x" });
|
|
499
|
+
// …but the page-added keys are GONE, and no identity is anywhere in the tail.
|
|
500
|
+
expect(data.props.leak).toBeUndefined();
|
|
501
|
+
expect(data.props.sneaky).toBeUndefined();
|
|
502
|
+
expect(tail).not.toContain("user_alice");
|
|
503
|
+
expect(tail).not.toContain("alice@x.com");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("NON-bucket tail still preserves page-added props (no behavior change)", () => {
|
|
507
|
+
// The allowlist applies ONLY to bucketed renders. A normal (non-shared)
|
|
508
|
+
// render keeps serializing all props so hydration matches the server tree.
|
|
509
|
+
const tail = buildHydrationTail({
|
|
510
|
+
...baseArgs(alice),
|
|
511
|
+
props: { url: "/", params: {}, searchParams: {}, auth: alice, custom: 42 },
|
|
512
|
+
});
|
|
513
|
+
const data = parsePylonData(tail);
|
|
514
|
+
expect(data.props.custom).toBe(42);
|
|
515
|
+
expect(data.props.auth).toEqual(alice);
|
|
516
|
+
});
|
|
517
|
+
});
|