@pylonsync/functions 0.3.309 → 0.3.310

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/runtime.d.ts CHANGED
@@ -15,5 +15,5 @@
15
15
  * needs to queue multiple RPCs per call_id.
16
16
  */
17
17
  import type { DbReader, DbWriter } from "./types";
18
- export declare function buildDbReader(callId: string): DbReader;
18
+ export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
19
  export declare function buildDbWriter(callId: string): DbWriter;
@@ -181,7 +181,7 @@ export declare function makeRevocableReadTrackingProxy(obj: Record<string, unkno
181
181
  proxy: Record<string, unknown>;
182
182
  revoke: () => void;
183
183
  };
184
- export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>): Record<string, string>;
184
+ export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>, effectiveStatus?: number): Record<string, string>;
185
185
  /**
186
186
  * Phase 1 SSR handler. Resolves the component, renders it via
187
187
  * react-dom/server.renderToReadableStream, pumps chunks back to the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.309",
3
+ "version": "0.3.310",
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",
package/src/runtime.ts CHANGED
@@ -391,11 +391,24 @@ function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
391
391
  // `serverData` read handle that reuses this module's `send` + `pendingRpcs`
392
392
  // + reader loop. The render call_id ("r_<n>") correlates DB replies back
393
393
  // through the shared pendingRpcs map.
394
- export function buildDbReader(callId: string): DbReader {
395
- return { ...buildReaderOps(callId, false), unsafe: buildReaderOps(callId, true) };
394
+ export function buildDbReader(callId: string, ssrRead = false): DbReader {
395
+ return {
396
+ ...buildReaderOps(callId, false, ssrRead),
397
+ unsafe: buildReaderOps(callId, true, ssrRead),
398
+ };
396
399
  }
397
400
 
398
- function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsafe"> {
401
+ function buildReaderOps(
402
+ callId: string,
403
+ unsafeOp: boolean,
404
+ // `ssrRead`: true when the reader backs SSR `serverData.*` (results are
405
+ // serialized into the client-visible `__PYLON_DATA__` blob). The Rust side
406
+ // then applies the same per-row policy filter + `server_only`/`passwordHash`
407
+ // projection the entity/sync read API does — UNLESS `unsafe_op` is also set
408
+ // (`serverData.unsafe.*` stays server-trust). Server-function `ctx.db.*`
409
+ // leaves it false and reads raw.
410
+ ssrRead = false,
411
+ ): Omit<DbReader, "unsafe"> {
399
412
  // All DB ops use rpcDb so Promise.all over ctx.db reads can run in
400
413
  // parallel without colliding on the outer call_id key.
401
414
  //
@@ -412,6 +425,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
412
425
  entity,
413
426
  id,
414
427
  unsafe_op: unsafeOp,
428
+ ssr_read: ssrRead,
415
429
  })) as any;
416
430
  },
417
431
  async list(entity) {
@@ -420,6 +434,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
420
434
  op: "list",
421
435
  entity,
422
436
  unsafe_op: unsafeOp,
437
+ ssr_read: ssrRead,
423
438
  })) as any;
424
439
  },
425
440
  async lookup(entity, field, value) {
@@ -430,6 +445,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
430
445
  field,
431
446
  value,
432
447
  unsafe_op: unsafeOp,
448
+ ssr_read: ssrRead,
433
449
  })) as any;
434
450
  },
435
451
  async query(entity, filter) {
@@ -439,6 +455,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
439
455
  entity,
440
456
  data: filter,
441
457
  unsafe_op: unsafeOp,
458
+ ssr_read: ssrRead,
442
459
  })) as any;
443
460
  },
444
461
  async queryGraph(query) {
@@ -448,6 +465,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
448
465
  entity: "",
449
466
  data: query,
450
467
  unsafe_op: unsafeOp,
468
+ ssr_read: ssrRead,
451
469
  })) as any;
452
470
  },
453
471
  async paginate(entity, opts) {
@@ -461,6 +479,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
461
479
  after: opts.cursor ?? undefined,
462
480
  limit: numItems,
463
481
  unsafe_op: unsafeOp,
482
+ ssr_read: ssrRead,
464
483
  })) as any;
465
484
  },
466
485
  async search(entity, query) {
@@ -470,6 +489,7 @@ function buildReaderOps(callId: string, unsafeOp: boolean): Omit<DbReader, "unsa
470
489
  entity,
471
490
  data: query,
472
491
  unsafe_op: unsafeOp,
492
+ ssr_read: ssrRead,
473
493
  })) as any;
474
494
  },
475
495
  };
@@ -1057,11 +1077,15 @@ async function main() {
1057
1077
  send({ type: "ready", functions });
1058
1078
 
1059
1079
  // Belt-and-suspenders against orphaning: if the host dies in a way that
1060
- // somehow leaves our stdin open, we'll have been reparented to init
1061
- // (ppid === 1). Notice and exit. Unref'd so it never keeps us alive on its
1062
- // own.
1080
+ // somehow leaves our stdin open, we'll have been REPARENTED — our ppid
1081
+ // changes (to init or the nearest subreaper). Compare against the ppid we
1082
+ // were born with rather than testing `ppid === 1`: in a container the
1083
+ // host pylon usually IS PID 1, so every healthy runner is born with
1084
+ // ppid 1 and the equality check kills the whole pool in a 2s respawn
1085
+ // loop. Unref'd so the watch never keeps us alive on its own.
1086
+ const initialPpid = process.ppid;
1063
1087
  const orphanWatch = setInterval(() => {
1064
- if (process.ppid === 1) process.exit(0);
1088
+ if (process.ppid !== initialPpid) process.exit(0);
1065
1089
  }, 2000);
1066
1090
  if (typeof orphanWatch.unref === "function") orphanWatch.unref();
1067
1091
 
@@ -172,11 +172,15 @@ export async function handleForm(
172
172
  for (const [k, v] of Object.entries(raw.headers ?? {})) {
173
173
  extra[k.toLowerCase()] = String(v);
174
174
  }
175
+ const rawStatus = raw.status ?? responseState.status;
175
176
  send({
176
177
  type: "response_start",
177
178
  call_id: msg.call_id,
178
- status: raw.status ?? responseState.status,
179
- headers: finalizeHeaders(responseState, extra),
179
+ status: rawStatus,
180
+ // Pass rawStatus so the open-redirect guard sees the handler's own
181
+ // status (a raw GET's redirect status lives on `raw.status`, not
182
+ // `responseState.status`).
183
+ headers: finalizeHeaders(responseState, extra, undefined, rawStatus),
180
184
  });
181
185
  if (raw.body != null) {
182
186
  const bodyStr =
@@ -127,6 +127,54 @@ describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () =
127
127
  expect(out2["x-pylon-cacheable"]).toBeUndefined();
128
128
  });
129
129
 
130
+ test("finalizeHeaders: 3xx open-redirect guard on setHeader('location') / returned headers", () => {
131
+ const st = (status: number, location: string) => ({
132
+ status,
133
+ headers: { location } as Record<string, string>,
134
+ cookies: [] as string[],
135
+ });
136
+
137
+ // An off-site absolute Location on a 3xx (set via setHeader or a route
138
+ // handler's returned headers) is refused — the same rule redirect() applies.
139
+ expect(() => finalizeHeaders(st(302, "https://evil.example/steal"))).toThrow(
140
+ /open redirect/i,
141
+ );
142
+ // Protocol-relative `//host` is the classic bypass — also refused.
143
+ expect(() => finalizeHeaders(st(307, "//evil.example"))).toThrow(/open redirect/i);
144
+ // A backslash variant that browsers normalize cross-origin — refused.
145
+ expect(() => finalizeHeaders(st(303, "/\\evil.example"))).toThrow(/open redirect/i);
146
+
147
+ // A same-site relative path is fine and passes through unchanged.
148
+ expect(finalizeHeaders(st(302, "/dashboard"))["location"]).toBe("/dashboard");
149
+
150
+ // Case-insensitive header name is still caught (host lowercases, but guard
151
+ // must not depend on that).
152
+ expect(() =>
153
+ finalizeHeaders({
154
+ status: 302,
155
+ headers: { Location: "https://evil.example" } as Record<string, string>,
156
+ cookies: [],
157
+ }),
158
+ ).toThrow(/open redirect/i);
159
+
160
+ // NON-3xx status → `location` is just a header, not a redirect: no guard.
161
+ expect(
162
+ finalizeHeaders(st(200, "https://evil.example"))["location"],
163
+ ).toBe("https://evil.example");
164
+
165
+ // A raw `route.ts` GET returns its OWN status via the `effectiveStatus`
166
+ // arg — an off-site Location there is still refused even though
167
+ // `state.status` is 200.
168
+ expect(() =>
169
+ finalizeHeaders(
170
+ { status: 200, headers: {}, cookies: [] },
171
+ { location: "https://evil.example" },
172
+ undefined,
173
+ 302,
174
+ ),
175
+ ).toThrow(/open redirect/i);
176
+ });
177
+
130
178
  test("makeReadTrackingProxy trips on get / in / Object.keys / descriptor / spread", () => {
131
179
  const probes: Array<(o: any) => unknown> = [
132
180
  (o) => o.host,
@@ -385,6 +385,10 @@ export function finalizeHeaders(
385
385
  // TRUSTED runtime headers (e.g. the #277 `x-pylon-cacheable` proof). The ONLY
386
386
  // legitimate source of `x-pylon-*`; merged last and never stripped.
387
387
  internal?: Record<string, string>,
388
+ // The status this header map ships with, when it isn't `state.status` (a raw
389
+ // `route.ts` GET handler returns its own status). Used for the open-redirect
390
+ // guard below. Defaults to `state.status`.
391
+ effectiveStatus?: number,
388
392
  ): Record<string, string> {
389
393
  // The host TRUSTS `x-pylon-*` headers (cache verdict, etc.). They must come
390
394
  // ONLY from `internal` — strip them from BOTH page-set headers AND `extra` so
@@ -402,6 +406,34 @@ export function finalizeHeaders(
402
406
  mergeStripped(state.headers);
403
407
  mergeStripped(extra);
404
408
  if (internal) Object.assign(h, internal); // trusted, never stripped
409
+ // SECURITY: open-redirect guard. A 3xx response's `Location` must be same-site
410
+ // or a trusted host — the SAME rule `response.redirect()` enforces — no matter
411
+ // how it was set: `response.setHeader("location", …)`, a `route.ts` handler's
412
+ // returned `headers`, or a `location` in `extra`. Those paths skip
413
+ // `redirect()`'s check, so without this a request-derived value becomes an
414
+ // off-site redirect. Fail loud (throws → 500/error boundary) rather than
415
+ // silently emitting the redirect. The `redirect()`/route-control paths pass an
416
+ // already-validated URL, so re-checking them here is a safe no-op.
417
+ const status = effectiveStatus ?? state.status;
418
+ if (status >= 300 && status < 400) {
419
+ const locKey = Object.keys(h).find((k) => k.toLowerCase() === "location");
420
+ if (locKey != null) {
421
+ const env = (globalThis as any).process?.env ?? {};
422
+ if (
423
+ !isSafeRedirect(h[locKey], {
424
+ publicUrl: env.PYLON_PUBLIC_URL,
425
+ canonicalHost: env.PYLON_CANONICAL_HOST,
426
+ trustedHostsCsv: env.PYLON_TRUSTED_HOSTS,
427
+ })
428
+ ) {
429
+ throw new Error(
430
+ `pylon ssr: refused an untrusted redirect target ${JSON.stringify(h[locKey])} ` +
431
+ `in a ${status} response — use a same-site path (e.g. "/dashboard") or add the ` +
432
+ `host to PYLON_TRUSTED_HOSTS. (Refusing to emit an open redirect.)`,
433
+ );
434
+ }
435
+ }
436
+ }
405
437
  if (!h["content-type"]) h["content-type"] = "text/html; charset=utf-8";
406
438
  if (state.cookies.length > 0) {
407
439
  // Preserve a set-cookie value set via setHeader() (rare) and join it
@@ -2397,8 +2429,12 @@ export async function handleRenderRoute(
2397
2429
  // cached so `use()` doesn't re-suspend forever; resolved values land in
2398
2430
  // `ssrValueCache` for hydration replay.
2399
2431
  const { buildDbReader } = await import("./runtime");
2432
+ // `ssrRead: true` — serverData results are serialized into the
2433
+ // client-visible `__PYLON_DATA__` blob, so the host applies the same
2434
+ // per-row policy filter + `server_only`/`passwordHash` projection the
2435
+ // entity/sync read API does. `serverData.unsafe.*` stays server-trust.
2400
2436
  const serverData = makeServerData(
2401
- buildDbReader(msg.call_id),
2437
+ buildDbReader(msg.call_id, true),
2402
2438
  ssrValueCache,
2403
2439
  );
2404
2440