From 0387bbc0e2b4d1b133137b85153cbb08ee4398a2 Mon Sep 17 00:00:00 2001
From: svemagie <869694+svemagie@users.noreply.github.com>
Date: Sun, 17 May 2026 10:05:36 +0200
Subject: [PATCH] fix: backfill actor avatar/handle, URL scheme validation,
visibility validation, short-post dereferenceable, autolink before escape
---
lib/controllers/profile.remote.js | 28 +++++++++++++---
lib/federation-setup.js | 55 +++++++++++++++++++++----------
lib/mastodon/routes/statuses.js | 19 ++++++++---
lib/outbox-backfill.js | 11 ++++---
4 files changed, 84 insertions(+), 29 deletions(-)
diff --git a/lib/controllers/profile.remote.js b/lib/controllers/profile.remote.js
index 9a99fb5..803ac67 100644
--- a/lib/controllers/profile.remote.js
+++ b/lib/controllers/profile.remote.js
@@ -24,6 +24,20 @@ export function remoteProfileController(mountPath, plugin) {
});
}
+ // Reject non-http(s) schemes and loopback/private-range hosts
+ try {
+ const _parsed = new URL(actorUrl);
+ if (!["http:", "https:"].includes(_parsed.protocol)) {
+ return response.status(400).render("error", { title: "Error", content: "Invalid actor URL" });
+ }
+ const _h = _parsed.hostname;
+ if (_h === "localhost" || _h === "127.0.0.1" || _h === "::1" || /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(_h)) {
+ return response.status(400).render("error", { title: "Error", content: "Invalid actor URL" });
+ }
+ } catch {
+ return response.status(400).render("error", { title: "Error", content: "Invalid actor URL" });
+ }
+
if (!plugin._federation) {
return response.status(503).render("error", {
title: "Error",
@@ -162,10 +176,16 @@ export function followController(mountPath, plugin) {
const { url } = request.body;
if (!url) {
- return response.status(400).json({
- success: false,
- error: "Missing actor URL",
- });
+ return response.status(400).json({ success: false, error: "Missing actor URL" });
+ }
+
+ try {
+ const _p = new URL(url);
+ if (!["http:", "https:"].includes(_p.protocol)) throw new Error("scheme");
+ const _h = _p.hostname;
+ if (_h === "localhost" || _h === "127.0.0.1" || _h === "::1" || /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(_h)) throw new Error("private");
+ } catch {
+ return response.status(400).json({ success: false, error: "Invalid actor URL" });
}
const result = await plugin.followActor(url);
diff --git a/lib/federation-setup.js b/lib/federation-setup.js
index bb3c73c..04f3894 100644
--- a/lib/federation-setup.js
+++ b/lib/federation-setup.js
@@ -792,25 +792,46 @@ function setupOutbox(federation, mountPath, handle, collections) {
}
function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) {
- // Shared lookup: find post by URL path, convert to Fedify Note/Article
+ // Shared lookup: find post by URL path, convert to Fedify Note/Article.
+ // Falls back to ap_timeline for short posts (UUIDs) that bypass the Micropub pipeline.
async function resolvePost(ctx, id) {
- if (!collections.posts || !publicationUrl) return null;
+ if (!publicationUrl) return null;
const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
- // Match with or without trailing slash — AP object URLs omit the slash
- // but posts are stored with one, so an exact match would fail.
- const post = await collections.posts.findOne({
- "properties.url": { $in: [postUrl, postUrl + "/"] },
- });
- if (!post) return null;
- if (post?.properties?.["post-status"] === "draft") return null;
- if (post?.properties?.visibility === "unlisted") return null;
- // Soft-deleted posts should not be dereferenceable
- if (post.properties?.deleted) return null;
- const actorUrl = ctx.getActorUri(handle).href;
- const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
- // Only Create activities wrap Note/Article objects
- if (!(activity instanceof Create)) return null;
- return await activity.getObject();
+
+ // Primary: look up in posts collection (Micropub pipeline posts)
+ if (collections.posts) {
+ const post = await collections.posts.findOne({
+ "properties.url": { $in: [postUrl, postUrl + "/"] },
+ });
+ if (post) {
+ if (post?.properties?.["post-status"] === "draft") return null;
+ if (post?.properties?.visibility === "unlisted") return null;
+ if (post.properties?.deleted) return null;
+ const actorUrl = ctx.getActorUri(handle).href;
+ const activity = await jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
+ if (!(activity instanceof Create)) return null;
+ return await activity.getObject();
+ }
+ }
+
+ // Fallback: ap_timeline for short posts (UUID-based canonical URLs)
+ if (collections.ap_timeline) {
+ const item = await collections.ap_timeline.findOne({
+ uid: postUrl,
+ "author.url": { $regex: new RegExp(`^${publicationUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\/+$/, "")}`) },
+ });
+ if (item && item.content) {
+ const actorUri = ctx.getActorUri(handle);
+ return new Note({
+ id: new URL(postUrl),
+ attributedTo: actorUri,
+ content: item.content.html || item.content.text || "",
+ published: item.published ? new Date(item.published) : undefined,
+ });
+ }
+ }
+
+ return null;
}
// Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin
diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js
index a0132e8..1cb4dd3 100644
--- a/lib/mastodon/routes/statuses.js
+++ b/lib/mastodon/routes/statuses.js
@@ -201,6 +201,11 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
return res.status(422).json({ error: "Validation failed: Text content is required" });
}
+ const VALID_VISIBILITIES = ["public", "unlisted", "private", "direct"];
+ if (visibility && !VALID_VISIBILITIES.includes(visibility)) {
+ return res.status(422).json({ error: "Validation failed: Invalid visibility" });
+ }
+
// Short standalone posts (≤ 280 chars, no media, no reply, not a DM) skip
// the Micropub pipeline — they federate directly without creating a blog entry.
const _isShortPost =
@@ -224,11 +229,17 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
const _spNow = new Date().toISOString();
const _spText = (statusText || "").trim();
+ // Auto-link URLs before escaping so the href is the raw URL,
+ // then escape only the text segments (not the generated tags).
const _spHtml = _spText
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/(https?:\/\/[^\s<>&"')]+)/g, '$1')
+ .replace(/(https?:\/\/[^\s<>"')]+)/g, "\x00$1\x00")
+ .split("\x00")
+ .map((seg, i) =>
+ i % 2 === 1
+ ? `${seg.replace(/&/g, "&").replace(//g, ">")}`
+ : seg.replace(/&/g, "&").replace(//g, ">"),
+ )
+ .join("")
.replace(/\n/g, "
");
const _spTlItem = await addTimelineItem(collections, {
diff --git a/lib/outbox-backfill.js b/lib/outbox-backfill.js
index 7722e67..c6e3fda 100644
--- a/lib/outbox-backfill.js
+++ b/lib/outbox-backfill.js
@@ -73,12 +73,15 @@ export async function backfillFromOutbox({ actorUrl, remoteActor, ctx, handle, c
} else if (activity instanceof Announce) {
object = await activity.getObject({ documentLoader }).catch(() => null);
if (object) {
- // For boosts: actor info comes from the remote actor we just followed
+ const _username = remoteActor.preferredUsername?.toString() || "";
+ const _domain = (() => { try { return new URL(actorUrl).hostname; } catch { return ""; } })();
+ let _photo = "";
+ try { const _icon = await remoteActor.getIcon(); _photo = _icon?.url?.href || ""; } catch { /* ignore */ }
boostedBy = {
- name: remoteActor.name?.toString() || remoteActor.preferredUsername?.toString() || "",
+ name: remoteActor.name?.toString() || _username || "",
url: actorUrl,
- photo: "",
- handle: "",
+ photo: _photo,
+ handle: _username && _domain ? `@${_username}@${_domain}` : "",
};
}
}