@rmdes/indiekit-endpoint-microsub 1.0.55 → 1.0.56
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/index.js +24 -13
- package/lib/activitypub/outbox-fetcher.js +14 -2
- package/lib/cache/redis.js +14 -4
- package/lib/controllers/channels.js +2 -2
- package/lib/controllers/reader.js +5 -23
- package/lib/controllers/search.js +6 -0
- package/lib/controllers/timeline.js +2 -2
- package/lib/feeds/capabilities.js +5 -0
- package/lib/feeds/fetcher.js +6 -0
- package/lib/feeds/normalizer.js +3 -55
- package/lib/media/proxy.js +82 -27
- package/lib/polling/processor.js +27 -4
- package/lib/polling/scheduler.js +2 -0
- package/lib/realtime/broker.js +6 -1
- package/lib/storage/channels.js +53 -42
- package/lib/storage/feeds.js +3 -1
- package/lib/storage/items.js +18 -24
- package/lib/utils/constants.js +7 -0
- package/lib/utils/csrf.js +51 -0
- package/lib/utils/sanitize.js +61 -0
- package/lib/utils/validation.js +8 -2
- package/lib/webmention/verifier.js +10 -21
- package/lib/websub/subscriber.js +12 -0
- package/package.json +3 -1
- package/views/actor.njk +2 -0
- package/views/channel-new.njk +1 -0
- package/views/channel.njk +9 -3
- package/views/compose.njk +1 -0
- package/views/deck-settings.njk +1 -0
- package/views/feed-edit.njk +3 -0
- package/views/feeds.njk +4 -0
- package/views/layouts/reader.njk +1 -0
- package/views/search.njk +2 -0
- package/views/settings.njk +2 -0
- package/views/timeline.njk +6 -3
package/index.js
CHANGED
|
@@ -2,11 +2,13 @@ import path from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
|
|
4
4
|
import express from "express";
|
|
5
|
+
import rateLimit from "express-rate-limit";
|
|
5
6
|
|
|
6
7
|
import { microsubController } from "./lib/controllers/microsub.js";
|
|
7
8
|
import { opmlController } from "./lib/controllers/opml.js";
|
|
8
9
|
import { readerController } from "./lib/controllers/reader.js";
|
|
9
10
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
11
|
+
import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
|
|
10
12
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
11
13
|
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
|
|
12
14
|
import {
|
|
@@ -77,17 +79,14 @@ export default class MicrosubEndpoint {
|
|
|
77
79
|
router.get("/", microsubController.get);
|
|
78
80
|
router.post("/", microsubController.post);
|
|
79
81
|
|
|
80
|
-
// WebSub
|
|
81
|
-
|
|
82
|
-
router.post("/websub/:id", websubHandler.receive);
|
|
83
|
-
|
|
84
|
-
// Webmention receiving endpoint
|
|
85
|
-
router.post("/webmention", webmentionReceiver.receive);
|
|
86
|
-
|
|
87
|
-
// Media proxy endpoint
|
|
88
|
-
router.get("/media/:hash", handleMediaProxy);
|
|
82
|
+
// WebSub, webmention, and media proxy are registered in routesPublic only
|
|
83
|
+
// (they must be accessible without authentication)
|
|
89
84
|
|
|
90
85
|
// Reader UI routes (mounted as sub-router for correct baseUrl)
|
|
86
|
+
// CSRF protection: generate token on all requests, validate on POST
|
|
87
|
+
readerRouter.use(csrfToken);
|
|
88
|
+
readerRouter.use(csrfValidate);
|
|
89
|
+
|
|
91
90
|
readerRouter.get("/", readerController.index);
|
|
92
91
|
readerRouter.get("/channels", readerController.channels);
|
|
93
92
|
readerRouter.get("/channels/new", readerController.newChannel);
|
|
@@ -155,15 +154,20 @@ export default class MicrosubEndpoint {
|
|
|
155
154
|
get routesPublic() {
|
|
156
155
|
const publicRouter = express.Router();
|
|
157
156
|
|
|
157
|
+
// Rate limiters for public endpoints
|
|
158
|
+
const mediaLimiter = rateLimit({ windowMs: 60_000, max: 120, message: "Too many requests" });
|
|
159
|
+
const websubLimiter = rateLimit({ windowMs: 60_000, max: 30, message: "Too many requests" });
|
|
160
|
+
const webmentionLimiter = rateLimit({ windowMs: 60_000, max: 10, message: "Too many requests" });
|
|
161
|
+
|
|
158
162
|
// WebSub verification must be public for hubs to verify
|
|
159
|
-
publicRouter.get("/websub/:id", websubHandler.verify);
|
|
160
|
-
publicRouter.post("/websub/:id", websubHandler.receive);
|
|
163
|
+
publicRouter.get("/websub/:id", websubLimiter, websubHandler.verify);
|
|
164
|
+
publicRouter.post("/websub/:id", websubLimiter, websubHandler.receive);
|
|
161
165
|
|
|
162
166
|
// Webmention endpoint must be public
|
|
163
|
-
publicRouter.post("/webmention", webmentionReceiver.receive);
|
|
167
|
+
publicRouter.post("/webmention", webmentionLimiter, webmentionReceiver.receive);
|
|
164
168
|
|
|
165
169
|
// Media proxy must be public for images to load
|
|
166
|
-
publicRouter.get("/media/:hash", handleMediaProxy);
|
|
170
|
+
publicRouter.get("/media/:hash", mediaLimiter, handleMediaProxy);
|
|
167
171
|
|
|
168
172
|
return publicRouter;
|
|
169
173
|
}
|
|
@@ -222,6 +226,13 @@ export default class MicrosubEndpoint {
|
|
|
222
226
|
cleanupStaleItems(indiekit).catch((error) => {
|
|
223
227
|
console.warn("[Microsub] Stale cleanup failed:", error.message);
|
|
224
228
|
});
|
|
229
|
+
|
|
230
|
+
// Schedule daily stale cleanup (items accumulate between restarts)
|
|
231
|
+
setInterval(() => {
|
|
232
|
+
cleanupStaleItems(indiekit).catch((error) => {
|
|
233
|
+
console.warn("[Microsub] Scheduled stale cleanup failed:", error.message);
|
|
234
|
+
});
|
|
235
|
+
}, 24 * 60 * 60 * 1000);
|
|
225
236
|
} else {
|
|
226
237
|
console.warn(
|
|
227
238
|
"[Microsub] Database not available at init, scheduler not started",
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* @module activitypub/outbox-fetcher
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import sanitizeHtml from "sanitize-html";
|
|
9
|
+
|
|
10
|
+
import { isPrivateUrl } from "../media/proxy.js";
|
|
11
|
+
import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
|
|
12
|
+
|
|
8
13
|
const AP_ACCEPT =
|
|
9
14
|
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
10
15
|
const FETCH_TIMEOUT = 10_000;
|
|
@@ -121,8 +126,9 @@ function activityToJf2(activity, actorInfo) {
|
|
|
121
126
|
]);
|
|
122
127
|
if (!contentTypes.has(object.type)) return null;
|
|
123
128
|
|
|
124
|
-
const
|
|
125
|
-
const
|
|
129
|
+
const rawHtml = object.content || "";
|
|
130
|
+
const contentHtml = rawHtml ? sanitizeHtml(rawHtml, SANITIZE_OPTIONS) : "";
|
|
131
|
+
const contentText = stripHtml(rawHtml);
|
|
126
132
|
|
|
127
133
|
const jf2 = {
|
|
128
134
|
type: "entry",
|
|
@@ -212,6 +218,12 @@ function extractMedia(attachments, mediaPrefix) {
|
|
|
212
218
|
async function fetchJson(url) {
|
|
213
219
|
if (!url) return null;
|
|
214
220
|
|
|
221
|
+
// SSRF protection — block private/internal IPs (including DNS rebinding)
|
|
222
|
+
if (await isPrivateUrl(url)) {
|
|
223
|
+
console.warn(`[Microsub] AP fetch blocked private URL: ${url}`);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
215
227
|
const controller = new AbortController();
|
|
216
228
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
217
229
|
|
package/lib/cache/redis.js
CHANGED
|
@@ -56,6 +56,15 @@ export function getRedisClient(application) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Namespace cache keys to prevent cross-instance collisions
|
|
61
|
+
* @param {string} key - Raw cache key
|
|
62
|
+
* @returns {string} Namespaced key
|
|
63
|
+
*/
|
|
64
|
+
function nsKey(key) {
|
|
65
|
+
return `microsub:${key}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
/**
|
|
60
69
|
* Get value from cache
|
|
61
70
|
* @param {object} redis - Redis client
|
|
@@ -68,7 +77,7 @@ export async function getCache(redis, key) {
|
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
try {
|
|
71
|
-
const value = await redis.get(key);
|
|
80
|
+
const value = await redis.get(nsKey(key));
|
|
72
81
|
if (value) {
|
|
73
82
|
return JSON.parse(value);
|
|
74
83
|
}
|
|
@@ -92,9 +101,10 @@ export async function setCache(redis, key, value, ttl = 300) {
|
|
|
92
101
|
|
|
93
102
|
try {
|
|
94
103
|
const serialized = JSON.stringify(value);
|
|
104
|
+
const nk = nsKey(key);
|
|
95
105
|
await (ttl
|
|
96
|
-
? redis.set(
|
|
97
|
-
: redis.set(
|
|
106
|
+
? redis.set(nk, serialized, "EX", ttl)
|
|
107
|
+
: redis.set(nk, serialized));
|
|
98
108
|
} catch {
|
|
99
109
|
// Ignore cache errors
|
|
100
110
|
}
|
|
@@ -112,7 +122,7 @@ export async function deleteCache(redis, key) {
|
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
try {
|
|
115
|
-
await redis.del(key);
|
|
125
|
+
await redis.del(nsKey(key));
|
|
116
126
|
} catch {
|
|
117
127
|
// Ignore cache errors
|
|
118
128
|
}
|
|
@@ -17,7 +17,7 @@ import { getUserId } from "../utils/auth.js";
|
|
|
17
17
|
import {
|
|
18
18
|
validateChannel,
|
|
19
19
|
validateChannelName,
|
|
20
|
-
parseArrayParameter
|
|
20
|
+
parseArrayParameter,
|
|
21
21
|
} from "../utils/validation.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -62,7 +62,7 @@ export async function action(request, response) {
|
|
|
62
62
|
|
|
63
63
|
// Reorder channels
|
|
64
64
|
if (method === "order") {
|
|
65
|
-
const channelUids =
|
|
65
|
+
const channelUids = parseArrayParameter(request.body, "channels");
|
|
66
66
|
if (channelUids.length === 0) {
|
|
67
67
|
throw new IndiekitError("Missing channels[] parameter", {
|
|
68
68
|
status: 400,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getChannels,
|
|
12
12
|
getChannelsWithColors,
|
|
13
13
|
getChannel,
|
|
14
|
+
getChannelById,
|
|
14
15
|
createChannel,
|
|
15
16
|
updateChannelSettings,
|
|
16
17
|
deleteChannel,
|
|
@@ -31,6 +32,8 @@ import {
|
|
|
31
32
|
countReadItems,
|
|
32
33
|
} from "../storage/items.js";
|
|
33
34
|
import { fetchActorOutbox } from "../activitypub/outbox-fetcher.js";
|
|
35
|
+
|
|
36
|
+
const ACTOR_OUTBOX_LIMIT = 30;
|
|
34
37
|
import { getUserId } from "../utils/auth.js";
|
|
35
38
|
import {
|
|
36
39
|
validateChannelName,
|
|
@@ -399,8 +402,7 @@ export async function item(request, response) {
|
|
|
399
402
|
// Get the channel for this item (needed for mark-read)
|
|
400
403
|
let channel = null;
|
|
401
404
|
if (itemDocument.channelId) {
|
|
402
|
-
|
|
403
|
-
channel = await channelsCollection.findOne({ _id: itemDocument.channelId });
|
|
405
|
+
channel = await getChannelById(application, itemDocument.channelId);
|
|
404
406
|
}
|
|
405
407
|
|
|
406
408
|
const itemBreadcrumbs = [
|
|
@@ -556,20 +558,6 @@ export async function submitCompose(request, response) {
|
|
|
556
558
|
const bookmarkOf = request.body["bookmark-of"];
|
|
557
559
|
const syndicateTo = request.body["mp-syndicate-to"];
|
|
558
560
|
|
|
559
|
-
// Debug logging
|
|
560
|
-
console.info(
|
|
561
|
-
"[Microsub] submitCompose request.body:",
|
|
562
|
-
JSON.stringify(request.body),
|
|
563
|
-
);
|
|
564
|
-
console.info("[Microsub] Extracted values:", {
|
|
565
|
-
content,
|
|
566
|
-
inReplyTo,
|
|
567
|
-
likeOf,
|
|
568
|
-
repostOf,
|
|
569
|
-
bookmarkOf,
|
|
570
|
-
syndicateTo,
|
|
571
|
-
});
|
|
572
|
-
|
|
573
561
|
// Get Micropub endpoint
|
|
574
562
|
const micropubEndpoint = application.micropubEndpoint;
|
|
575
563
|
if (!micropubEndpoint) {
|
|
@@ -629,12 +617,6 @@ export async function submitCompose(request, response) {
|
|
|
629
617
|
}
|
|
630
618
|
}
|
|
631
619
|
|
|
632
|
-
// Debug: log what we're sending
|
|
633
|
-
console.info("[Microsub] Sending to Micropub:", {
|
|
634
|
-
url: micropubUrl,
|
|
635
|
-
body: micropubData.toString(),
|
|
636
|
-
});
|
|
637
|
-
|
|
638
620
|
try {
|
|
639
621
|
const micropubResponse = await fetch(micropubUrl, {
|
|
640
622
|
method: "POST",
|
|
@@ -1268,7 +1250,7 @@ export async function actorProfile(request, response) {
|
|
|
1268
1250
|
const canFollow = !!apPlugin;
|
|
1269
1251
|
|
|
1270
1252
|
try {
|
|
1271
|
-
const { actor, items } = await fetchActorOutbox(actorUrl, { limit:
|
|
1253
|
+
const { actor, items } = await fetchActorOutbox(actorUrl, { limit: ACTOR_OUTBOX_LIMIT });
|
|
1272
1254
|
|
|
1273
1255
|
response.render("actor", {
|
|
1274
1256
|
title: actor.name || "Actor",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { IndiekitError } from "@indiekit/error";
|
|
7
7
|
|
|
8
8
|
import { discoverFeeds } from "../feeds/hfeed.js";
|
|
9
|
+
import { isPrivateUrl } from "../media/proxy.js";
|
|
9
10
|
import { searchWithFallback } from "../search/query.js";
|
|
10
11
|
import { getChannel } from "../storage/channels.js";
|
|
11
12
|
import { getUserId } from "../utils/auth.js";
|
|
@@ -35,6 +36,11 @@ export async function discover(request, response) {
|
|
|
35
36
|
return response.json({ results: [] });
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// SSRF protection
|
|
40
|
+
if (await isPrivateUrl(url.href)) {
|
|
41
|
+
throw new IndiekitError("URL blocked (private/internal address)", { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
try {
|
|
39
45
|
// Fetch the URL content
|
|
40
46
|
const fetchResponse = await fetch(url.href, {
|
|
@@ -18,7 +18,7 @@ import { getUserId } from "../utils/auth.js";
|
|
|
18
18
|
import {
|
|
19
19
|
validateChannel,
|
|
20
20
|
validateEntries,
|
|
21
|
-
parseArrayParameter
|
|
21
|
+
parseArrayParameter,
|
|
22
22
|
} from "../utils/validation.js";
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -90,7 +90,7 @@ export async function action(request, response) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// Get entry IDs from request
|
|
93
|
-
const entries =
|
|
93
|
+
const entries = parseArrayParameter(request.body, "entry");
|
|
94
94
|
|
|
95
95
|
switch (method) {
|
|
96
96
|
case "mark_read": {
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module feeds/capabilities
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { isPrivateUrl } from "../media/proxy.js";
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Known Fediverse domain patterns
|
|
9
11
|
*/
|
|
@@ -136,6 +138,9 @@ async function discoverEndpoints(url) {
|
|
|
136
138
|
micropub: null,
|
|
137
139
|
};
|
|
138
140
|
|
|
141
|
+
// SSRF protection
|
|
142
|
+
if (await isPrivateUrl(url)) return endpoints;
|
|
143
|
+
|
|
139
144
|
const controller = new AbortController();
|
|
140
145
|
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
141
146
|
|
package/lib/feeds/fetcher.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getCache, setCache } from "../cache/redis.js";
|
|
7
|
+
import { isPrivateUrl } from "../media/proxy.js";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT = 30_000; // 30 seconds
|
|
9
10
|
const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
|
|
@@ -21,6 +22,11 @@ const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
|
|
|
21
22
|
export async function fetchFeed(url, options = {}) {
|
|
22
23
|
const { etag, lastModified, timeout = DEFAULT_TIMEOUT, redis } = options;
|
|
23
24
|
|
|
25
|
+
// SSRF protection — block private/internal IPs (including DNS rebinding)
|
|
26
|
+
if (await isPrivateUrl(url)) {
|
|
27
|
+
throw new Error(`Feed URL blocked (private/internal address): ${url}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
// Check cache first
|
|
25
31
|
if (redis) {
|
|
26
32
|
const cached = await getCache(redis, `feed:${url}`);
|
package/lib/feeds/normalizer.js
CHANGED
|
@@ -7,6 +7,8 @@ import crypto from "node:crypto";
|
|
|
7
7
|
|
|
8
8
|
import sanitizeHtml from "sanitize-html";
|
|
9
9
|
|
|
10
|
+
import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* Extract image URLs from HTML content.
|
|
12
14
|
* Used as a fallback when no explicit photo/enclosure is provided.
|
|
@@ -89,61 +91,7 @@ function toISOStringSafe(dateInput) {
|
|
|
89
91
|
return date ? date.toISOString() : undefined;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
* Sanitize HTML options
|
|
94
|
-
*/
|
|
95
|
-
const SANITIZE_OPTIONS = {
|
|
96
|
-
allowedTags: [
|
|
97
|
-
"a",
|
|
98
|
-
"abbr",
|
|
99
|
-
"b",
|
|
100
|
-
"blockquote",
|
|
101
|
-
"br",
|
|
102
|
-
"code",
|
|
103
|
-
"em",
|
|
104
|
-
"figcaption",
|
|
105
|
-
"figure",
|
|
106
|
-
"h1",
|
|
107
|
-
"h2",
|
|
108
|
-
"h3",
|
|
109
|
-
"h4",
|
|
110
|
-
"h5",
|
|
111
|
-
"h6",
|
|
112
|
-
"hr",
|
|
113
|
-
"i",
|
|
114
|
-
"img",
|
|
115
|
-
"li",
|
|
116
|
-
"ol",
|
|
117
|
-
"p",
|
|
118
|
-
"pre",
|
|
119
|
-
"s",
|
|
120
|
-
"span",
|
|
121
|
-
"strike",
|
|
122
|
-
"strong",
|
|
123
|
-
"sub",
|
|
124
|
-
"sup",
|
|
125
|
-
"table",
|
|
126
|
-
"tbody",
|
|
127
|
-
"td",
|
|
128
|
-
"th",
|
|
129
|
-
"thead",
|
|
130
|
-
"tr",
|
|
131
|
-
"u",
|
|
132
|
-
"ul",
|
|
133
|
-
"video",
|
|
134
|
-
"audio",
|
|
135
|
-
"source",
|
|
136
|
-
],
|
|
137
|
-
allowedAttributes: {
|
|
138
|
-
a: ["href", "title", "rel"],
|
|
139
|
-
img: ["src", "alt", "title", "width", "height"],
|
|
140
|
-
video: ["src", "poster", "controls", "width", "height"],
|
|
141
|
-
audio: ["src", "controls"],
|
|
142
|
-
source: ["src", "type"],
|
|
143
|
-
"*": ["class"],
|
|
144
|
-
},
|
|
145
|
-
allowedSchemes: ["http", "https", "mailto"],
|
|
146
|
-
};
|
|
94
|
+
// SANITIZE_OPTIONS imported from ../utils/sanitize.js (shared with AP outbox fetcher)
|
|
147
95
|
|
|
148
96
|
/**
|
|
149
97
|
* Generate unique ID for an item
|
package/lib/media/proxy.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import crypto from "node:crypto";
|
|
7
|
+
import dns from "node:dns/promises";
|
|
7
8
|
|
|
8
9
|
import { getCache, setCache } from "../cache/redis.js";
|
|
9
10
|
|
|
@@ -20,39 +21,59 @@ const BLOCKED_IP_PREFIXES = [
|
|
|
20
21
|
];
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* Check if
|
|
24
|
+
* Check if an IP address is in a private/internal range
|
|
25
|
+
* @param {string} ip - IP address to check
|
|
26
|
+
* @returns {boolean} True if private
|
|
27
|
+
*/
|
|
28
|
+
function isPrivateIp(ip) {
|
|
29
|
+
if (ip === "::1" || ip === "127.0.0.1") return true;
|
|
30
|
+
|
|
31
|
+
for (const prefix of BLOCKED_IP_PREFIXES) {
|
|
32
|
+
if (ip.startsWith(prefix)) return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 172.16.0.0/12
|
|
36
|
+
const match172 = ip.match(/^172\.(\d+)\./);
|
|
37
|
+
if (match172) {
|
|
38
|
+
const second = Number.parseInt(match172[1], 10);
|
|
39
|
+
if (second >= 16 && second <= 31) return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a URL targets a private/internal address.
|
|
47
|
+
* Performs both string-based hostname checks AND DNS resolution
|
|
48
|
+
* to prevent DNS rebinding attacks.
|
|
49
|
+
*
|
|
24
50
|
* @param {string} urlString - URL to check
|
|
25
|
-
* @returns {boolean} True if the URL targets a private/internal address
|
|
51
|
+
* @returns {Promise<boolean>} True if the URL targets a private/internal address
|
|
26
52
|
*/
|
|
27
|
-
export function isPrivateUrl(urlString) {
|
|
53
|
+
export async function isPrivateUrl(urlString) {
|
|
28
54
|
try {
|
|
29
55
|
const parsed = new URL(urlString);
|
|
30
56
|
const hostname = parsed.hostname;
|
|
31
57
|
|
|
58
|
+
// Block non-HTTP protocols
|
|
59
|
+
if (!["http:", "https:"].includes(parsed.protocol)) return true;
|
|
60
|
+
|
|
32
61
|
// Block known private hostnames
|
|
33
|
-
if (BLOCKED_HOSTNAMES.has(hostname))
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
62
|
+
if (BLOCKED_HOSTNAMES.has(hostname)) return true;
|
|
36
63
|
|
|
37
64
|
// Block IPv6 loopback
|
|
38
|
-
if (hostname === "::1" || hostname === "[::1]")
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
65
|
+
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
41
66
|
|
|
42
|
-
// Block private IPv4 ranges
|
|
43
|
-
|
|
44
|
-
if (hostname.startsWith(prefix)) {
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
67
|
+
// Block private IPv4 ranges (string check for literal IPs)
|
|
68
|
+
if (isPrivateIp(hostname)) return true;
|
|
48
69
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
// DNS resolution check — catches domains resolving to private IPs
|
|
71
|
+
try {
|
|
72
|
+
const { address } = await dns.lookup(hostname);
|
|
73
|
+
if (isPrivateIp(address)) return true;
|
|
74
|
+
} catch {
|
|
75
|
+
// DNS resolution failure — block as precaution
|
|
76
|
+
return true;
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
return false;
|
|
@@ -68,8 +89,8 @@ const ALLOWED_TYPES = new Set([
|
|
|
68
89
|
"image/png",
|
|
69
90
|
"image/gif",
|
|
70
91
|
"image/webp",
|
|
71
|
-
"image/svg+xml",
|
|
72
92
|
"image/avif",
|
|
93
|
+
// image/svg+xml intentionally excluded — SVGs can contain embedded JavaScript
|
|
73
94
|
]);
|
|
74
95
|
|
|
75
96
|
/**
|
|
@@ -81,6 +102,34 @@ export function hashUrl(url) {
|
|
|
81
102
|
return crypto.createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Generate HMAC signature for a media proxy URL
|
|
107
|
+
* @param {string} url - Original image URL
|
|
108
|
+
* @returns {string} HMAC hex signature (16 chars)
|
|
109
|
+
*/
|
|
110
|
+
export function signProxyUrl(url) {
|
|
111
|
+
const secret = process.env.SECRET || "microsub-default-secret";
|
|
112
|
+
return crypto
|
|
113
|
+
.createHmac("sha256", secret)
|
|
114
|
+
.update(url)
|
|
115
|
+
.digest("hex")
|
|
116
|
+
.slice(0, 16);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify HMAC signature for a media proxy URL
|
|
121
|
+
* @param {string} url - Original image URL
|
|
122
|
+
* @param {string} sig - Submitted signature
|
|
123
|
+
* @returns {boolean} Whether signature is valid
|
|
124
|
+
*/
|
|
125
|
+
export function verifyProxySignature(url, sig) {
|
|
126
|
+
if (!sig) return false;
|
|
127
|
+
const expected = signProxyUrl(url);
|
|
128
|
+
// Constant-time comparison
|
|
129
|
+
if (sig.length !== expected.length) return false;
|
|
130
|
+
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
|
131
|
+
}
|
|
132
|
+
|
|
84
133
|
/**
|
|
85
134
|
* Get the proxied URL for an image
|
|
86
135
|
* @param {string} baseUrl - Base URL of the Microsub endpoint
|
|
@@ -103,7 +152,8 @@ export function getProxiedUrl(baseUrl, originalUrl) {
|
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
const hash = hashUrl(originalUrl);
|
|
106
|
-
|
|
155
|
+
const sig = signProxyUrl(originalUrl);
|
|
156
|
+
return `${baseUrl}/microsub/media/${hash}?url=${encodeURIComponent(originalUrl)}&sig=${sig}`;
|
|
107
157
|
}
|
|
108
158
|
|
|
109
159
|
/**
|
|
@@ -155,7 +205,7 @@ export function proxyItemImages(item, baseUrl) {
|
|
|
155
205
|
*/
|
|
156
206
|
export async function fetchImage(redis, url) {
|
|
157
207
|
// Block private/internal URLs (defense-in-depth)
|
|
158
|
-
if (isPrivateUrl(url)) {
|
|
208
|
+
if (await isPrivateUrl(url)) {
|
|
159
209
|
console.error(`[Microsub] Media proxy blocked private URL: ${url}`);
|
|
160
210
|
return;
|
|
161
211
|
}
|
|
@@ -239,12 +289,17 @@ export async function fetchImage(redis, url) {
|
|
|
239
289
|
* @returns {Promise<void>}
|
|
240
290
|
*/
|
|
241
291
|
export async function handleMediaProxy(request, response) {
|
|
242
|
-
const { url } = request.query;
|
|
292
|
+
const { url, sig } = request.query;
|
|
243
293
|
|
|
244
294
|
if (!url) {
|
|
245
295
|
return response.status(400).send("Missing url parameter");
|
|
246
296
|
}
|
|
247
297
|
|
|
298
|
+
// Verify HMAC signature (prevents abuse as open proxy)
|
|
299
|
+
if (!verifyProxySignature(url, sig)) {
|
|
300
|
+
return response.status(403).send("Invalid proxy signature");
|
|
301
|
+
}
|
|
302
|
+
|
|
248
303
|
// Validate URL
|
|
249
304
|
try {
|
|
250
305
|
const parsed = new URL(url);
|
|
@@ -256,7 +311,7 @@ export async function handleMediaProxy(request, response) {
|
|
|
256
311
|
}
|
|
257
312
|
|
|
258
313
|
// Block requests to private/internal networks (SSRF protection)
|
|
259
|
-
if (isPrivateUrl(url)) {
|
|
314
|
+
if (await isPrivateUrl(url)) {
|
|
260
315
|
return response.status(403).send("URL not allowed");
|
|
261
316
|
}
|
|
262
317
|
|
package/lib/polling/processor.js
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* @module polling/processor
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const FEED_PROCESS_TIMEOUT = 60_000; // 60 seconds max per feed
|
|
7
|
+
const MAX_ITEMS_PER_CYCLE = 100; // Max items to process per feed per cycle
|
|
8
|
+
|
|
6
9
|
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
7
10
|
import { detectCapabilities } from "../feeds/capabilities.js";
|
|
8
11
|
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
|
9
|
-
import {
|
|
12
|
+
import { getChannelById } from "../storage/channels.js";
|
|
10
13
|
import {
|
|
11
14
|
updateFeed,
|
|
12
15
|
updateFeedAfterFetch,
|
|
@@ -69,11 +72,14 @@ export async function processFeed(application, feed) {
|
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
// Get channel for filtering
|
|
72
|
-
const channel = await
|
|
75
|
+
const channel = await getChannelById(application, feed.channelId);
|
|
73
76
|
|
|
74
|
-
// Process items
|
|
77
|
+
// Process items (limited to MAX_ITEMS_PER_CYCLE per feed per cycle)
|
|
75
78
|
let newItemCount = 0;
|
|
79
|
+
let processedCount = 0;
|
|
76
80
|
for (const item of parsed.items) {
|
|
81
|
+
if (processedCount >= MAX_ITEMS_PER_CYCLE) break;
|
|
82
|
+
processedCount++;
|
|
77
83
|
// Apply channel filters
|
|
78
84
|
if (channel?.settings && !passesFilters(item, channel.settings)) {
|
|
79
85
|
continue;
|
|
@@ -276,7 +282,24 @@ export async function processFeedBatch(application, feeds, options = {}) {
|
|
|
276
282
|
for (let index = 0; index < feeds.length; index += concurrency) {
|
|
277
283
|
const batch = feeds.slice(index, index + concurrency);
|
|
278
284
|
const batchResults = await Promise.all(
|
|
279
|
-
batch.map((feed) =>
|
|
285
|
+
batch.map((feed) =>
|
|
286
|
+
Promise.race([
|
|
287
|
+
processFeed(application, feed),
|
|
288
|
+
new Promise((resolve) =>
|
|
289
|
+
setTimeout(
|
|
290
|
+
() =>
|
|
291
|
+
resolve({
|
|
292
|
+
feedId: feed._id,
|
|
293
|
+
url: feed.url,
|
|
294
|
+
success: false,
|
|
295
|
+
itemsAdded: 0,
|
|
296
|
+
error: "Feed processing timeout",
|
|
297
|
+
}),
|
|
298
|
+
FEED_PROCESS_TIMEOUT,
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
]),
|
|
302
|
+
),
|
|
280
303
|
);
|
|
281
304
|
results.push(...batchResults);
|
|
282
305
|
}
|
package/lib/polling/scheduler.js
CHANGED
|
@@ -7,6 +7,8 @@ import { getFeedsToFetch } from "../storage/feeds.js";
|
|
|
7
7
|
|
|
8
8
|
import { processFeedBatch } from "./processor.js";
|
|
9
9
|
|
|
10
|
+
// TODO: Refactor scheduler to a class that accepts `application` as constructor
|
|
11
|
+
// argument. Module-level singletons prevent unit testing and multiple instances.
|
|
10
12
|
let schedulerInterval;
|
|
11
13
|
let indiekitInstance;
|
|
12
14
|
let isRunning = false;
|
package/lib/realtime/broker.js
CHANGED
|
@@ -62,7 +62,12 @@ export function removeClient(response) {
|
|
|
62
62
|
(c) => c.userId === client.userId,
|
|
63
63
|
);
|
|
64
64
|
if (!hasOtherClients) {
|
|
65
|
-
//
|
|
65
|
+
// Clean up Redis subscriber connection for this user
|
|
66
|
+
const subscriber = userSubscribers.get(client.userId);
|
|
67
|
+
if (subscriber) {
|
|
68
|
+
subscriber.quit().catch(() => {});
|
|
69
|
+
userSubscribers.delete(client.userId);
|
|
70
|
+
}
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
}
|