diff --git a/lib/federation-setup.js b/lib/federation-setup.js index 04f3894..81ab27b 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -153,10 +153,10 @@ export function setupFederation(options) { // Mastodon retries failed deliveries with the original signature, which // can be hours old by the time the delivery succeeds. signatureTimeWindow: { hours: 12 }, - // Allow fetching own-site URLs that resolve to private IPs (e.g. when - // the blog hostname resolves to a RFC-1918 address on the local LAN). - // Without this, Fedify's SSRF guard blocks lookupObject() and WebFinger - // calls for own-site posts, producing errors in the activity log. + // Required: the blog hostname (svemagie.net) resolves to a RFC-1918 address + // on the local LAN. Without this, Fedify blocks lookupObject() calls for + // own-site posts. SSRF from external input is mitigated at the input + // boundaries (profile.remote.js, followController) via scheme + host validation. allowPrivateAddress: true, }); diff --git a/lib/lookup-helpers.js b/lib/lookup-helpers.js index c542fc5..3484e9e 100644 --- a/lib/lookup-helpers.js +++ b/lib/lookup-helpers.js @@ -35,8 +35,11 @@ export async function lookupWithSecurity(ctx, input, options = {}) { } // If signed lookup failed and we used a custom documentLoader, - // retry without it (unsigned GET) + // retry without it (unsigned GET). Log the downgrade so operators can detect + // key misconfigurations before they silently open unauth fetch paths. if (!result && options.documentLoader) { + const inputStr = typeof input === "string" ? input : input?.href || String(input); + console.debug(`[ActivityPub] Signed lookup failed for ${inputStr} — retrying unsigned`); try { const { documentLoader: _, ...unsignedOptions } = baseOptions; result = await ctx.lookupObject(input, unsignedOptions); diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 1cb4dd3..23f6129 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -174,12 +174,14 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const baseUrl = `${req.protocol}://${req.get("host")}`; - // Idempotency-Key support — prevent duplicate posts on client retry + // Idempotency-Key support — prevent duplicate posts on client retry. + // Scope key per token so two different authenticated users can't share responses. const idempotencyKey = req.headers["idempotency-key"]; + const _tokenScope = (req.mastodonToken?.accessToken || req.mastodonToken?._id?.toString() || "").slice(0, 16); if (idempotencyKey && collections.ap_idempotency) { const { createHash } = await import("node:crypto"); const key = createHash("sha256") - .update(`${baseUrl}:${idempotencyKey}`) + .update(`${_tokenScope}:${baseUrl}:${idempotencyKey}`) .digest("hex"); const cached = await collections.ap_idempotency.findOne({ key }); if (cached) { @@ -305,7 +307,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta if (idempotencyKey && collections.ap_idempotency) { const { createHash } = await import("node:crypto"); - const _spKey = createHash("sha256").update(`${baseUrl}:${idempotencyKey}`).digest("hex"); + const _spKey = createHash("sha256").update(`${_tokenScope}:${baseUrl}:${idempotencyKey}`).digest("hex"); await collections.ap_idempotency.insertOne({ key: _spKey, response: _spStatus, createdAt: new Date() }).catch(() => {}); } @@ -653,7 +655,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta if (idempotencyKey && collections.ap_idempotency) { const { createHash } = await import("node:crypto"); const key = createHash("sha256") - .update(`${baseUrl}:${idempotencyKey}`) + .update(`${_tokenScope}:${baseUrl}:${idempotencyKey}`) .digest("hex"); await collections.ap_idempotency .insertOne({ key, response: statusResponse, createdAt: new Date() }) diff --git a/lib/outbox-backfill.js b/lib/outbox-backfill.js index c6e3fda..c0379c2 100644 --- a/lib/outbox-backfill.js +++ b/lib/outbox-backfill.js @@ -26,25 +26,33 @@ import { addTimelineItem } from "./storage/timeline.js"; export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, collections, limit = 20 }) { if (!collections?.ap_timeline) return; - try { - const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); + const withTimeout = (p, ms = 8000) => + Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]); - // Fetch outbox collection - const outbox = await remoteActor.getOutbox({ documentLoader }).catch(() => null); + try { + const documentLoader = await withTimeout(ctx.getDocumentLoader({ identifier: handle })); + + // Fetch outbox collection — cap at 8 s + const outbox = await withTimeout(remoteActor.getOutbox({ documentLoader })).catch(() => null); if (!outbox) return; - // Collect activity items — try direct items first, then first-page + // Guard: if outbox reports more than 500 items without pagination, skip the direct-items path + // to avoid materialising a huge in-memory array from a hostile server. + const totalItems = typeof outbox.totalItems === "number" ? outbox.totalItems : null; + + // Collect activity items — try direct items first (only for small collections), then first-page const activities = []; let itemSource = null; - try { - // Some servers return a directly-iterable collection without pagination - const direct = outbox.getItems?.({ documentLoader }); - if (direct) itemSource = direct; - } catch { /* not directly iterable */ } + if (totalItems === null || totalItems <= 100) { + try { + const direct = outbox.getItems?.({ documentLoader }); + if (direct) itemSource = direct; + } catch { /* not directly iterable */ } + } if (!itemSource) { // Paginated — navigate to first page - const firstPage = await outbox.getFirst({ documentLoader }).catch(() => null); + const firstPage = await withTimeout(outbox.getFirst({ documentLoader })).catch(() => null); if (firstPage?.getItems) itemSource = firstPage.getItems({ documentLoader }); } @@ -55,8 +63,8 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c activities.push(item); if (activities.length >= limit) break; } - } catch { - // Partial read is fine — use what we got + } catch (iterErr) { + console.debug(`[ActivityPub] Outbox backfill iterator stopped for ${actorUrl}: ${iterErr?.message}`); } if (activities.length === 0) return; @@ -69,9 +77,9 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c let boostedBy = null; if (activity instanceof Create) { - object = await activity.getObject({ documentLoader }).catch(() => null); + object = await withTimeout(activity.getObject({ documentLoader }), 5000).catch(() => null); } else if (activity instanceof Announce) { - object = await activity.getObject({ documentLoader }).catch(() => null); + object = await withTimeout(activity.getObject({ documentLoader }), 5000).catch(() => null); if (object) { const _username = remoteActor.preferredUsername?.toString() || ""; const _domain = (() => { try { return new URL(actorUrl).hostname; } catch { return ""; } })(); @@ -107,8 +115,8 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c await addTimelineItem({ ap_timeline: collections.ap_timeline }, item); stored++; - } catch { - // Skip individual item errors — keep going + } catch (itemErr) { + console.debug(`[ActivityPub] Outbox backfill skipped item from ${actorUrl}: ${itemErr?.message}`); } } diff --git a/lib/timeline-store.js b/lib/timeline-store.js index 962c1e9..0e1f3d1 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -235,7 +235,16 @@ export async function extractObjectData(object, options = {}) { try { if (typeof object.getAttributedTo === "function") { const attr = await object.getAttributedTo(loaderOpts); - authorObj = Array.isArray(attr) ? attr[0] : attr; + const candidate = Array.isArray(attr) ? attr[0] : attr; + // If a trusted actorFallback is provided, only accept the resolved author + // if it shares the same origin — prevents forged attributedTo impersonation. + if (candidate && options.actorFallback) { + const candidateHost = (() => { try { return new URL(candidate.id?.href || "").hostname; } catch { return null; } })(); + const fallbackHost = (() => { try { return new URL(options.actorFallback.id?.href || "").hostname; } catch { return null; } })(); + authorObj = (candidateHost && fallbackHost && candidateHost === fallbackHost) ? candidate : options.actorFallback; + } else { + authorObj = candidate; + } } } catch { // getAttributedTo() failed (unreachable, deleted, etc.)