@pylonsync/functions 0.3.256 → 0.3.257

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.256",
3
+ "version": "0.3.257",
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",
@@ -10,8 +10,48 @@ import {
10
10
  renderMetadata,
11
11
  buildHydrationTail,
12
12
  errorDigest,
13
+ resolveOrigin,
13
14
  } from "./ssr-runtime";
14
15
 
16
+ describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
17
+ const publicUrl = "https://www.notbehind.com";
18
+
19
+ test("trusts the Host only when it's the configured public origin", () => {
20
+ expect(resolveOrigin({ host: "www.notbehind.com", publicUrl })).toBe(
21
+ "https://www.notbehind.com",
22
+ );
23
+ });
24
+
25
+ test("an attacker Host falls back to the public origin (no poisoning)", () => {
26
+ // The crux: Host: evil.com must NOT produce https://evil.com (which would
27
+ // be baked into og:image + teed into the shared ISR/CDN cache).
28
+ expect(resolveOrigin({ host: "evil.com", publicUrl })).toBe(
29
+ "https://www.notbehind.com",
30
+ );
31
+ });
32
+
33
+ test("explicit PYLON_TRUSTED_HOSTS + canonical host are honored", () => {
34
+ expect(
35
+ resolveOrigin({ host: "cdn.notbehind.com", publicUrl, trustedHostsCsv: "cdn.notbehind.com, x.com" }),
36
+ ).toBe("https://cdn.notbehind.com");
37
+ expect(resolveOrigin({ host: "notbehind.com", publicUrl, canonicalHost: "notbehind.com" })).toBe(
38
+ "https://notbehind.com",
39
+ );
40
+ });
41
+
42
+ test("loopback is trusted for dev; X-Forwarded-Proto honored only there", () => {
43
+ expect(resolveOrigin({ host: "localhost:4321" })).toBe("http://localhost:4321");
44
+ // Attacker downgrade attempt on an untrusted host is ignored (falls back).
45
+ expect(
46
+ resolveOrigin({ host: "evil.com", forwardedProto: "http", publicUrl }),
47
+ ).toBe("https://www.notbehind.com");
48
+ });
49
+
50
+ test("no public origin + untrusted host → empty (relative, never poisoned)", () => {
51
+ expect(resolveOrigin({ host: "evil.com" })).toBe("");
52
+ });
53
+ });
54
+
15
55
  // Pull the JSON out of the `__PYLON_DATA__` <script> a hydration tail emits.
