@commonpub/server 2.53.1 → 2.54.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/dist/federation/federation.d.ts.map +1 -1
- package/dist/federation/federation.js +6 -9
- package/dist/federation/federation.js.map +1 -1
- package/dist/federation/hubMirroring.d.ts.map +1 -1
- package/dist/federation/hubMirroring.js +35 -42
- package/dist/federation/hubMirroring.js.map +1 -1
- package/dist/federation/inboxHandlers.d.ts.map +1 -1
- package/dist/federation/inboxHandlers.js +11 -7
- package/dist/federation/inboxHandlers.js.map +1 -1
- package/dist/federation/timeline.d.ts.map +1 -1
- package/dist/federation/timeline.js +5 -11
- package/dist/federation/timeline.js.map +1 -1
- package/dist/identity/__tests__/health.test.js +1 -1
- package/dist/identity/__tests__/health.test.js.map +1 -1
- package/dist/import/ssrf.d.ts +9 -37
- package/dist/import/ssrf.d.ts.map +1 -1
- package/dist/import/ssrf.js +9 -244
- package/dist/import/ssrf.js.map +1 -1
- package/package.json +8 -8
package/dist/import/ssrf.js
CHANGED
|
@@ -1,247 +1,12 @@
|
|
|
1
|
-
/** SSRF protection for content import / remote-asset fetches. */
|
|
2
|
-
import net from 'node:net';
|
|
3
|
-
// Tested against the raw hostname string (a strict superset of the
|
|
4
|
-
// pre-existing patterns — preserves blocking of names like `127.0.0.1.x`).
|
|
5
|
-
const PRIVATE_HOST_PATTERNS = [
|
|
6
|
-
/^127\./,
|
|
7
|
-
/^10\./,
|
|
8
|
-
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
9
|
-
/^192\.168\./,
|
|
10
|
-
/^169\.254\./,
|
|
11
|
-
/^0\./,
|
|
12
|
-
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
13
|
-
/^198\.1[89]\./,
|
|
14
|
-
/^192\.0\.0\./,
|
|
15
|
-
/^192\.0\.2\./,
|
|
16
|
-
/^198\.51\.100\./,
|
|
17
|
-
/^203\.0\.113\./,
|
|
18
|
-
/^22[4-9]\./, // 224.0.0.0+ multicast / reserved
|
|
19
|
-
/^23\d\./,
|
|
20
|
-
/^24\d\./,
|
|
21
|
-
/^25[0-5]\./,
|
|
22
|
-
/^::1$/,
|
|
23
|
-
/^::$/,
|
|
24
|
-
/^::ffff:/i, // IPv4-mapped IPv6
|
|
25
|
-
/^fc/i, // IPv6 unique-local
|
|
26
|
-
/^fd/i,
|
|
27
|
-
/^fe80/i, // IPv6 link-local
|
|
28
|
-
/^ff/i, // IPv6 multicast
|
|
29
|
-
];
|
|
30
|
-
const PRIVATE_IPV4_PATTERNS = [
|
|
31
|
-
/^127\./,
|
|
32
|
-
/^10\./,
|
|
33
|
-
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
34
|
-
/^192\.168\./,
|
|
35
|
-
/^169\.254\./,
|
|
36
|
-
/^0\./,
|
|
37
|
-
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
38
|
-
/^198\.1[89]\./,
|
|
39
|
-
/^192\.0\.0\./,
|
|
40
|
-
/^192\.0\.2\./,
|
|
41
|
-
/^198\.51\.100\./,
|
|
42
|
-
/^203\.0\.113\./,
|
|
43
|
-
/^22[4-9]\./,
|
|
44
|
-
/^23\d\./,
|
|
45
|
-
/^24\d\./,
|
|
46
|
-
/^25[0-5]\./,
|
|
47
|
-
];
|
|
48
|
-
const BLOCKED_HOSTNAMES = new Set([
|
|
49
|
-
'localhost',
|
|
50
|
-
'localhost.localdomain',
|
|
51
|
-
'metadata.google.internal',
|
|
52
|
-
'metadata.internal',
|
|
53
|
-
]);
|
|
54
|
-
/** Classify a literal IP (v4, v6, or IPv4-mapped IPv6) as private/reserved. */
|
|
55
|
-
export function isPrivateIp(ip) {
|
|
56
|
-
let addr = ip.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '');
|
|
57
|
-
const mapped = addr.match(/^(?:::ffff:|::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
58
|
-
if (mapped)
|
|
59
|
-
addr = mapped[1];
|
|
60
|
-
const fam = net.isIP(addr);
|
|
61
|
-
if (fam === 4)
|
|
62
|
-
return PRIVATE_IPV4_PATTERNS.some((p) => p.test(addr));
|
|
63
|
-
if (fam === 6) {
|
|
64
|
-
if (addr === '::1' || addr === '::')
|
|
65
|
-
return true;
|
|
66
|
-
if (/^f[cd]/.test(addr))
|
|
67
|
-
return true; // fc00::/7 unique-local
|
|
68
|
-
if (/^fe[89ab]/.test(addr))
|
|
69
|
-
return true; // fe80::/10 link-local
|
|
70
|
-
if (/^ff/.test(addr))
|
|
71
|
-
return true; // ff00::/8 multicast
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
1
|
/**
|
|
77
|
-
*
|
|
78
|
-
* legitimate public host uses: dotless decimal (2130706433), dotless/segmented
|
|
79
|
-
* hex (0x7f000001 / 0x7f.0.0.1), or octal-leading segments (0177.0.0.1).
|
|
80
|
-
*/
|
|
81
|
-
function isSuspiciousNumericHost(host) {
|
|
82
|
-
if (/^\d+$/.test(host))
|
|
83
|
-
return true;
|
|
84
|
-
if (/^0x[0-9a-f]+$/i.test(host))
|
|
85
|
-
return true;
|
|
86
|
-
if (/^0\d+(\.\d+){0,3}$/.test(host))
|
|
87
|
-
return true;
|
|
88
|
-
if (/^0x[0-9a-f]+(\.[0-9a-fx]+){0,3}$/i.test(host))
|
|
89
|
-
return true;
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Synchronous, string-level SSRF check. Blocks malformed URLs, non-HTTP(S)
|
|
94
|
-
* schemes, blocked hostnames, literal private/reserved IPs (v4, v6,
|
|
95
|
-
* IPv4-mapped), and numeric-encoding bypasses.
|
|
2
|
+
* SSRF protection — re-export shim.
|
|
96
3
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
parsed = new URL(urlString);
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return true;
|
|
110
|
-
}
|
|
111
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
115
|
-
if (!hostname)
|
|
116
|
-
return true;
|
|
117
|
-
if (BLOCKED_HOSTNAMES.has(hostname))
|
|
118
|
-
return true;
|
|
119
|
-
if (isSuspiciousNumericHost(hostname))
|
|
120
|
-
return true;
|
|
121
|
-
// String-prefix check (superset of the original behavior — also blocks
|
|
122
|
-
// names crafted as `127.0.0.1.evil.com`).
|
|
123
|
-
if (PRIVATE_HOST_PATTERNS.some((p) => p.test(hostname)))
|
|
124
|
-
return true;
|
|
125
|
-
// Literal-IP check catches normalized forms the string regex misses
|
|
126
|
-
// (e.g. IPv4-mapped IPv6 `::ffff:7f00:1`).
|
|
127
|
-
if (net.isIP(hostname) && isPrivateIp(hostname))
|
|
128
|
-
return true;
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
132
|
-
const FETCH_TIMEOUT_MS = 30_000;
|
|
133
|
-
const MAX_REDIRECTS = 5;
|
|
134
|
-
/**
|
|
135
|
-
* Stream a Response body into a Buffer, aborting if it exceeds maxSize.
|
|
136
|
-
* Avoids the "buffer everything, then check" pattern that allows a
|
|
137
|
-
* malicious upstream with chunked encoding (no Content-Length) to OOM us.
|
|
138
|
-
*
|
|
139
|
-
* Falls back to `arrayBuffer()` when `response.body` is unavailable
|
|
140
|
-
* (test mocks, HEAD responses, 304s) — the size check still applies after
|
|
141
|
-
* the buffer is materialised.
|
|
142
|
-
*/
|
|
143
|
-
async function streamBoundedBody(response, maxSize) {
|
|
144
|
-
if (!response.body) {
|
|
145
|
-
const buf = Buffer.from(await response.arrayBuffer());
|
|
146
|
-
if (buf.byteLength > maxSize)
|
|
147
|
-
throw new Error('Response too large');
|
|
148
|
-
return buf;
|
|
149
|
-
}
|
|
150
|
-
const reader = response.body.getReader();
|
|
151
|
-
let total = 0;
|
|
152
|
-
const chunks = [];
|
|
153
|
-
try {
|
|
154
|
-
while (true) {
|
|
155
|
-
const { done, value } = await reader.read();
|
|
156
|
-
if (done)
|
|
157
|
-
break;
|
|
158
|
-
total += value.length;
|
|
159
|
-
if (total > maxSize) {
|
|
160
|
-
await reader.cancel();
|
|
161
|
-
throw new Error('Response too large');
|
|
162
|
-
}
|
|
163
|
-
chunks.push(value);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
finally {
|
|
167
|
-
try {
|
|
168
|
-
reader.releaseLock();
|
|
169
|
-
}
|
|
170
|
-
catch { /* may already be released */ }
|
|
171
|
-
}
|
|
172
|
-
return Buffer.concat(chunks);
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Fetch a URL, re-validating every hop against `isPrivateUrl`.
|
|
176
|
-
*
|
|
177
|
-
* The caller owns the deadline via `options.signal`, so the abort timeout
|
|
178
|
-
* spans connect + redirects + body read (the body is consumed by the caller
|
|
179
|
-
* after this returns) — a slow-trickle upstream that streams headers fast
|
|
180
|
-
* but withholds the body can no longer hold the worker past the timeout.
|
|
181
|
-
*/
|
|
182
|
-
async function fetchWithRedirectValidation(url, options = {}) {
|
|
183
|
-
const accept = options.accept ?? 'text/html,application/xhtml+xml';
|
|
184
|
-
const userAgent = options.userAgent ?? 'CommonPub/1.0 (+https://commonpub.io)';
|
|
185
|
-
const signal = options.signal;
|
|
186
|
-
let currentUrl = url;
|
|
187
|
-
let redirectCount = 0;
|
|
188
|
-
while (redirectCount < MAX_REDIRECTS) {
|
|
189
|
-
if (isPrivateUrl(currentUrl)) {
|
|
190
|
-
throw new Error('URL points to a private or reserved address');
|
|
191
|
-
}
|
|
192
|
-
const response = await fetch(currentUrl, {
|
|
193
|
-
signal,
|
|
194
|
-
redirect: 'manual',
|
|
195
|
-
headers: { 'User-Agent': userAgent, 'Accept': accept },
|
|
196
|
-
});
|
|
197
|
-
if (response.status >= 300 && response.status < 400) {
|
|
198
|
-
const location = response.headers.get('location');
|
|
199
|
-
if (!location)
|
|
200
|
-
throw new Error('Redirect without Location header');
|
|
201
|
-
currentUrl = new URL(location, currentUrl).toString();
|
|
202
|
-
redirectCount++;
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
if (!response.ok) {
|
|
206
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
207
|
-
}
|
|
208
|
-
return { response, finalUrl: currentUrl };
|
|
209
|
-
}
|
|
210
|
-
throw new Error('Too many redirects');
|
|
211
|
-
}
|
|
212
|
-
/** Run an operation under a single abort deadline that spans fetch + body read. */
|
|
213
|
-
async function withDeadline(timeoutMs, fn) {
|
|
214
|
-
const controller = new AbortController();
|
|
215
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
216
|
-
try {
|
|
217
|
-
return await fn(controller.signal);
|
|
218
|
-
}
|
|
219
|
-
finally {
|
|
220
|
-
clearTimeout(timeout);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Fetch a URL with SSRF protection, redirect re-validation, a streaming
|
|
225
|
-
* size cap, and a deadline covering the whole exchange (incl. body read).
|
|
226
|
-
* Returns the response body as a string.
|
|
227
|
-
*/
|
|
228
|
-
export async function safeFetch(url, options = {}) {
|
|
229
|
-
return withDeadline(options.timeoutMs ?? FETCH_TIMEOUT_MS, async (signal) => {
|
|
230
|
-
const { response, finalUrl } = await fetchWithRedirectValidation(url, { ...options, signal });
|
|
231
|
-
const buffer = await streamBoundedBody(response, MAX_RESPONSE_SIZE);
|
|
232
|
-
return { html: new TextDecoder().decode(buffer), finalUrl };
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Like `safeFetch` but returns the body as a Buffer plus the upstream
|
|
237
|
-
* Content-Type. Use for binary content (images, etc.).
|
|
238
|
-
*/
|
|
239
|
-
export async function safeFetchBinary(url, options = {}) {
|
|
240
|
-
return withDeadline(options.timeoutMs ?? FETCH_TIMEOUT_MS, async (signal) => {
|
|
241
|
-
const { response, finalUrl } = await fetchWithRedirectValidation(url, { ...options, signal });
|
|
242
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
243
|
-
const buffer = await streamBoundedBody(response, MAX_RESPONSE_SIZE);
|
|
244
|
-
return { buffer, contentType, finalUrl };
|
|
245
|
-
});
|
|
246
|
-
}
|
|
4
|
+
* The canonical implementation moved to `@commonpub/protocol` in the
|
|
5
|
+
* federation-hardening work (Item 5) so `actorResolver` and the server
|
|
6
|
+
* share one module (and one pinned-lookup dispatcher). This shim keeps
|
|
7
|
+
* every internal `~/import/ssrf` import site and `@commonpub/server`'s
|
|
8
|
+
* public API (stable since 2.48.0) unchanged. Do not add logic here —
|
|
9
|
+
* edit `packages/protocol/src/ssrf.ts`.
|
|
10
|
+
*/
|
|
11
|
+
export { isPrivateIp, isPrivateUrl, safeFetch, safeFetchBinary } from '@commonpub/protocol';
|
|
247
12
|
//# sourceMappingURL=ssrf.js.map
|
package/dist/import/ssrf.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../../src/import/ssrf.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ssrf.js","sourceRoot":"","sources":["../../src/import/ssrf.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.54.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Framework-agnostic business logic for CommonPub instances",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -108,14 +108,14 @@
|
|
|
108
108
|
"linkedom": "^0.18.12",
|
|
109
109
|
"megalodon": "^10.3.0",
|
|
110
110
|
"turndown": "^7.2.4",
|
|
111
|
-
"@commonpub/config": "0.12.0",
|
|
112
|
-
"@commonpub/docs": "0.6.3",
|
|
113
|
-
"@commonpub/learning": "0.5.2",
|
|
114
|
-
"@commonpub/protocol": "0.9.10",
|
|
115
|
-
"@commonpub/schema": "0.16.0",
|
|
116
|
-
"@commonpub/infra": "0.7.0",
|
|
117
111
|
"@commonpub/auth": "0.6.0",
|
|
118
|
-
"@commonpub/
|
|
112
|
+
"@commonpub/protocol": "0.10.1",
|
|
113
|
+
"@commonpub/schema": "0.16.0",
|
|
114
|
+
"@commonpub/learning": "0.5.2",
|
|
115
|
+
"@commonpub/infra": "0.7.1",
|
|
116
|
+
"@commonpub/config": "0.13.0",
|
|
117
|
+
"@commonpub/docs": "0.6.3",
|
|
118
|
+
"@commonpub/editor": "0.7.10"
|
|
119
119
|
},
|
|
120
120
|
"peerDependencies": {
|
|
121
121
|
"drizzle-orm": "^0.45.1"
|