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
// 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,
});
+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,
// 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);
+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 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() })
+21 -13
View File
@@ -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;
if (totalItems === null || totalItems <= 100) {
try {
// Some servers return a directly-iterable collection without pagination
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}`);
}
}
+10 -1
View File
@@ -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.)