@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.
@@ -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
- // Redirect to original URL as fallback
206
- return response.redirect(url);
270
+ return response.status(404).send("Image not available");
207
271
  }
208
272
 
209
273
  // Set cache headers
@@ -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 regex = new RegExp(query, "i");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.29",
3
+ "version": "1.0.30",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",