@pylonsync/functions 0.3.296 → 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.
@@ -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
- export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>): Record<string, string>;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.296",
3
+ "version": "0.3.298",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -868,11 +868,8 @@ async function _doBuild(appDirRel: string): Promise<BuildOutput> {
868
868
  return _doBuildInner(fs, path, cwd, appDirRel);
869
869
  }
870
870
 
871
- // Monotonic per-process counter for the Tailwind temp filename. `pylon dev`
872
- // warms the SSR bundle in one runner process while incoming requests can drive
873
- // their own rebuild in another, so two `buildTailwind` calls may run against the
874
- // same outdir at once. Combined with the pid it gives every compile a unique
875
- // temp path, so concurrent builds never rename each other's file away.
871
+ // Per-process counter for the Tailwind temp filename with the pid it gives
872
+ // every concurrent compile a unique temp path (see buildTailwind below).
876
873
  let _styleBuildSeq = 0;
877
874
 
878
875
  /**
@@ -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.
@@ -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
- const h: Record<string, string> = { ...state.headers, ...(extra ?? {}) };
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
- // Only honor a forwarded proto for a TRUSTED host else an attacker
815
- // could downgrade the cached URL to http://. Default https off-loopback.
816
- const proto = opts.forwardedProto || (isLoopback ? "http" : "https");
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 auth-INDEPENDENT — so wrap props.auth in a Proxy that
1794
- // flips `authTouched` the moment a page/layout reads it. Reading auth at
1795
- // all (even for an anonymous request) opts the render OUT of caching,
1796
- // because the output could differ by identity. The raw auth is restored
1797
- // before serialization (so the hydration blob carries real values, and so
1798
- // JSON.stringify doesn't trip the Proxy itself).
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 = new Proxy(msg.auth as Record<string, unknown>, {
1801
- get(target, prop, receiver) {
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 Proxy was only
1987
- // for the render-time auth-touch probe).
1988
- if (props) props.auth = msg.auth;
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
- cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : {},
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,