16
56
  function extractPylonData(tail: string): any {
17
57
  const m = tail.match(
@@ -78,11 +118,17 @@ describe("opengraph-image file convention", () => {
78
118
  pngHeader(1200, 630),
79
119
  );
80
120
 
81
- const md = applyAutoSocialImages(
82
- "app/blog/page",
83
- { host: "example.com" },
84
- undefined,
85
- );
121
+ // Host must be allowlisted to be trusted for the absolute OG origin
122
+ // (the cache-poisoning fence). Configure it as the public origin.
123
+ const prevPub = process.env.PYLON_PUBLIC_URL;
124
+ process.env.PYLON_PUBLIC_URL = "https://example.com";
125
+ let md;
126
+ try {
127
+ md = applyAutoSocialImages("app/blog/page", { host: "example.com" }, undefined);
128
+ } finally {
129
+ if (prevPub === undefined) delete process.env.PYLON_PUBLIC_URL;
130
+ else process.env.PYLON_PUBLIC_URL = prevPub;
131
+ }
86
132
 
87
133
  expect(md?.openGraph?.image).toContain(
88
134
  "https://example.com/_pylon/og?src=app%2Fblog%2Fopengraph-image.png",
@@ -594,21 +594,76 @@ function readSocialImageMeta(relPath: string): {
594
594
  return { type, width, height, v };
595
595
  }
596
596
 
597
- /** Absolute origin for OG URLs (crawlers require absolute). Prefers the
598
- * request Host (works for any custom domain the app serves), falling
599
- * back to PYLON_PUBLIC_URL. Empty string relative (dev last resort). */
600
- function resolveRequestOrigin(headers: Record<string, string> | undefined): string {
601
- const host = headers?.["host"];
597
+ const LOOPBACK_HOST = /^(localhost|127\.|\[?::1|0\.0\.0\.0)/;
598
+
599
+ /** Normalize a bare host or a full URL down to a lowercase `host` (host:port).
600
+ * Returns "" for unparseable input. */
601
+ function hostOf(value: string): string {
602
+ const t = (value || "").trim();
603
+ if (!t) return "";
604
+ try {
605
+ return (t.includes("://") ? new URL(t).host : t.replace(/^\/+|\/+$/g, "")).toLowerCase();
606
+ } catch {
607
+ return "";
608
+ }
609
+ }
610
+
611
+ /** Pure origin resolution (exported for tests).
612
+ *
613
+ * SECURITY: the request `Host` (and `X-Forwarded-Proto`) is attacker-
614
+ * controlled. It's only trusted to build the absolute origin baked into
615
+ * `og:image` / canonical URLs when it's in the allowlist — the configured
616
+ * public/canonical host, an explicit `PYLON_TRUSTED_HOSTS` entry, or
617
+ * loopback. An untrusted (or absent) Host falls back to the configured
618
+ * public origin. Without this, `Host: evil.com` on a cacheable
619
+ * (force-static / `revalidate`) render bakes `https://evil.com/_pylon/og…`
620
+ * into the HTML, which is then teed into the shared ISR/CDN cache and
621
+ * served to every subsequent visitor (cache poisoning). */
622
+ export function resolveOrigin(opts: {
623
+ host?: string;
624
+ forwardedProto?: string;
625
+ publicUrl?: string;
626
+ canonicalHost?: string;
627
+ trustedHostsCsv?: string;
628
+ }): string {
629
+ const publicUrl = (opts.publicUrl || "").trim().replace(/\/+$/, "");
630
+ const host = opts.host?.trim().toLowerCase();
602
631
  if (host) {
603
- const proto =
604
- headers?.["x-forwarded-proto"] ||
605
- (/^(localhost|127\.|\[?::1|0\.0\.0\.0)/.test(host) ? "http" : "https");
606
- return `${proto}://${host}`;
632
+ const allow = new Set<string>();
633
+ const add = (v: string) => {
634
+ const h = hostOf(v);
635
+ if (h) allow.add(h);
636
+ };
637
+ add(opts.publicUrl || "");
638
+ add(opts.canonicalHost || "");
639
+ for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
640
+ const isLoopback = LOOPBACK_HOST.test(host);
641
+ if (isLoopback || allow.has(host)) {
642
+ // Only honor a forwarded proto for a TRUSTED host — else an attacker
643
+ // could downgrade the cached URL to http://. Default https off-loopback.
644
+ const proto = opts.forwardedProto || (isLoopback ? "http" : "https");
645
+ return `${proto}://${host}`;
646
+ }
607
647
  }
608
- const env = ((globalThis as any).process?.env?.PYLON_PUBLIC_URL || "")
609
- .trim()
610
- .replace(/\/+$/, "");
611
- return env;
648
+ // Untrusted / absent Host → the configured canonical origin. (Prefer the
649
+ // full public URL; fall back to the canonical host as https.)
650
+ if (publicUrl) return publicUrl;
651
+ const canon = hostOf(opts.canonicalHost || "");
652
+ return canon ? `https://${canon}` : "";
653
+ }
654
+
655
+ /** Absolute origin for OG URLs (crawlers require absolute). Trusts the
656
+ * request Host only when it's allowlisted; otherwise uses PYLON_PUBLIC_URL.
657
+ * See `resolveOrigin` for the security rationale. */
658
+ function resolveRequestOrigin(headers: Record<string, string> | undefined): string {
659
+ const env = (globalThis as any).process?.env ?? {};
660
+ return resolveOrigin({
661
+ host: headers?.["host"],
662
+ forwardedProto: headers?.["x-forwarded-proto"],
663
+ publicUrl: env.PYLON_PUBLIC_URL,
664
+ canonicalHost: env.PYLON_CANONICAL_HOST,
665
+ trustedHostsCsv: env.PYLON_TRUSTED_HOSTS,
666
+ });
612
667
  }
613
668
 
614
669
  /** Merge auto-discovered social-card images into a page's metadata. An