@rmdes/indiekit-endpoint-microsub 1.0.29 → 1.0.30
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/lib/media/proxy.js +66 -2
- package/lib/storage/items.js +5 -1
- package/lib/webmention/verifier.js +24 -1
- package/package.json +1 -1
package/lib/media/proxy.js
CHANGED
|
@@ -7,6 +7,60 @@ import crypto from "node:crypto";
|
|
|
7
7
|
|
|
8
8
|
import { getCache, setCache } from "../cache/redis.js";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Private/internal IP ranges that should never be fetched (SSRF protection)
|
|
12
|
+
*/
|
|
13
|
+
const BLOCKED_HOSTNAMES = new Set(["localhost", "0.0.0.0"]);
|
|
14
|
+
const BLOCKED_IP_PREFIXES = [
|
|
15
|
+
"127.", // Loopback
|
|
16
|
+
"10.", // Private Class A
|
|
17
|
+
"192.168.", // Private Class C
|
|
18
|
+
"169.254.", // Link-local
|
|
19
|
+
"0.", // Current network
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a hostname resolves to a private/internal address
|
|
24
|
+
* @param {string} urlString - URL to check
|
|
25
|
+
* @returns {boolean} True if the URL targets a private/internal address
|
|
26
|
+
*/
|
|
27
|
+
export function isPrivateUrl(urlString) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = new URL(urlString);
|
|
30
|
+
const hostname = parsed.hostname;
|
|
31
|
+
|
|
32
|
+
// Block known private hostnames
|
|
33
|
+
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Block IPv6 loopback
|
|
38
|
+
if (hostname === "::1" || hostname === "[::1]") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Block private IPv4 ranges
|
|
43
|
+
for (const prefix of BLOCKED_IP_PREFIXES) {
|
|
44
|
+
if (hostname.startsWith(prefix)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Block 172.16.0.0/12 (172.16.x.x - 172.31.x.x)
|
|
50
|
+
const match172 = hostname.match(/^172\.(\d+)\./);
|
|
51
|
+
if (match172) {
|
|
52
|
+
const second = Number.parseInt(match172[1], 10);
|
|
53
|
+
if (second >= 16 && second <= 31) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false;
|
|
59
|
+
} catch {
|
|
60
|
+
return true; // Invalid URLs are blocked
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
10
64
|
const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
|
|
11
65
|
const CACHE_TTL = 4 * 60 * 60; // 4 hours
|
|
12
66
|
const ALLOWED_TYPES = new Set([
|
|
@@ -99,6 +153,12 @@ export function proxyItemImages(item, baseUrl) {
|
|
|
99
153
|
* @returns {Promise<object|null>} Cached image data or null
|
|
100
154
|
*/
|
|
101
155
|
export async function fetchImage(redis, url) {
|
|
156
|
+
// Block private/internal URLs (defense-in-depth)
|
|
157
|
+
if (isPrivateUrl(url)) {
|
|
158
|
+
console.error(`[Microsub] Media proxy blocked private URL: ${url}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
102
162
|
const cacheKey = `media:${hashUrl(url)}`;
|
|
103
163
|
|
|
104
164
|
// Try cache first
|
|
@@ -194,6 +254,11 @@ export async function handleMediaProxy(request, response) {
|
|
|
194
254
|
return response.status(400).send("Invalid URL");
|
|
195
255
|
}
|
|
196
256
|
|
|
257
|
+
// Block requests to private/internal networks (SSRF protection)
|
|
258
|
+
if (isPrivateUrl(url)) {
|
|
259
|
+
return response.status(403).send("URL not allowed");
|
|
260
|
+
}
|
|
261
|
+
|
|
197
262
|
// Get Redis client from application
|
|
198
263
|
const { application } = request.app.locals;
|
|
199
264
|
const redis = application.redis;
|
|
@@ -202,8 +267,7 @@ export async function handleMediaProxy(request, response) {
|
|
|
202
267
|
const imageData = await fetchImage(redis, url);
|
|
203
268
|
|
|
204
269
|
if (!imageData) {
|
|
205
|
-
|
|
206
|
-
return response.redirect(url);
|
|
270
|
+
return response.status(404).send("Image not available");
|
|
207
271
|
}
|
|
208
272
|
|
|
209
273
|
// Set cache headers
|
package/lib/storage/items.js
CHANGED
|
@@ -602,7 +602,11 @@ export async function searchItems(application, channelId, query, limit = 20) {
|
|
|
602
602
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
603
603
|
|
|
604
604
|
// Use regex search (consider adding text index for better performance)
|
|
605
|
-
const
|
|
605
|
+
const escapedQuery = query.replaceAll(
|
|
606
|
+
/[$()*+.?[\\\]^{|}]/g,
|
|
607
|
+
String.raw`\$&`,
|
|
608
|
+
);
|
|
609
|
+
const regex = new RegExp(escapedQuery, "i");
|
|
606
610
|
const items = await collection
|
|
607
611
|
.find({
|
|
608
612
|
channelId: objectId,
|
|
@@ -4,6 +4,29 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { mf2 } from "microformats-parser";
|
|
7
|
+
import sanitizeHtml from "sanitize-html";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sanitize HTML options (matches normalizer.js)
|
|
11
|
+
*/
|
|
12
|
+
const SANITIZE_OPTIONS = {
|
|
13
|
+
allowedTags: [
|
|
14
|
+
"a", "abbr", "b", "blockquote", "br", "code", "em", "figcaption",
|
|
15
|
+
"figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img",
|
|
16
|
+
"li", "ol", "p", "pre", "s", "span", "strike", "strong", "sub",
|
|
17
|
+
"sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul",
|
|
18
|
+
"video", "audio", "source",
|
|
19
|
+
],
|
|
20
|
+
allowedAttributes: {
|
|
21
|
+
a: ["href", "title", "rel"],
|
|
22
|
+
img: ["src", "alt", "title", "width", "height"],
|
|
23
|
+
video: ["src", "poster", "controls", "width", "height"],
|
|
24
|
+
audio: ["src", "controls"],
|
|
25
|
+
source: ["src", "type"],
|
|
26
|
+
"*": ["class"],
|
|
27
|
+
},
|
|
28
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
29
|
+
};
|
|
7
30
|
|
|
8
31
|
/**
|
|
9
32
|
* Verify a webmention
|
|
@@ -276,7 +299,7 @@ function extractContent(entry) {
|
|
|
276
299
|
|
|
277
300
|
return {
|
|
278
301
|
text: content.value,
|
|
279
|
-
html: content.html,
|
|
302
|
+
html: content.html ? sanitizeHtml(content.html, SANITIZE_OPTIONS) : undefined,
|
|
280
303
|
};
|
|
281
304
|
}
|
|
282
305
|
|
package/package.json
CHANGED