@pylonsync/functions 0.3.309 → 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.
- package/dist/runtime.d.ts +1 -1
- package/dist/ssr-runtime.d.ts +10 -1
- package/package.json +1 -1
- package/src/runtime.ts +31 -7
- package/src/ssr-form-runtime.ts +10 -4
- package/src/ssr-runtime.test.ts +114 -0
- package/src/ssr-runtime.ts +62 -12
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;
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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";
|
|
@@ -181,7 +182,7 @@ export declare function makeRevocableReadTrackingProxy(obj: Record<string, unkno
|
|
|
181
182
|
proxy: Record<string, unknown>;
|
|
182
183
|
revoke: () => void;
|
|
183
184
|
};
|
|
184
|
-
export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string
|
|
185
|
+
export declare function finalizeHeaders(state: ResponseState, extra?: Record<string, string>, internal?: Record<string, string>, effectiveStatus?: number): Record<string, string>;
|
|
185
186
|
/**
|
|
186
187
|
* Phase 1 SSR handler. Resolves the component, renders it via
|
|
187
188
|
* react-dom/server.renderToReadableStream, pumps chunks back to the
|
|
@@ -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
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 {
|
|
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(
|
|
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
|
|
1061
|
-
// (
|
|
1062
|
-
//
|
|
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
|
|
1088
|
+
if (process.ppid !== initialPpid) process.exit(0);
|
|
1065
1089
|
}, 2000);
|
|
1066
1090
|
if (typeof orphanWatch.unref === "function") orphanWatch.unref();
|
|
1067
1091
|
|
package/src/ssr-form-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
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,
|
|
@@ -172,11 +174,15 @@ export async function handleForm(
|
|
|
172
174
|
for (const [k, v] of Object.entries(raw.headers ?? {})) {
|
|
173
175
|
extra[k.toLowerCase()] = String(v);
|
|
174
176
|
}
|
|
177
|
+
const rawStatus = raw.status ?? responseState.status;
|
|
175
178
|
send({
|
|
176
179
|
type: "response_start",
|
|
177
180
|
call_id: msg.call_id,
|
|
178
|
-
status:
|
|
179
|
-
|
|
181
|
+
status: rawStatus,
|
|
182
|
+
// Pass rawStatus so the open-redirect guard sees the handler's own
|
|
183
|
+
// status (a raw GET's redirect status lives on `raw.status`, not
|
|
184
|
+
// `responseState.status`).
|
|
185
|
+
headers: finalizeHeaders(responseState, extra, undefined, rawStatus),
|
|
180
186
|
});
|
|
181
187
|
if (raw.body != null) {
|
|
182
188
|
const bodyStr =
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -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,
|
|
@@ -127,6 +128,54 @@ describe("reserved x-pylon-* header namespace (cache-proof forgery fence)", () =
|
|
|
127
128
|
expect(out2["x-pylon-cacheable"]).toBeUndefined();
|
|
128
129
|
});
|
|
129
130
|
|
|
131
|
+
test("finalizeHeaders: 3xx open-redirect guard on setHeader('location') / returned headers", () => {
|
|
132
|
+
const st = (status: number, location: string) => ({
|
|
133
|
+
status,
|
|
134
|
+
headers: { location } as Record<string, string>,
|
|
135
|
+
cookies: [] as string[],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// An off-site absolute Location on a 3xx (set via setHeader or a route
|
|
139
|
+
// handler's returned headers) is refused — the same rule redirect() applies.
|
|
140
|
+
expect(() => finalizeHeaders(st(302, "https://evil.example/steal"))).toThrow(
|
|
141
|
+
/open redirect/i,
|
|
142
|
+
);
|
|
143
|
+
// Protocol-relative `//host` is the classic bypass — also refused.
|
|
144
|
+
expect(() => finalizeHeaders(st(307, "//evil.example"))).toThrow(/open redirect/i);
|
|
145
|
+
// A backslash variant that browsers normalize cross-origin — refused.
|
|
146
|
+
expect(() => finalizeHeaders(st(303, "/\\evil.example"))).toThrow(/open redirect/i);
|
|
147
|
+
|
|
148
|
+
// A same-site relative path is fine and passes through unchanged.
|
|
149
|
+
expect(finalizeHeaders(st(302, "/dashboard"))["location"]).toBe("/dashboard");
|
|
150
|
+
|
|
151
|
+
// Case-insensitive header name is still caught (host lowercases, but guard
|
|
152
|
+
// must not depend on that).
|
|
153
|
+
expect(() =>
|
|
154
|
+
finalizeHeaders({
|
|
155
|
+
status: 302,
|
|
156
|
+
headers: { Location: "https://evil.example" } as Record<string, string>,
|
|
157
|
+
cookies: [],
|
|
158
|
+
}),
|
|
159
|
+
).toThrow(/open redirect/i);
|
|
160
|
+
|
|
161
|
+
// NON-3xx status → `location` is just a header, not a redirect: no guard.
|
|
162
|
+
expect(
|
|
163
|
+
finalizeHeaders(st(200, "https://evil.example"))["location"],
|
|
164
|
+
).toBe("https://evil.example");
|
|
165
|
+
|
|
166
|
+
// A raw `route.ts` GET returns its OWN status via the `effectiveStatus`
|
|
167
|
+
// arg — an off-site Location there is still refused even though
|
|
168
|
+
// `state.status` is 200.
|
|
169
|
+
expect(() =>
|
|
170
|
+
finalizeHeaders(
|
|
171
|
+
{ status: 200, headers: {}, cookies: [] },
|
|
172
|
+
{ location: "https://evil.example" },
|
|
173
|
+
undefined,
|
|
174
|
+
302,
|
|
175
|
+
),
|
|
176
|
+
).toThrow(/open redirect/i);
|
|
177
|
+
});
|
|
178
|
+
|
|
130
179
|
test("makeReadTrackingProxy trips on get / in / Object.keys / descriptor / spread", () => {
|
|
131
180
|
const probes: Array<(o: any) => unknown> = [
|
|
132
181
|
(o) => o.host,
|
|
@@ -702,3 +751,68 @@ describe("isSafeRedirect — open-redirect guard for response.redirect()", () =>
|
|
|
702
751
|
expect(isSafeRedirect("https://app.example.com/x", {})).toBe(false);
|
|
703
752
|
});
|
|
704
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
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -385,6 +391,10 @@ export function finalizeHeaders(
|
|
|
385
391
|
// TRUSTED runtime headers (e.g. the #277 `x-pylon-cacheable` proof). The ONLY
|
|
386
392
|
// legitimate source of `x-pylon-*`; merged last and never stripped.
|
|
387
393
|
internal?: Record<string, string>,
|
|
394
|
+
// The status this header map ships with, when it isn't `state.status` (a raw
|
|
395
|
+
// `route.ts` GET handler returns its own status). Used for the open-redirect
|
|
396
|
+
// guard below. Defaults to `state.status`.
|
|
397
|
+
effectiveStatus?: number,
|
|
388
398
|
): Record<string, string> {
|
|
389
399
|
// The host TRUSTS `x-pylon-*` headers (cache verdict, etc.). They must come
|
|
390
400
|
// ONLY from `internal` — strip them from BOTH page-set headers AND `extra` so
|
|
@@ -402,6 +412,34 @@ export function finalizeHeaders(
|
|
|
402
412
|
mergeStripped(state.headers);
|
|
403
413
|
mergeStripped(extra);
|
|
404
414
|
if (internal) Object.assign(h, internal); // trusted, never stripped
|
|
415
|
+
// SECURITY: open-redirect guard. A 3xx response's `Location` must be same-site
|
|
416
|
+
// or a trusted host — the SAME rule `response.redirect()` enforces — no matter
|
|
417
|
+
// how it was set: `response.setHeader("location", …)`, a `route.ts` handler's
|
|
418
|
+
// returned `headers`, or a `location` in `extra`. Those paths skip
|
|
419
|
+
// `redirect()`'s check, so without this a request-derived value becomes an
|
|
420
|
+
// off-site redirect. Fail loud (throws → 500/error boundary) rather than
|
|
421
|
+
// silently emitting the redirect. The `redirect()`/route-control paths pass an
|
|
422
|
+
// already-validated URL, so re-checking them here is a safe no-op.
|
|
423
|
+
const status = effectiveStatus ?? state.status;
|
|
424
|
+
if (status >= 300 && status < 400) {
|
|
425
|
+
const locKey = Object.keys(h).find((k) => k.toLowerCase() === "location");
|
|
426
|
+
if (locKey != null) {
|
|
427
|
+
const env = (globalThis as any).process?.env ?? {};
|
|
428
|
+
if (
|
|
429
|
+
!isSafeRedirect(h[locKey], {
|
|
430
|
+
publicUrl: env.PYLON_PUBLIC_URL,
|
|
431
|
+
canonicalHost: env.PYLON_CANONICAL_HOST,
|
|
432
|
+
trustedHostsCsv: env.PYLON_TRUSTED_HOSTS,
|
|
433
|
+
})
|
|
434
|
+
) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`pylon ssr: refused an untrusted redirect target ${JSON.stringify(h[locKey])} ` +
|
|
437
|
+
`in a ${status} response — use a same-site path (e.g. "/dashboard") or add the ` +
|
|
438
|
+
`host to PYLON_TRUSTED_HOSTS. (Refusing to emit an open redirect.)`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
405
443
|
if (!h["content-type"]) h["content-type"] = "text/html; charset=utf-8";
|
|
406
444
|
if (state.cookies.length > 0) {
|
|
407
445
|
// Preserve a set-cookie value set via setHeader() (rare) and join it
|
|
@@ -1485,11 +1523,22 @@ function pylonDevHud() {
|
|
|
1485
1523
|
* marker + probe are in place before the deferred client entry boots the sync
|
|
1486
1524
|
* engine. `<` is escaped so the JSON can't break out of the script.
|
|
1487
1525
|
*/
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
|
1490
1535
|
.replace(/</g, "\\u003c")
|
|
1491
1536
|
.replace(/\u2028/g, "\\u2028")
|
|
1492
1537
|
.replace(/\u2029/g, "\\u2029");
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
|
|
1541
|
+
const json = escapeScriptJson(JSON.stringify(devInfo));
|
|
1493
1542
|
return (
|
|
1494
1543
|
`<script id="__PYLON_DEV__" type="application/json">${json}</script>` +
|
|
1495
1544
|
`<script>(${pylonDevHud.toString()})();</script>`
|
|
@@ -1629,13 +1678,7 @@ export function buildHydrationTail(args: {
|
|
|
1629
1678
|
ssrData: args.ssrData,
|
|
1630
1679
|
};
|
|
1631
1680
|
if (args.kind) hydrationPayload.kind = args.kind;
|
|
1632
|
-
|
|
1633
|
-
// JS statement terminators). Regex form keeps the separators visible in
|
|
1634
|
-
// source rather than as invisible literals.
|
|
1635
|
-
const json = JSON.stringify(hydrationPayload)
|
|
1636
|
-
.replace(/</g, "\\u003c")
|
|
1637
|
-
.replace(/\u2028/g, "\\u2028")
|
|
1638
|
-
.replace(/\u2029/g, "\\u2029");
|
|
1681
|
+
const json = escapeScriptJson(JSON.stringify(hydrationPayload));
|
|
1639
1682
|
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
1640
1683
|
if (args.manifestRoute) {
|
|
1641
1684
|
// A cross-origin CDN module (`public_prefix` is an absolute http(s) URL) is
|
|
@@ -1645,7 +1688,10 @@ export function buildHydrationTail(args: {
|
|
|
1645
1688
|
const co = /^https?:\/\//i.test(args.publicPrefix) ? " crossorigin" : "";
|
|
1646
1689
|
tail += `<script type="module"${co} src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
|
|
1647
1690
|
} else {
|
|
1648
|
-
|
|
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>`;
|
|
1649
1695
|
}
|
|
1650
1696
|
if (isDevMode()) tail += DEV_LIVE_RELOAD_SNIPPET;
|
|
1651
1697
|
return tail;
|
|
@@ -2397,8 +2443,12 @@ export async function handleRenderRoute(
|
|
|
2397
2443
|
// cached so `use()` doesn't re-suspend forever; resolved values land in
|
|
2398
2444
|
// `ssrValueCache` for hydration replay.
|
|
2399
2445
|
const { buildDbReader } = await import("./runtime");
|
|
2446
|
+
// `ssrRead: true` — serverData results are serialized into the
|
|
2447
|
+
// client-visible `__PYLON_DATA__` blob, so the host applies the same
|
|
2448
|
+
// per-row policy filter + `server_only`/`passwordHash` projection the
|
|
2449
|
+
// entity/sync read API does. `serverData.unsafe.*` stays server-trust.
|
|
2400
2450
|
const serverData = makeServerData(
|
|
2401
|
-
buildDbReader(msg.call_id),
|
|
2451
|
+
buildDbReader(msg.call_id, true),
|
|
2402
2452
|
ssrValueCache,
|
|
2403
2453
|
);
|
|
2404
2454
|
|