@pylonsync/functions 0.3.297 → 0.3.298
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 +11 -1
- package/package.json +1 -1
- package/src/ssr-runtime.test.ts +95 -0
- package/src/ssr-runtime.ts +118 -20
- package/src/ssr-streaming.test.ts +3 -0
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -142,7 +142,16 @@ export declare function makeResponseController(state: ResponseState, defaultRedi
|
|
|
142
142
|
* into one `Set-Cookie` header each (newline is forbidden inside a
|
|
143
143
|
* cookie, so it can't be turned into header injection).
|
|
144
144
|
*/
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Wrap a per-request object so ANY observation — property `get`, `in` (`has`),
|
|
147
|
+
* `Object.keys`/spread/`for…in` (`ownKeys`), or a descriptor probe — calls
|
|
148
|
+
* `onTouch`. Used to mark a render request-specific (vetoing shared caching) the
|
|
149
|
+
* instant it reads auth/headers/cookies. A bare `get` trap misses `in` and
|
|
150
|
+
* `Object.keys`, which would observe the data without tripping the veto.
|
|
151
|
+
* Exported for direct unit testing of that property.
|
|
152
|
+
*/
|
|
153
|
+
export declare function makeReadTrackingProxy(obj: Record<string, unknown> | undefined, onTouch: () => void): Record<string, unknown>;
|
|
154
|
+
export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>): Record<string, string>;
|
|
146
155
|
/**
|
|
147
156
|
* Phase 1 SSR handler. Resolves the component, renders it via
|
|
148
157
|
* react-dom/server.renderToReadableStream, pumps chunks back to the
|
|
@@ -355,6 +364,7 @@ export declare function computeCacheVerdict(args: {
|
|
|
355
364
|
revalidateSecs: number | null;
|
|
356
365
|
forceDynamic: boolean;
|
|
357
366
|
authTouched: boolean;
|
|
367
|
+
dynamicTouched: boolean;
|
|
358
368
|
cookieCount: number;
|
|
359
369
|
strictPolicies: boolean;
|
|
360
370
|
wantsStream: boolean;
|
package/package.json
CHANGED
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
isSafeRedirect,
|
|
15
15
|
asRouteControl,
|
|
16
16
|
PylonRouteControl,
|
|
17
|
+
finalizeHeaders,
|
|
18
|
+
makeResponseController,
|
|
19
|
+
makeReadTrackingProxy,
|
|
17
20
|
} from "./ssr-runtime";
|
|
18
21
|
|
|
19
22
|
describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
|
|
@@ -53,6 +56,98 @@ describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () =
|
|
|
53
56
|
test("no public origin + untrusted host → empty (relative, never poisoned)", () => {
|
|
54
57
|
expect(resolveOrigin({ host: "evil.com" })).toBe("");
|
|
55
58
|
});
|
|
59
|
+
|
|
60
|
+
test("off-loopback never honors X-Forwarded-Proto (no downgrade poisoning)", () => {
|
|
61
|
+
// Even on a TRUSTED host, a client-supplied proto must not change the
|
|
62
|
+
// absolute origin — it feeds og:image/canonical and is cache-keyed only by
|
|
63
|
+
// host, so honoring `http` would downgrade the cached URL for everyone.
|
|
64
|
+
// Off-loopback is ALWAYS https.
|
|
65
|
+
expect(
|
|
66
|
+
resolveOrigin({ host: "www.notbehind.com", publicUrl, forwardedProto: "http" }),
|
|
67
|
+
).toBe("https://www.notbehind.com");
|
|
68
|
+
expect(
|
|
69
|
+
resolveOrigin({
|
|
70
|
+
host: "www.notbehind.com",
|
|
71
|
+
publicUrl,
|
|
72
|
+
forwardedProto: "javascript:alert(1)",
|
|
73
|
+
}),
|
|
74
|
+
).toBe("https://www.notbehind.com");
|
|
75
|
+
// Loopback (dev) may be http; an explicit https there is honored.
|
|
76
|
+
expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "http" })).toBe(
|
|
77
|
+
"http://localhost:4321",
|
|
78
|
+
);
|
|
79
|
+
expect(resolveOrigin({ host: "localhost:4321", forwardedProto: "https" })).toBe(
|
|
80
|
+
"https://localhost:4321",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () => {
|
|
86
|
+
test("response.setHeader() rejects the reserved x-pylon-* namespace", () => {
|
|
87
|
+
const state = {
|
|
88
|
+
status: 200,
|
|
89
|
+
headers: {} as Record<string, string>,
|
|
90
|
+
cookies: [] as string[],
|
|
91
|
+
};
|
|
92
|
+
const res = makeResponseController(state);
|
|
93
|
+
// Forging the #277 cache proof from userland must throw, not silently set it.
|
|
94
|
+
expect(() => res.setHeader("x-pylon-cacheable", "300")).toThrow(/reserved/i);
|
|
95
|
+
expect(() => res.setHeader("X-Pylon-Anything", "1")).toThrow(/reserved/i);
|
|
96
|
+
// An ordinary header still works.
|
|
97
|
+
res.setHeader("x-custom", "ok");
|
|
98
|
+
expect(state.headers["x-custom"]).toBe("ok");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("finalizeHeaders: x-pylon-* survives ONLY from the trusted internal channel", () => {
|
|
102
|
+
// The #277 proof must come from the 3rd `internal` arg. A page-set header
|
|
103
|
+
// (state.headers) OR a route-handler header (the 2nd `extra` arg, which
|
|
104
|
+
// ssr-form-runtime fills from user-returned headers) is stripped — so
|
|
105
|
+
// userland can't forge the host-side cache verdict through ANY path.
|
|
106
|
+
const state = {
|
|
107
|
+
status: 200,
|
|
108
|
+
headers: { "x-pylon-cacheable": "999", "x-keep": "yes" } as Record<string, string>,
|
|
109
|
+
cookies: [] as string[],
|
|
110
|
+
};
|
|
111
|
+
const out = finalizeHeaders(
|
|
112
|
+
state,
|
|
113
|
+
{ "x-pylon-cacheable": "888", "x-extra": "e" }, // untrusted extra → stripped
|
|
114
|
+
{ "x-pylon-cacheable": "60" }, // trusted internal → kept
|
|
115
|
+
);
|
|
116
|
+
expect(out["x-pylon-cacheable"]).toBe("60"); // only the trusted value
|
|
117
|
+
expect(out["x-keep"]).toBe("yes");
|
|
118
|
+
expect(out["x-extra"]).toBe("e"); // a non-reserved extra header still merges
|
|
119
|
+
|
|
120
|
+
// No internal proof → NO x-pylon-* survives, from page headers OR extra.
|
|
121
|
+
const out2 = finalizeHeaders(
|
|
122
|
+
{ status: 200, headers: { "x-pylon-cacheable": "999" }, cookies: [] as string[] },
|
|
123
|
+
{ "x-pylon-cacheable": "777" },
|
|
124
|
+
);
|
|
125
|
+
expect(out2["x-pylon-cacheable"]).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("makeReadTrackingProxy trips on get / in / Object.keys / descriptor / spread", () => {
|
|
129
|
+
const probes: Array<(o: any) => unknown> = [
|
|
130
|
+
(o) => o.host,
|
|
131
|
+
(o) => "host" in o,
|
|
132
|
+
(o) => Object.keys(o),
|
|
133
|
+
(o) => Object.getOwnPropertyDescriptor(o, "host"),
|
|
134
|
+
(o) => ({ ...o }),
|
|
135
|
+
];
|
|
136
|
+
for (const probe of probes) {
|
|
137
|
+
let touched = false;
|
|
138
|
+
const p = makeReadTrackingProxy({ host: "x" }, () => {
|
|
139
|
+
touched = true;
|
|
140
|
+
});
|
|
141
|
+
probe(p);
|
|
142
|
+
expect(touched).toBe(true); // a bare `get` trap would miss in/keys
|
|
143
|
+
}
|
|
144
|
+
// No observation → never touched.
|
|
145
|
+
let t = false;
|
|
146
|
+
makeReadTrackingProxy({ host: "x" }, () => {
|
|
147
|
+
t = true;
|
|
148
|
+
});
|
|
149
|
+
expect(t).toBe(false);
|
|
150
|
+
});
|
|
56
151
|
});
|
|
57
152
|
|
|
58
153
|
// Pull the JSON out of the `__PYLON_DATA__` <script> a hydration tail emits.
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -253,6 +253,16 @@ export function makeResponseController(
|
|
|
253
253
|
if (!TOKEN_RE.test(name)) {
|
|
254
254
|
throw new Error(`pylon ssr: invalid header name ${JSON.stringify(name)}`);
|
|
255
255
|
}
|
|
256
|
+
// `x-pylon-*` is a reserved internal namespace — the host trusts headers
|
|
257
|
+
// like the #277 `x-pylon-cacheable` cache proof as runtime-emitted. Letting
|
|
258
|
+
// userland set one would forge the cache verdict (e.g. mark a personalized
|
|
259
|
+
// render shareable). Reject it loudly.
|
|
260
|
+
if (name.toLowerCase().startsWith("x-pylon-")) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`pylon ssr: "x-pylon-*" is a reserved internal header namespace and ` +
|
|
263
|
+
`cannot be set via response.setHeader() (got ${JSON.stringify(name)})`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
256
266
|
assertNoControlChars(value, "header value");
|
|
257
267
|
state.headers[name.toLowerCase()] = value;
|
|
258
268
|
},
|
|
@@ -299,11 +309,63 @@ export function makeResponseController(
|
|
|
299
309
|
* into one `Set-Cookie` header each (newline is forbidden inside a
|
|
300
310
|
* cookie, so it can't be turned into header injection).
|
|
301
311
|
*/
|
|
312
|
+
/**
|
|
313
|
+
* Wrap a per-request object so ANY observation — property `get`, `in` (`has`),
|
|
314
|
+
* `Object.keys`/spread/`for…in` (`ownKeys`), or a descriptor probe — calls
|
|
315
|
+
* `onTouch`. Used to mark a render request-specific (vetoing shared caching) the
|
|
316
|
+
* instant it reads auth/headers/cookies. A bare `get` trap misses `in` and
|
|
317
|
+
* `Object.keys`, which would observe the data without tripping the veto.
|
|
318
|
+
* Exported for direct unit testing of that property.
|
|
319
|
+
*/
|
|
320
|
+
export function makeReadTrackingProxy(
|
|
321
|
+
obj: Record<string, unknown> | undefined,
|
|
322
|
+
onTouch: () => void,
|
|
323
|
+
): Record<string, unknown> {
|
|
324
|
+
return new Proxy((obj ?? {}) as Record<string, unknown>, {
|
|
325
|
+
get(t, p, r) {
|
|
326
|
+
onTouch();
|
|
327
|
+
return Reflect.get(t, p, r);
|
|
328
|
+
},
|
|
329
|
+
has(t, p) {
|
|
330
|
+
onTouch();
|
|
331
|
+
return Reflect.has(t, p);
|
|
332
|
+
},
|
|
333
|
+
ownKeys(t) {
|
|
334
|
+
onTouch();
|
|
335
|
+
return Reflect.ownKeys(t);
|
|
336
|
+
},
|
|
337
|
+
getOwnPropertyDescriptor(t, p) {
|
|
338
|
+
onTouch();
|
|
339
|
+
return Reflect.getOwnPropertyDescriptor(t, p);
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
302
344
|
export function finalizeHeaders(
|
|
303
345
|
state: ResponseState,
|
|
346
|
+
// UNTRUSTED user headers (page-set `state.headers` and, at the form/data-route
|
|
347
|
+
// call sites, a route handler's returned headers). `x-pylon-*` is stripped.
|
|
304
348
|
extra?: Record<string, string>,
|
|
349
|
+
// TRUSTED runtime headers (e.g. the #277 `x-pylon-cacheable` proof). The ONLY
|
|
350
|
+
// legitimate source of `x-pylon-*`; merged last and never stripped.
|
|
351
|
+
internal?: Record<string, string>,
|
|
305
352
|
): Record<string, string> {
|
|
306
|
-
|
|
353
|
+
// The host TRUSTS `x-pylon-*` headers (cache verdict, etc.). They must come
|
|
354
|
+
// ONLY from `internal` — strip them from BOTH page-set headers AND `extra` so
|
|
355
|
+
// userland can't forge the verdict through any path (setHeader is also
|
|
356
|
+
// rejected at the source, this is defense-in-depth + covers route-handler
|
|
357
|
+
// headers that flow through `extra`).
|
|
358
|
+
const h: Record<string, string> = {};
|
|
359
|
+
const mergeStripped = (src?: Record<string, string>) => {
|
|
360
|
+
if (!src) return;
|
|
361
|
+
for (const [k, v] of Object.entries(src)) {
|
|
362
|
+
if (k.toLowerCase().startsWith("x-pylon-")) continue;
|
|
363
|
+
h[k] = v; // later sources override earlier (page < extra)
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
mergeStripped(state.headers);
|
|
367
|
+
mergeStripped(extra);
|
|
368
|
+
if (internal) Object.assign(h, internal); // trusted, never stripped
|
|
307
369
|
if (!h["content-type"]) h["content-type"] = "text/html; charset=utf-8";
|
|
308
370
|
if (state.cookies.length > 0) {
|
|
309
371
|
// Preserve a set-cookie value set via setHeader() (rare) and join it
|
|
@@ -811,9 +873,17 @@ export function resolveOrigin(opts: {
|
|
|
811
873
|
for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
|
|
812
874
|
const isLoopback = LOOPBACK_HOST.test(host);
|
|
813
875
|
if (isLoopback || allow.has(host)) {
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
876
|
+
// Off-loopback (prod) we ALWAYS use https and never honor the request's
|
|
877
|
+
// X-Forwarded-Proto. The SSR cache is keyed only by host (not proto), so
|
|
878
|
+
// honoring a client-supplied `http` would poison the cached canonical/OG
|
|
879
|
+
// URL with a downgraded scheme for every subsequent visitor. Loopback
|
|
880
|
+
// (dev) may be plain http. (A genuinely non-https prod origin should set
|
|
881
|
+
// PYLON_PUBLIC_URL explicitly, which takes precedence above.)
|
|
882
|
+
const proto = isLoopback
|
|
883
|
+
? opts.forwardedProto === "https"
|
|
884
|
+
? "https"
|
|
885
|
+
: "http"
|
|
886
|
+
: "https";
|
|
817
887
|
return `${proto}://${host}`;
|
|
818
888
|
}
|
|
819
889
|
}
|
|
@@ -1486,6 +1556,13 @@ export function computeCacheVerdict(args: {
|
|
|
1486
1556
|
revalidateSecs: number | null;
|
|
1487
1557
|
forceDynamic: boolean;
|
|
1488
1558
|
authTouched: boolean;
|
|
1559
|
+
// True when the render read any OTHER per-request input that is not part of
|
|
1560
|
+
// the cache key — request `headers` (incl. non-bucketed ones) or `cookies`
|
|
1561
|
+
// (incl. via generateMetadata). Such a read makes the output request-specific,
|
|
1562
|
+
// so it must veto shared caching exactly like `authTouched`. (Keyed inputs —
|
|
1563
|
+
// pathname, and Host via the host bucket — are handled by the cache key and do
|
|
1564
|
+
// not set this.)
|
|
1565
|
+
dynamicTouched: boolean;
|
|
1489
1566
|
cookieCount: number;
|
|
1490
1567
|
strictPolicies: boolean;
|
|
1491
1568
|
wantsStream: boolean;
|
|
@@ -1495,6 +1572,7 @@ export function computeCacheVerdict(args: {
|
|
|
1495
1572
|
args.revalidateSecs != null &&
|
|
1496
1573
|
!args.forceDynamic &&
|
|
1497
1574
|
!args.authTouched &&
|
|
1575
|
+
!args.dynamicTouched &&
|
|
1498
1576
|
args.cookieCount === 0 &&
|
|
1499
1577
|
!args.strictPolicies &&
|
|
1500
1578
|
!args.wantsStream &&
|
|
@@ -1790,26 +1868,37 @@ export async function handleRenderRoute(
|
|
|
1790
1868
|
);
|
|
1791
1869
|
|
|
1792
1870
|
// #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
|
|
1793
|
-
// if its output is
|
|
1794
|
-
//
|
|
1795
|
-
//
|
|
1796
|
-
//
|
|
1797
|
-
|
|
1798
|
-
//
|
|
1871
|
+
// if its output is independent of per-request inputs — so wrap each in a
|
|
1872
|
+
// read-tracking Proxy (makeReadTrackingProxy) that flips a flag the moment a
|
|
1873
|
+
// page/layout/generateMetadata observes it (get / `in` / Object.keys / probe).
|
|
1874
|
+
// The raw objects are restored before serialization.
|
|
1875
|
+
|
|
1876
|
+
// Reading auth at all (even for an anon request) opts the render OUT of
|
|
1877
|
+
// caching, because the output could differ by identity.
|
|
1799
1878
|
let authTouched = false;
|
|
1800
|
-
const authProxy =
|
|
1801
|
-
|
|
1879
|
+
const authProxy = makeReadTrackingProxy(
|
|
1880
|
+
msg.auth as Record<string, unknown> | undefined,
|
|
1881
|
+
() => {
|
|
1802
1882
|
authTouched = true;
|
|
1803
|
-
return Reflect.get(target, prop, receiver);
|
|
1804
1883
|
},
|
|
1805
|
-
|
|
1884
|
+
);
|
|
1885
|
+
|
|
1886
|
+
// Same proof for the OTHER per-request inputs that aren't in the cache key:
|
|
1887
|
+
// reading `headers` (any header, incl. via generateMetadata) or `cookies`
|
|
1888
|
+
// makes the output request-specific. Only USER code reads props.headers/
|
|
1889
|
+
// cookies — the framework's metadata path reads `msg.headers` directly.
|
|
1890
|
+
let dynamicTouched = false;
|
|
1891
|
+
const touchProxy = (obj: Record<string, unknown> | undefined) =>
|
|
1892
|
+
makeReadTrackingProxy(obj, () => {
|
|
1893
|
+
dynamicTouched = true;
|
|
1894
|
+
});
|
|
1806
1895
|
|
|
1807
1896
|
props = {
|
|
1808
1897
|
url: msg.url,
|
|
1809
1898
|
params: msg.params,
|
|
1810
1899
|
searchParams: msg.search_params,
|
|
1811
|
-
headers: msg.headers,
|
|
1812
|
-
cookies: msg.cookies,
|
|
1900
|
+
headers: touchProxy(msg.headers as Record<string, unknown> | undefined),
|
|
1901
|
+
cookies: touchProxy(msg.cookies as Record<string, unknown> | undefined),
|
|
1813
1902
|
auth: authProxy,
|
|
1814
1903
|
// Response controller — a page/layout calls response.setStatus /
|
|
1815
1904
|
// setHeader / setCookie / redirect / notFound to shape the reply.
|
|
@@ -1978,14 +2067,19 @@ export async function handleRenderRoute(
|
|
|
1978
2067
|
revalidateSecs,
|
|
1979
2068
|
forceDynamic,
|
|
1980
2069
|
authTouched,
|
|
2070
|
+
dynamicTouched,
|
|
1981
2071
|
cookieCount: responseState.cookies.length,
|
|
1982
2072
|
strictPolicies,
|
|
1983
2073
|
wantsStream,
|
|
1984
2074
|
status: responseState.status,
|
|
1985
2075
|
});
|
|
1986
|
-
// Restore the raw auth before any serialization below (the
|
|
1987
|
-
// for the render-time
|
|
1988
|
-
if (props)
|
|
2076
|
+
// Restore the raw auth/headers/cookies before any serialization below (the
|
|
2077
|
+
// Proxies were only for the render-time touch probe).
|
|
2078
|
+
if (props) {
|
|
2079
|
+
props.auth = msg.auth;
|
|
2080
|
+
props.headers = msg.headers;
|
|
2081
|
+
props.cookies = msg.cookies;
|
|
2082
|
+
}
|
|
1989
2083
|
// #278: on a STREAMING render the head commits NOW, before suspended
|
|
1990
2084
|
// subtrees run. Snapshot what's committed so we can detect (after EOF) a
|
|
1991
2085
|
// late response.setStatus/setCookie/setHeader from a suspended subtree that
|
|
@@ -2005,7 +2099,11 @@ export async function handleRenderRoute(
|
|
|
2005
2099
|
status: responseState.status,
|
|
2006
2100
|
headers: finalizeHeaders(
|
|
2007
2101
|
responseState,
|
|
2008
|
-
|
|
2102
|
+
undefined,
|
|
2103
|
+
// The #277 proof rides the TRUSTED `internal` channel (never stripped),
|
|
2104
|
+
// so userland (page setHeader / route-handler headers via `extra`) can't
|
|
2105
|
+
// forge it.
|
|
2106
|
+
cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : undefined,
|
|
2009
2107
|
),
|
|
2010
2108
|
});
|
|
2011
2109
|
|
|
@@ -96,6 +96,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
|
96
96
|
revalidateSecs: 60 as number | null,
|
|
97
97
|
forceDynamic: false,
|
|
98
98
|
authTouched: false,
|
|
99
|
+
dynamicTouched: false,
|
|
99
100
|
cookieCount: 0,
|
|
100
101
|
strictPolicies: false,
|
|
101
102
|
wantsStream: false,
|
|
@@ -110,6 +111,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
|
110
111
|
expect(computeCacheVerdict({ ...base, revalidateSecs: null })).toBe(false); // no opt-in
|
|
111
112
|
expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
|
|
112
113
|
expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
|
|
114
|
+
expect(computeCacheVerdict({ ...base, dynamicTouched: true })).toBe(false); // read headers/cookies
|
|
113
115
|
expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
|
|
114
116
|
expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
|
|
115
117
|
expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
|
|
@@ -133,6 +135,7 @@ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
|
|
|
133
135
|
revalidateSecs,
|
|
134
136
|
forceDynamic,
|
|
135
137
|
authTouched,
|
|
138
|
+
dynamicTouched: false,
|
|
136
139
|
cookieCount,
|
|
137
140
|
strictPolicies,
|
|
138
141
|
wantsStream,
|