@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.1
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/CHANGELOG.md +245 -0
- package/docs/POPUPS.md +102 -0
- package/package.json +23 -3
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/routes/host.ts +30 -7
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/config/modules.ts +184 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +40 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +10 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/popups/admin.tsx +72 -0
- package/src/popups/cms-bridges.ts +200 -0
- package/src/popups/destinations.tsx +141 -0
- package/src/popups/host.ts +82 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +94 -0
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/types/css-modules.d.ts +6 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an outbound webhook is allowed to go.
|
|
3
|
+
*
|
|
4
|
+
* A webhook endpoint is a URL an administrator types in, and the server then
|
|
5
|
+
* fetches it. That is a server-side request forgery primitive unless something
|
|
6
|
+
* stops it: without this guard, "https://169.254.169.254/latest/meta-data/"
|
|
7
|
+
* turns the CMS into a proxy for reading the host's cloud credentials, and
|
|
8
|
+
* "https://127.0.0.1:5432" turns it into a port scanner for the private
|
|
9
|
+
* network the server sits in.
|
|
10
|
+
*
|
|
11
|
+
* So the rule is an allowlist of shapes, not a blocklist of strings:
|
|
12
|
+
* - https only, never http, never file:, gopher:, data: or anything else;
|
|
13
|
+
* - no credentials in the URL;
|
|
14
|
+
* - a hostname that is not a literal IP address in any private, loopback,
|
|
15
|
+
* link-local, unique-local or otherwise special range;
|
|
16
|
+
* - no hostname that is a known cloud-metadata name.
|
|
17
|
+
*
|
|
18
|
+
* DNS is deliberately NOT resolved here. A name can resolve to a public
|
|
19
|
+
* address at validation time and a private one at delivery time, so the
|
|
20
|
+
* defence that matters is re-checking every hop at fetch time — which is what
|
|
21
|
+
* `safeFetch` does, including on each redirect.
|
|
22
|
+
*
|
|
23
|
+
* Server-only. Never import from client code.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type UrlRejection =
|
|
27
|
+
| "not-a-url"
|
|
28
|
+
| "not-https"
|
|
29
|
+
| "has-credentials"
|
|
30
|
+
| "blocked-host"
|
|
31
|
+
| "blocked-ip"
|
|
32
|
+
| "too-long";
|
|
33
|
+
|
|
34
|
+
export type UrlCheck =
|
|
35
|
+
| { readonly ok: true; readonly url: URL }
|
|
36
|
+
| {
|
|
37
|
+
readonly ok: false;
|
|
38
|
+
readonly reason: UrlRejection;
|
|
39
|
+
/** Safe to show an administrator: never contains a resolved address. */
|
|
40
|
+
readonly message: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const MAX_URL_LENGTH = 2048;
|
|
44
|
+
|
|
45
|
+
/** Hostnames that address the machine or its cloud environment, never a customer's server. */
|
|
46
|
+
const BLOCKED_HOSTNAMES: ReadonlySet<string> = new Set([
|
|
47
|
+
"localhost",
|
|
48
|
+
"localhost.localdomain",
|
|
49
|
+
"ip6-localhost",
|
|
50
|
+
"ip6-loopback",
|
|
51
|
+
// Cloud instance metadata. Reaching any of these leaks the host's own
|
|
52
|
+
// credentials, which is the single highest-value target of an SSRF.
|
|
53
|
+
"metadata",
|
|
54
|
+
"metadata.google.internal",
|
|
55
|
+
"metadata.goog",
|
|
56
|
+
"instance-data",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Suffixes that only ever name something inside the network the server is on. */
|
|
60
|
+
const BLOCKED_SUFFIXES: readonly string[] = [
|
|
61
|
+
".localhost",
|
|
62
|
+
".local",
|
|
63
|
+
".internal",
|
|
64
|
+
".intranet",
|
|
65
|
+
".private",
|
|
66
|
+
".corp",
|
|
67
|
+
".home",
|
|
68
|
+
".lan",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function parseIPv4(host: string): readonly number[] | null {
|
|
72
|
+
const parts = host.split(".");
|
|
73
|
+
if (parts.length !== 4) return null;
|
|
74
|
+
const octets = parts.map((part) => {
|
|
75
|
+
if (!/^\d{1,3}$/.test(part)) return -1;
|
|
76
|
+
const value = Number(part);
|
|
77
|
+
return value >= 0 && value <= 255 ? value : -1;
|
|
78
|
+
});
|
|
79
|
+
return octets.some((o) => o < 0) ? null : octets;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every IPv4 range that is not a routable, public destination. */
|
|
83
|
+
function isBlockedIPv4(octets: readonly number[]): boolean {
|
|
84
|
+
const [a, b] = octets as [number, number, number, number];
|
|
85
|
+
if (a === 0) return true; // "this network"
|
|
86
|
+
if (a === 10) return true; // private
|
|
87
|
+
if (a === 127) return true; // loopback
|
|
88
|
+
if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata
|
|
89
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
90
|
+
if (a === 192 && b === 168) return true; // private
|
|
91
|
+
if (a === 192 && b === 0) return true; // IETF protocol assignments
|
|
92
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
|
|
93
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
|
|
94
|
+
if (a >= 224) return true; // multicast and reserved, incl. 255.255.255.255
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* IPv6, including the forms that smuggle an IPv4 address inside one.
|
|
100
|
+
*
|
|
101
|
+
* `::ffff:127.0.0.1` and `::ffff:7f00:1` are both loopback wearing a different
|
|
102
|
+
* hat, and a guard that only looked at the textual prefix would pass them.
|
|
103
|
+
*/
|
|
104
|
+
function isBlockedIPv6(host: string): boolean {
|
|
105
|
+
const address = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
106
|
+
if (address === "::" || address === "::1") return true;
|
|
107
|
+
|
|
108
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(address);
|
|
109
|
+
if (mapped !== null) {
|
|
110
|
+
const octets = parseIPv4(mapped[1] as string);
|
|
111
|
+
return octets === null || isBlockedIPv4(octets);
|
|
112
|
+
}
|
|
113
|
+
// ::ffff:7f00:1 — the same address written as hex groups.
|
|
114
|
+
const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(address);
|
|
115
|
+
if (hexMapped !== null) {
|
|
116
|
+
const high = Number.parseInt(hexMapped[1] as string, 16);
|
|
117
|
+
const low = Number.parseInt(hexMapped[2] as string, 16);
|
|
118
|
+
const octets = [high >> 8, high & 0xff, low >> 8, low & 0xff];
|
|
119
|
+
return isBlockedIPv4(octets);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (/^fe[89ab][0-9a-f]:/.test(address)) return true; // link-local
|
|
123
|
+
if (/^f[cd][0-9a-f]{2}:/.test(address)) return true; // unique local
|
|
124
|
+
if (/^ff[0-9a-f]{2}:/.test(address)) return true; // multicast
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether this hostname is one the server must never be told to fetch. */
|
|
129
|
+
export function isBlockedHostname(hostname: string): boolean {
|
|
130
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
131
|
+
if (host === "") return true;
|
|
132
|
+
if (BLOCKED_HOSTNAMES.has(host)) return true;
|
|
133
|
+
if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
134
|
+
|
|
135
|
+
const v4 = parseIPv4(host);
|
|
136
|
+
if (v4 !== null) return isBlockedIPv4(v4);
|
|
137
|
+
if (host.includes(":")) return isBlockedIPv6(host);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a webhook endpoint.
|
|
143
|
+
*
|
|
144
|
+
* Used both when an administrator saves a destination and again on every hop
|
|
145
|
+
* of the actual request — a URL that was fine at save time can redirect
|
|
146
|
+
* somewhere that is not.
|
|
147
|
+
*/
|
|
148
|
+
export function checkWebhookUrl(raw: string): UrlCheck {
|
|
149
|
+
const input = (raw ?? "").trim();
|
|
150
|
+
if (input === "")
|
|
151
|
+
return { ok: false, reason: "not-a-url", message: "Enter an endpoint." };
|
|
152
|
+
if (input.length > MAX_URL_LENGTH) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: "too-long",
|
|
156
|
+
message: "That endpoint is too long.",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let url: URL;
|
|
161
|
+
try {
|
|
162
|
+
url = new URL(input);
|
|
163
|
+
} catch {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: "not-a-url",
|
|
167
|
+
message: "That is not a valid URL.",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (url.protocol !== "https:") {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "not-https",
|
|
175
|
+
message:
|
|
176
|
+
"The endpoint must start with https:// — an unencrypted webhook would expose the data it carries.",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (url.username !== "" || url.password !== "") {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: "has-credentials",
|
|
183
|
+
message:
|
|
184
|
+
"Remove the username and password from the URL. Use the signing secret instead.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (isBlockedHostname(url.hostname)) {
|
|
188
|
+
const looksLikeIp =
|
|
189
|
+
parseIPv4(url.hostname) !== null || url.hostname.includes(":");
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
reason: looksLikeIp ? "blocked-ip" : "blocked-host",
|
|
193
|
+
message:
|
|
194
|
+
"That address is inside this server's own network. A webhook must point at a public endpoint.",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { ok: true, url };
|
|
199
|
+
}
|