@pylonsync/functions 0.3.256 → 0.3.258
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/ssr-runtime.test.ts +51 -5
- package/src/ssr-runtime.ts +68 -13
package/package.json
CHANGED
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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",
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -594,21 +594,76 @@ function readSocialImageMeta(relPath: string): {
|
|
|
594
594
|
return { type, width, height, v };
|
|
595
595
|
}
|
|
596
596
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|