security: fix H1 attributedTo origin check, H2 outbox timeouts+size guard, M3 idempotency token-scoped, I1/I2 debug logging
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() })
|
||||
|
||||
+25
-17
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -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.)
|
||||
|
||||
Reference in New Issue
Block a user