@pylonsync/functions 0.3.310 → 0.3.311

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.
@@ -101,6 +101,7 @@ export interface SsrCookieOptions {
101
101
  expires?: Date | string;
102
102
  /** Defaults to true (secure default). Pass false for a client-readable cookie. */
103
103
  httpOnly?: boolean;
104
+ /** Defaults to true in prod / false in dev. Forced true when sameSite="none". */
104
105
  secure?: boolean;
105
106
  /** Defaults to "lax". */
106
107
  sameSite?: "strict" | "lax" | "none";
@@ -341,6 +342,14 @@ export declare function applyAutoSocialImages(component: string, headers: Record
341
342
  * marker + probe are in place before the deferred client entry boots the sync
342
343
  * engine. `<` is escaped so the JSON can't break out of the script.
343
344
  */
345
+ /**
346
+ * Escape a JSON string so it can't break out of a `<script>` element: `<`
347
+ * closes a `</script>` (or `<!--`, `<script`); U+2028/U+2029 are JS statement
348
+ * terminators (valid in JSON, not in a JS string literal). Applied to every
349
+ * server-serialized blob embedded in a script tag \u2014 the `application/json`
350
+ * data blobs AND the executable fallback warning.
351
+ */
352
+ export declare function escapeScriptJson(json: string): string;
344
353
  export declare function buildDevHudChunk(devInfo: Record<string, unknown>): string;
345
354
  /**
346
355
  * Build the hydration tail appended after React's stream EOFs: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.310",
3
+ "version": "0.3.311",
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",
@@ -19,6 +19,7 @@ import {
19
19
  PylonRouteControl,
20
20
  finalizeHeaders,
21
21
  importModule,
22
+ isDevMode,
22
23
  type ResponseState,
23
24
  } from "./ssr-runtime";
24
25
  import { buildDbWriter } from "./runtime";
@@ -81,8 +82,9 @@ function makeFormFields(raw: Record<string, string | string[]>): FormFields {
81
82
  // the list drives the 405 `Allow` header advertising which a route.ts exports.
82
83
  const HANDLER_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
83
84
  const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64");
84
- const isDev = () =>
85
- process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
85
+ // Share ONE dev-mode parse with ssr-runtime (case-insensitive "1"/"true") so
86
+ // the form path and the render path never disagree on whether to leak stacks.
87
+ const isDev = isDevMode;
86
88
 
87
89
  export async function handleForm(
88
90
  msg: HandleFormMessage,
@@ -15,6 +15,7 @@ import {
15
15
  asRouteControl,
16
16
  PylonRouteControl,
17
17
  finalizeHeaders,
18
+ escapeScriptJson,
18
19
  makeResponseController,
19
20
  makeReadTrackingProxy,
20
21
  makeRevocableReadTrackingProxy,
@@ -750,3 +751,68 @@ describe("isSafeRedirect — open-redirect guard for response.redirect()", () =>
750
751
  expect(isSafeRedirect("https://app.example.com/x", {})).toBe(false);
751
752
  });
752
753
  });
754
+
755
+ describe("script-escaping + secure cookies", () => {
756
+ test("escapeScriptJson neutralizes a </script> breakout", () => {
757
+ const out = escapeScriptJson(JSON.stringify("</script><script>alert(1)</script>"));
758
+ expect(out).not.toContain("<");
759
+ expect(out).toContain("\\u003c");
760
+ });
761
+
762
+ test("buildHydrationTail fallback warn script escapes manifestErr", () => {
763
+ const tail = buildHydrationTail({
764
+ component: "app/x/page",
765
+ layouts: [],
766
+ props: {},
767
+ ssrData: {},
768
+ manifestRoute: null,
769
+ publicPrefix: "/_pylon/build/",
770
+ manifestErr: 'no entry for "</script><script>evil</script>"',
771
+ });
772
+ const warn = tail.slice(tail.indexOf("console.warn"));
773
+ // The executable fallback must escape the error — no raw injected tag.
774
+ expect(warn).not.toContain("<script>evil");
775
+ expect(warn).toContain("\\u003c");
776
+ });
777
+
778
+ test("serializeCookie: Secure defaults + SameSite=None forces Secure", () => {
779
+ const mk = () => {
780
+ const s = { status: 200, headers: {}, cookies: [] as string[] };
781
+ return { s, r: makeResponseController(s) };
782
+ };
783
+ const prev = process.env.PYLON_DEV_MODE;
784
+ try {
785
+ // Prod (dev off): Secure defaults ON.
786
+ delete process.env.PYLON_DEV_MODE;
787
+ {
788
+ const { s, r } = mk();
789
+ r.setCookie("a", "1");
790
+ expect(s.cookies[0]).toContain("; Secure");
791
+ expect(s.cookies[0]).toContain("; HttpOnly");
792
+ }
793
+ // Explicit secure:false (non-None) wins even in prod.
794
+ {
795
+ const { s, r } = mk();
796
+ r.setCookie("a", "1", { secure: false });
797
+ expect(s.cookies[0]).not.toContain("; Secure");
798
+ }
799
+ // Dev: Secure defaults OFF so http://localhost keeps the cookie.
800
+ process.env.PYLON_DEV_MODE = "1";
801
+ {
802
+ const { s, r } = mk();
803
+ r.setCookie("a", "1");
804
+ expect(s.cookies[0]).not.toContain("; Secure");
805
+ }
806
+ // SameSite=None forces Secure even in dev (browsers drop it otherwise).
807
+ {
808
+ const { s, r } = mk();
809
+ r.setCookie("a", "1", { sameSite: "none" });
810
+ expect(s.cookies[0]).toContain("; Secure");
811
+ expect(s.cookies[0]).toContain("SameSite=None");
812
+ }
813
+ } finally {
814
+ if (prev === undefined) delete process.env.PYLON_DEV_MODE;
815
+ else process.env.PYLON_DEV_MODE = prev;
816
+ }
817
+ });
818
+ });
@@ -146,6 +146,7 @@ export interface SsrCookieOptions {
146
146
  expires?: Date | string;
147
147
  /** Defaults to true (secure default). Pass false for a client-readable cookie. */
148
148
  httpOnly?: boolean;
149
+ /** Defaults to true in prod / false in dev. Forced true when sameSite="none". */
149
150
  secure?: boolean;
150
151
  /** Defaults to "lax". */
151
152
  sameSite?: "strict" | "lax" | "none";
@@ -194,8 +195,13 @@ function serializeCookie(
194
195
  c += `; Domain=${opts.domain}`;
195
196
  }
196
197
  if (opts.httpOnly !== false) c += `; HttpOnly`;
197
- if (opts.secure) c += `; Secure`;
198
198
  const ss = opts.sameSite ?? "lax";
199
+ // `Secure` defaults ON in prod (mirrors the framework session cookie), OFF in
200
+ // dev so an http://localhost cookie isn't dropped. `SameSite=None` REQUIRES
201
+ // `Secure` — browsers silently drop a None cookie without it — so force it
202
+ // there regardless of the caller / dev mode.
203
+ const secure = ss === "none" ? true : (opts.secure ?? !isDevMode());
204
+ if (secure) c += `; Secure`;
199
205
  c += `; SameSite=${ss[0].toUpperCase()}${ss.slice(1)}`;
200
206
  return c;
201
207
  }
@@ -1517,11 +1523,22 @@ function pylonDevHud() {
1517
1523
  * marker + probe are in place before the deferred client entry boots the sync
1518
1524
  * engine. `<` is escaped so the JSON can't break out of the script.
1519
1525
  */
1520
- export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
1521
- const json = JSON.stringify(devInfo)
1526
+ /**
1527
+ * Escape a JSON string so it can't break out of a `<script>` element: `<`
1528
+ * closes a `</script>` (or `<!--`, `<script`); U+2028/U+2029 are JS statement
1529
+ * terminators (valid in JSON, not in a JS string literal). Applied to every
1530
+ * server-serialized blob embedded in a script tag \u2014 the `application/json`
1531
+ * data blobs AND the executable fallback warning.
1532
+ */
1533
+ export function escapeScriptJson(json: string): string {
1534
+ return json
1522
1535
  .replace(/</g, "\\u003c")
1523
1536
  .replace(/\u2028/g, "\\u2028")
1524
1537
  .replace(/\u2029/g, "\\u2029");
1538
+ }
1539
+
1540
+ export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
1541
+ const json = escapeScriptJson(JSON.stringify(devInfo));
1525
1542
  return (
1526
1543
  `<script id="__PYLON_DEV__" type="application/json">${json}</script>` +
1527
1544
  `<script>(${pylonDevHud.toString()})();</script>`
@@ -1661,13 +1678,7 @@ export function buildHydrationTail(args: {
1661
1678
  ssrData: args.ssrData,
1662
1679
  };
1663
1680
  if (args.kind) hydrationPayload.kind = args.kind;
1664
- // Escape `<` (closes a </script> breakout) + U+2028/U+2029 (JSON-valid but
1665
- // JS statement terminators). Regex form keeps the separators visible in
1666
- // source rather than as invisible literals.
1667
- const json = JSON.stringify(hydrationPayload)
1668
- .replace(/</g, "\\u003c")
1669
- .replace(/\u2028/g, "\\u2028")
1670
- .replace(/\u2029/g, "\\u2029");
1681
+ const json = escapeScriptJson(JSON.stringify(hydrationPayload));
1671
1682
  let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
1672
1683
  if (args.manifestRoute) {
1673
1684
  // A cross-origin CDN module (`public_prefix` is an absolute http(s) URL) is
@@ -1677,7 +1688,10 @@ export function buildHydrationTail(args: {
1677
1688
  const co = /^https?:\/\//i.test(args.publicPrefix) ? " crossorigin" : "";
1678
1689
  tail += `<script type="module"${co} src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
1679
1690
  } else {
1680
- tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`)})</script>`;
1691
+ // Executable script → the serialized message MUST be script-escaped
1692
+ // (`manifestErr` is server-derived today, but this is the one script sink
1693
+ // that would otherwise skip the `<`-escape every other blob applies).
1694
+ tail += `<script>console.warn(${escapeScriptJson(JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`))})</script>`;
1681
1695
  }
1682
1696
  if (isDevMode()) tail += DEV_LIVE_RELOAD_SNIPPET;
1683
1697
  return tail;