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
|
// 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.)
|
||||||
|
|||||||
Reference in New Issue
Block a user