security: fix H1 attributedTo origin check, H2 outbox timeouts+size guard, M3 idempotency token-scoped, I1/I2 debug logging

This commit is contained in:
svemagie
2026-05-17 10:11:23 +02:00
parent 0387bbc0e2
commit d5119e6ba6
5 changed files with 49 additions and 27 deletions
+4 -4
View File
@@ -153,10 +153,10 @@ export function setupFederation(options) {
// Mastodon retries failed deliveries with the original signature, which // Mastodon retries failed deliveries with the original signature, which
// can be hours old by the time the delivery succeeds. // can be hours old by the time the delivery succeeds.
signatureTimeWindow: { hours: 12 }, signatureTimeWindow: { hours: 12 },
// Allow fetching own-site URLs that resolve to private IPs (e.g. when // Required: the blog hostname (svemagie.net) resolves to a RFC-1918 address
// the blog hostname resolves to a RFC-1918 address on the local LAN). // on the local LAN. Without this, Fedify blocks lookupObject() calls for
// Without this, Fedify's SSRF guard blocks lookupObject() and WebFinger // own-site posts. SSRF from external input is mitigated at the input
// calls for own-site posts, producing errors in the activity log. // boundaries (profile.remote.js, followController) via scheme + host validation.
allowPrivateAddress: true, allowPrivateAddress: true,
}); });
+4 -1
View File
@@ -35,8 +35,11 @@ export async function lookupWithSecurity(ctx, input, options = {}) {
} }
// If signed lookup failed and we used a custom documentLoader, // 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) { 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 { try {
const { documentLoader: _, ...unsignedOptions } = baseOptions; const { documentLoader: _, ...unsignedOptions } = baseOptions;
result = await ctx.lookupObject(input, unsignedOptions); result = await ctx.lookupObject(input, unsignedOptions);
+6 -4
View File
@@ -174,12 +174,14 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const pluginOptions = req.app.locals.mastodonPluginOptions || {};
const baseUrl = `${req.protocol}://${req.get("host")}`; 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 idempotencyKey = req.headers["idempotency-key"];
const _tokenScope = (req.mastodonToken?.accessToken || req.mastodonToken?._id?.toString() || "").slice(0, 16);
if (idempotencyKey && collections.ap_idempotency) { if (idempotencyKey && collections.ap_idempotency) {
const { createHash } = await import("node:crypto"); const { createHash } = await import("node:crypto");
const key = createHash("sha256") const key = createHash("sha256")
.update(`${baseUrl}:${idempotencyKey}`) .update(`${_tokenScope}:${baseUrl}:${idempotencyKey}`)
.digest("hex"); .digest("hex");
const cached = await collections.ap_idempotency.findOne({ key }); const cached = await collections.ap_idempotency.findOne({ key });
if (cached) { if (cached) {
@@ -305,7 +307,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
if (idempotencyKey && collections.ap_idempotency) { if (idempotencyKey && collections.ap_idempotency) {
const { createHash } = await import("node:crypto"); 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(() => {}); 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) { if (idempotencyKey && collections.ap_idempotency) {
const { createHash } = await import("node:crypto"); const { createHash } = await import("node:crypto");
const key = createHash("sha256") const key = createHash("sha256")
.update(`${baseUrl}:${idempotencyKey}`) .update(`${_tokenScope}:${baseUrl}:${idempotencyKey}`)
.digest("hex"); .digest("hex");
await collections.ap_idempotency await collections.ap_idempotency
.insertOne({ key, response: statusResponse, createdAt: new Date() }) .insertOne({ key, response: statusResponse, createdAt: new Date() })
+25 -17
View File
@@ -26,25 +26,33 @@ import { addTimelineItem } from "./storage/timeline.js";
export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, collections, limit = 20 }) { export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, collections, limit = 20 }) {
if (!collections?.ap_timeline) return; if (!collections?.ap_timeline) return;
try { const withTimeout = (p, ms = 8000) =>
const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]);
// Fetch outbox collection try {
const outbox = await remoteActor.getOutbox({ documentLoader }).catch(() => null); 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; 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 = []; const activities = [];
let itemSource = null; let itemSource = null;
try { if (totalItems === null || totalItems <= 100) {
// Some servers return a directly-iterable collection without pagination try {
const direct = outbox.getItems?.({ documentLoader }); const direct = outbox.getItems?.({ documentLoader });
if (direct) itemSource = direct; if (direct) itemSource = direct;
} catch { /* not directly iterable */ } } catch { /* not directly iterable */ }
}
if (!itemSource) { if (!itemSource) {
// Paginated — navigate to first page // 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 }); if (firstPage?.getItems) itemSource = firstPage.getItems({ documentLoader });
} }
@@ -55,8 +63,8 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c
activities.push(item); activities.push(item);
if (activities.length >= limit) break; if (activities.length >= limit) break;
} }
} catch { } catch (iterErr) {
// Partial read is fine — use what we got console.debug(`[ActivityPub] Outbox backfill iterator stopped for ${actorUrl}: ${iterErr?.message}`);
} }
if (activities.length === 0) return; if (activities.length === 0) return;
@@ -69,9 +77,9 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c
let boostedBy = null; let boostedBy = null;
if (activity instanceof Create) { 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) { } else if (activity instanceof Announce) {
object = await activity.getObject({ documentLoader }).catch(() => null); object = await withTimeout(activity.getObject({ documentLoader }), 5000).catch(() => null);
if (object) { if (object) {
const _username = remoteActor.preferredUsername?.toString() || ""; const _username = remoteActor.preferredUsername?.toString() || "";
const _domain = (() => { try { return new URL(actorUrl).hostname; } catch { return ""; } })(); 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); await addTimelineItem({ ap_timeline: collections.ap_timeline }, item);
stored++; stored++;
} catch { } catch (itemErr) {
// Skip individual item errors — keep going console.debug(`[ActivityPub] Outbox backfill skipped item from ${actorUrl}: ${itemErr?.message}`);
} }
} }
+10 -1
View File
@@ -235,7 +235,16 @@ export async function extractObjectData(object, options = {}) {
try { try {
if (typeof object.getAttributedTo === "function") { if (typeof object.getAttributedTo === "function") {
const attr = await object.getAttributedTo(loaderOpts); 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 { } catch {
// getAttributedTo() failed (unreachable, deleted, etc.) // getAttributedTo() failed (unreachable, deleted, etc.)