@pylonsync/functions 0.3.274 → 0.3.276
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/package.json +1 -1
- package/src/runtime.ts +24 -2
- package/src/ssr-client-bundler.ts +10 -4
- package/src/ssr-runtime.test.ts +58 -0
- package/src/ssr-runtime.ts +63 -0
- package/src/types.ts +8 -0
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -40,9 +40,13 @@ import { join, basename } from "path";
|
|
|
40
40
|
// consuming apps type-check this source under node/DOM where the `Bun` global
|
|
41
41
|
// is absent — so declare the surface we touch (mirrors the ambient in
|
|
42
42
|
// ssr-client-bundler.ts). Keeps `tsc` clean in a scaffolded app.
|
|
43
|
+
interface BunFileSink {
|
|
44
|
+
write(chunk: string): number;
|
|
45
|
+
flush(): number | Promise<number>;
|
|
46
|
+
}
|
|
43
47
|
declare const Bun: {
|
|
44
48
|
write(destination: unknown, input: string): Promise<number>;
|
|
45
|
-
stdout:
|
|
49
|
+
stdout: { writer(): BunFileSink };
|
|
46
50
|
stderr: unknown;
|
|
47
51
|
stdin: { stream(): ReadableStream<Uint8Array> };
|
|
48
52
|
};
|
|
@@ -71,9 +75,27 @@ interface ResultMessage {
|
|
|
71
75
|
// Send
|
|
72
76
|
// ---------------------------------------------------------------------------
|
|
73
77
|
|
|
78
|
+
// Protocol frames go through ONE FileSink over stdout. The old
|
|
79
|
+
// `Bun.write(Bun.stdout, line)` returned an UNAWAITED promise, so once a
|
|
80
|
+
// runner multiplexes concurrent calls (since the v0.3.259 runner change) two
|
|
81
|
+
// handlers emitting frames larger than PIPE_BUF (~4KB) could race their
|
|
82
|
+
// write() syscalls and interleave on stdout — the host's NDJSON reader then
|
|
83
|
+
// failed to parse the corrupted line and dropped the frame, hanging the caller
|
|
84
|
+
// to its timeout. A single sink appends every frame to one ordered buffer, so
|
|
85
|
+
// writes can't interleave; `send` is the only stdout writer.
|
|
86
|
+
let stdoutSink: BunFileSink | undefined;
|
|
74
87
|
function send(msg: Record<string, unknown>): void {
|
|
75
88
|
const line = JSON.stringify(msg) + "\n";
|
|
76
|
-
|
|
89
|
+
if (!stdoutSink) stdoutSink = Bun.stdout.writer();
|
|
90
|
+
stdoutSink.write(line);
|
|
91
|
+
// flush() can return a Promise under pipe backpressure. The single sink
|
|
92
|
+
// already preserves frame order, so we don't await — just keep a flush
|
|
93
|
+
// error (e.g. EPIPE after the host exits) from becoming an unhandled
|
|
94
|
+
// rejection.
|
|
95
|
+
const flushed = stdoutSink.flush();
|
|
96
|
+
if (flushed && typeof (flushed as Promise<number>).then === "function") {
|
|
97
|
+
(flushed as Promise<number>).then(undefined, () => {});
|
|
98
|
+
}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
/**
|
|
@@ -910,12 +910,18 @@ async function _doBuildInner(
|
|
|
910
910
|
// rather than an inline string in CLIENT_RUNTIME_SOURCE that nothing
|
|
911
911
|
// could render. Bun pulls it into the shared chunk via the runtime's
|
|
912
912
|
// static `import { createPylonBoundary } from "./client-boundary"`.
|
|
913
|
+
//
|
|
914
|
+
// Resolve THIS module's directory from the standard `import.meta.url`
|
|
915
|
+
// (via node:url) rather than Bun's `import.meta.dir` — the latter is a
|
|
916
|
+
// Bun-only extension that `tsc` doesn't know about, so any app that
|
|
917
|
+
// imports `@pylonsync/functions` and runs `tsc` would otherwise fail
|
|
918
|
+
// type-checking on this file. `import.meta.url` works in Bun and Node.
|
|
919
|
+
const urlMod: any = await import("node:url");
|
|
920
|
+
const fileURLToPath = urlMod.fileURLToPath ?? urlMod.default?.fileURLToPath;
|
|
921
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
913
922
|
fs.writeFileSync(
|
|
914
923
|
path.join(stageDir, "client-boundary.ts"),
|
|
915
|
-
fs.readFileSync(
|
|
916
|
-
path.join(import.meta.dir, "ssr-client-boundary.ts"),
|
|
917
|
-
"utf8",
|
|
918
|
-
),
|
|
924
|
+
fs.readFileSync(path.join(here, "ssr-client-boundary.ts"), "utf8"),
|
|
919
925
|
"utf8",
|
|
920
926
|
);
|
|
921
927
|
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
buildHydrationTail,
|
|
12
12
|
errorDigest,
|
|
13
13
|
resolveOrigin,
|
|
14
|
+
isSafeRedirect,
|
|
14
15
|
asRouteControl,
|
|
15
16
|
PylonRouteControl,
|
|
16
17
|
} from "./ssr-runtime";
|
|
@@ -284,6 +285,24 @@ describe("renderMetadata head-tag marking (client-nav sync)", () => {
|
|
|
284
285
|
expect(titles[0].children).toEqual(["Hello"]);
|
|
285
286
|
});
|
|
286
287
|
|
|
288
|
+
test("emits og:site_name when openGraph.siteName is set", () => {
|
|
289
|
+
const frag = renderMetadata(fakeReact, {
|
|
290
|
+
openGraph: { title: "OG", siteName: "Pylon" },
|
|
291
|
+
});
|
|
292
|
+
const metas: any[] = frag.children.filter((k: any) => k.type === "meta");
|
|
293
|
+
const siteName = metas.find((m) => m.props.property === "og:site_name");
|
|
294
|
+
expect(siteName).toBeDefined();
|
|
295
|
+
expect(siteName.props.content).toBe("Pylon");
|
|
296
|
+
// It's a head tag like the rest, so it must carry the nav-swap marker.
|
|
297
|
+
expect(siteName.props["data-pylon-meta"]).toBe("");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("omits og:site_name when not provided", () => {
|
|
301
|
+
const frag = renderMetadata(fakeReact, { openGraph: { title: "OG" } });
|
|
302
|
+
const metas: any[] = frag.children.filter((k: any) => k.type === "meta");
|
|
303
|
+
expect(metas.some((m) => m.props.property === "og:site_name")).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
287
306
|
test("returns null when there's nothing to emit", () => {
|
|
288
307
|
expect(renderMetadata(fakeReact, undefined)).toBeNull();
|
|
289
308
|
expect(renderMetadata(fakeReact, {})).toBeNull();
|
|
@@ -431,3 +450,42 @@ describe("asRouteControl — route-control normalization (redirect/notFound)", (
|
|
|
431
450
|
expect(asRouteControl("PYLON_NOT_FOUND")).toBeNull(); // a bare string, not an error
|
|
432
451
|
});
|
|
433
452
|
});
|
|
453
|
+
|
|
454
|
+
describe("isSafeRedirect — open-redirect guard for response.redirect()", () => {
|
|
455
|
+
const trusted = {
|
|
456
|
+
publicUrl: "https://app.example.com",
|
|
457
|
+
trustedHostsCsv: "checkout.stripe.com, other.example.com",
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
test("allows same-site relative paths", () => {
|
|
461
|
+
expect(isSafeRedirect("/", trusted)).toBe(true);
|
|
462
|
+
expect(isSafeRedirect("/dashboard", trusted)).toBe(true);
|
|
463
|
+
expect(isSafeRedirect("/a/b?x=1#h", trusted)).toBe(true);
|
|
464
|
+
// %2F in a path stays a path segment (browsers don't change origin on it).
|
|
465
|
+
expect(isSafeRedirect("/%2F%2Fevil.com", trusted)).toBe(true);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("rejects the classic open-redirect vectors", () => {
|
|
469
|
+
expect(isSafeRedirect("//evil.com", trusted)).toBe(false); // protocol-relative
|
|
470
|
+
expect(isSafeRedirect("/\\evil.com", trusted)).toBe(false); // backslash trick
|
|
471
|
+
expect(isSafeRedirect("\\/evil.com", trusted)).toBe(false);
|
|
472
|
+
expect(isSafeRedirect("https://evil.com", trusted)).toBe(false); // other origin
|
|
473
|
+
expect(isSafeRedirect("https://evil.com/path", trusted)).toBe(false);
|
|
474
|
+
expect(isSafeRedirect("javascript:alert(1)", trusted)).toBe(false);
|
|
475
|
+
expect(isSafeRedirect("data:text/html,x", trusted)).toBe(false);
|
|
476
|
+
expect(isSafeRedirect("dashboard", trusted)).toBe(false); // bare-relative → reject
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("allows absolute URLs to a trusted host (public origin / PYLON_TRUSTED_HOSTS / loopback)", () => {
|
|
480
|
+
expect(isSafeRedirect("https://app.example.com/next", trusted)).toBe(true);
|
|
481
|
+
expect(isSafeRedirect("https://checkout.stripe.com/pay/abc", trusted)).toBe(true);
|
|
482
|
+
expect(isSafeRedirect("http://localhost:3000/x", trusted)).toBe(true);
|
|
483
|
+
expect(isSafeRedirect("http://127.0.0.1/x", trusted)).toBe(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("with no trusted config, only relative paths + loopback are allowed", () => {
|
|
487
|
+
expect(isSafeRedirect("/ok", {})).toBe(true);
|
|
488
|
+
expect(isSafeRedirect("http://localhost/x", {})).toBe(true);
|
|
489
|
+
expect(isSafeRedirect("https://app.example.com/x", {})).toBe(false);
|
|
490
|
+
});
|
|
491
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -264,6 +264,24 @@ export function makeResponseController(
|
|
|
264
264
|
if (!Number.isInteger(status) || status < 300 || status > 399) {
|
|
265
265
|
throw new Error(`pylon ssr: redirect() status must be 3xx, got ${status}`);
|
|
266
266
|
}
|
|
267
|
+
// SECURITY: refuse open redirects. A relative path or a trusted-host
|
|
268
|
+
// absolute is fine; anything else (//evil.com, https://evil.com,
|
|
269
|
+
// javascript:, …) throws — surfaced as a render error, never a silent
|
|
270
|
+
// off-site redirect from `redirect(request-derived-url)`.
|
|
271
|
+
const env = (globalThis as any).process?.env ?? {};
|
|
272
|
+
if (
|
|
273
|
+
!isSafeRedirect(url, {
|
|
274
|
+
publicUrl: env.PYLON_PUBLIC_URL,
|
|
275
|
+
canonicalHost: env.PYLON_CANONICAL_HOST,
|
|
276
|
+
trustedHostsCsv: env.PYLON_TRUSTED_HOSTS,
|
|
277
|
+
})
|
|
278
|
+
) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`pylon ssr: redirect() refused an untrusted target ${JSON.stringify(url)} — ` +
|
|
281
|
+
`use a same-site path (e.g. "/dashboard") or add the host to ` +
|
|
282
|
+
`PYLON_TRUSTED_HOSTS. (Refusing to emit an open redirect.)`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
267
285
|
const e = new PylonRouteControl("redirect");
|
|
268
286
|
e.url = url;
|
|
269
287
|
e.redirectStatus = status;
|
|
@@ -339,6 +357,9 @@ export interface SsrMetadata {
|
|
|
339
357
|
imageAlt?: string;
|
|
340
358
|
url?: string;
|
|
341
359
|
type?: string;
|
|
360
|
+
/** `og:site_name` — the brand the page belongs to (e.g. "Pylon").
|
|
361
|
+
* Discord and other unfurlers show this above the title. */
|
|
362
|
+
siteName?: string;
|
|
342
363
|
};
|
|
343
364
|
twitter?: {
|
|
344
365
|
card?: string;
|
|
@@ -399,6 +420,7 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
|
399
420
|
}
|
|
400
421
|
if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
|
|
401
422
|
if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
|
|
423
|
+
if (og.siteName) kids.push(el("meta", { key: "ogsn", property: "og:site_name", content: og.siteName }));
|
|
402
424
|
}
|
|
403
425
|
const tw = m.twitter;
|
|
404
426
|
if (tw) {
|
|
@@ -700,6 +722,47 @@ function resolveRequestOrigin(headers: Record<string, string> | undefined): stri
|
|
|
700
722
|
});
|
|
701
723
|
}
|
|
702
724
|
|
|
725
|
+
/** SECURITY: validate a `response.redirect()` target to prevent OPEN REDIRECTS.
|
|
726
|
+
* Mirrors the OAuth-layer `validate_trusted_redirect` (crates/auth). A relative
|
|
727
|
+
* same-site path is always allowed; an absolute URL only when it's http(s) to a
|
|
728
|
+
* trusted host — the app's public/canonical origin, a `PYLON_TRUSTED_HOSTS`
|
|
729
|
+
* entry, or loopback. Everything else is rejected: protocol-relative
|
|
730
|
+
* `//evil.com`, backslash tricks (`/\evil.com` — browsers normalize `\`→`/`),
|
|
731
|
+
* other-origin absolutes, and `javascript:`/`data:` schemes. So the natural
|
|
732
|
+
* `response.redirect(searchParams.get("next"))` can't be turned into an
|
|
733
|
+
* off-site redirect by attacker-supplied input. Exported for tests. */
|
|
734
|
+
export function isSafeRedirect(
|
|
735
|
+
url: string,
|
|
736
|
+
opts: { publicUrl?: string; canonicalHost?: string; trustedHostsCsv?: string },
|
|
737
|
+
): boolean {
|
|
738
|
+
// Relative same-site path: exactly one leading slash. Reject protocol-
|
|
739
|
+
// relative (`//host`) and backslash variants that resolve cross-origin.
|
|
740
|
+
if (url.startsWith("/")) {
|
|
741
|
+
return url.length < 2 || (url[1] !== "/" && url[1] !== "\\");
|
|
742
|
+
}
|
|
743
|
+
if (url.startsWith("\\")) return false; // `\\host` / `\/host`
|
|
744
|
+
// Absolute: only http(s) to a trusted host. A bare-relative ("dashboard"),
|
|
745
|
+
// opaque, or unparseable target throws here → rejected (fail closed).
|
|
746
|
+
let parsed: URL;
|
|
747
|
+
try {
|
|
748
|
+
parsed = new URL(url);
|
|
749
|
+
} catch {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
753
|
+
const host = parsed.host.toLowerCase();
|
|
754
|
+
if (LOOPBACK_HOST.test(host)) return true;
|
|
755
|
+
const allow = new Set<string>();
|
|
756
|
+
const add = (v: string) => {
|
|
757
|
+
const h = hostOf(v);
|
|
758
|
+
if (h) allow.add(h);
|
|
759
|
+
};
|
|
760
|
+
add(opts.publicUrl || "");
|
|
761
|
+
add(opts.canonicalHost || "");
|
|
762
|
+
for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
|
|
763
|
+
return allow.has(host);
|
|
764
|
+
}
|
|
765
|
+
|
|
703
766
|
/** Merge auto-discovered social-card images into a page's metadata. An
|
|
704
767
|
* explicit `openGraph.image` / `twitter.image` always wins; otherwise a
|
|
705
768
|
* colocated `opengraph-image.*` (and `twitter-image.*`, falling back to
|
package/src/types.ts
CHANGED
|
@@ -293,6 +293,14 @@ export interface Scheduler {
|
|
|
293
293
|
* webhook). Available on action ctx only — sending email is external
|
|
294
294
|
* I/O, not allowed in mutation transactions.
|
|
295
295
|
*
|
|
296
|
+
* This is the APP email channel (`PYLON_EMAIL_*`): arbitrary recipient
|
|
297
|
+
* and body, so it must be the app's own provider. It is deliberately
|
|
298
|
+
* separate from Pylon's built-in auth emails (codes / password reset /
|
|
299
|
+
* invitations), which send via a `PYLON_AUTH_EMAIL_*` channel. On Pylon
|
|
300
|
+
* Cloud the auth channel may be a shared, locked-down platform key, so
|
|
301
|
+
* `ctx.email` stays inert until you set `PYLON_EMAIL_*` yourself — the
|
|
302
|
+
* shared auth key can never be used to send arbitrary mail.
|
|
303
|
+
*
|
|
296
304
|
* The runtime owns provider config + credentials; functions only
|
|
297
305
|
* supply the (to, subject, body) tuple. Failures are surfaced as
|
|
298
306
|
* thrown errors; on success the return is void.
|