diff --git a/docs/changelog.md b/docs/changelog.md index 4edb1f2446..b5a66b7680 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,9 +5,10 @@ Critical security updates are indicated by the :warning: icon. - Server administrators should check [notice-for-admins.md](./notice-for-admins.md) as well. - Third-party client/bot developers may want to check [api-change.md](./api-change.md) as well. -## Unreleased +## :warning: Unreleased -- Fix bugs +- Fix bugs (including a critical security issue) + - We are very thankful to Oneric (the reporter of the security issue) and Laura Hausmann (Iceshrimp maintainer) for kindly and securely sharing the information to fix the issue. ## [v20240326](https://firefish.dev/firefish/firefish/-/merge_requests/10713/commits) diff --git a/packages/backend/src/misc/download-url.ts b/packages/backend/src/misc/download-url.ts index 83680c175f..ab04e8aa9c 100644 --- a/packages/backend/src/misc/download-url.ts +++ b/packages/backend/src/misc/download-url.ts @@ -8,10 +8,15 @@ import chalk from "chalk"; import Logger from "@/services/logger.js"; import IPCIDR from "ip-cidr"; import PrivateIp from "private-ip"; +import { isValidUrl } from "./is-valid-url.js"; const pipeline = util.promisify(stream.pipeline); export async function downloadUrl(url: string, path: string): Promise<void> { + if (!isValidUrl(url)) { + throw new StatusError("Invalid URL", 400); + } + const logger = new Logger("download"); logger.info(`Downloading ${chalk.cyan(url)} ...`); @@ -44,6 +49,12 @@ export async function downloadUrl(url: string, path: string): Promise<void> { limit: 0, }, }) + .on("redirect", (res: Got.Response, opts: Got.NormalizedOptions) => { + if (!isValidUrl(opts.url)) { + logger.warn(`Invalid URL: ${opts.url}`); + req.destroy(); + } + }) .on("response", (res: Got.Response) => { if ( (process.env.NODE_ENV === "production" || diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts index 75eb2e17f4..8108b13d9e 100644 --- a/packages/backend/src/misc/fetch.ts +++ b/packages/backend/src/misc/fetch.ts @@ -2,9 +2,10 @@ import * as http from "node:http"; import * as https from "node:https"; import type { URL } from "node:url"; import CacheableLookup from "cacheable-lookup"; -import fetch from "node-fetch"; +import fetch, { RequestRedirect } from "node-fetch"; import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; import config from "@/config/index.js"; +import { isValidUrl } from "./is-valid-url.js"; export async function getJson( url: string, @@ -57,7 +58,12 @@ export async function getResponse(args: { headers: Record<string, string>; timeout?: number; size?: number; + redirect?: RequestRedirect; }) { + if (!isValidUrl(args.url)) { + throw new StatusError("Invalid URL", 400); + } + const timeout = args.timeout || 10 * 1000; const controller = new AbortController(); @@ -73,8 +79,16 @@ export async function getResponse(args: { size: args.size || 10 * 1024 * 1024, agent: getAgentByUrl, signal: controller.signal, + redirect: args.redirect, }); + if (args.redirect === "manual" && [301, 302, 307, 308].includes(res.status)) { + if (!isValidUrl(res.url)) { + throw new StatusError("Invalid URL", 400); + } + return res; + } + if (!res.ok) { throw new StatusError( `${res.status} ${res.statusText}`, diff --git a/packages/backend/src/misc/is-valid-url.ts b/packages/backend/src/misc/is-valid-url.ts new file mode 100644 index 0000000000..5aebefcb71 --- /dev/null +++ b/packages/backend/src/misc/is-valid-url.ts @@ -0,0 +1,20 @@ +export function isValidUrl(url: string | URL | undefined): boolean { + if (process.env.NODE_ENV !== "production") return true; + + try { + if (url == null) return false; + + const u = typeof url === "string" ? new URL(url) : url; + if (!u.protocol.match(/^https?:$/) || u.hostname === "unix") { + return false; + } + + if (u.port !== "" && !["80", "443"].includes(u.port)) { + return false; + } + + return true; + } catch { + return false; + } +} diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts index 9fc2a8115c..f6d33b8549 100644 --- a/packages/backend/src/remote/activitypub/request.ts +++ b/packages/backend/src/remote/activitypub/request.ts @@ -1,10 +1,12 @@ import config from "@/config/index.js"; import { getUserKeypair } from "@/misc/keypair-store.js"; import type { User, ILocalUser } from "@/models/entities/user.js"; -import { getResponse } from "@/misc/fetch.js"; +import { StatusError, getResponse } from "@/misc/fetch.js"; import { createSignedPost, createSignedGet } from "./ap-request.js"; import type { Response } from "node-fetch"; import type { IObject } from "./type.js"; +import { isValidUrl } from "@/misc/is-valid-url.js"; +import { apLogger } from "@/remote/activitypub/logger.js"; export default async (user: { id: User["id"] }, url: string, object: any) => { const body = JSON.stringify(object); @@ -33,10 +35,19 @@ export default async (user: { id: User["id"] }, url: string, object: any) => { /** * Get ActivityPub object - * @param user http-signature user * @param url URL to fetch + * @param user http-signature user + * @param redirects whether or not to accept redirects */ -export async function apGet(url: string, user?: ILocalUser): Promise<IObject> { +export async function apGet( + url: string, + user?: ILocalUser, + redirects: boolean = true, +): Promise<{ finalUrl: string; content: IObject }> { + if (!isValidUrl(url)) { + throw new StatusError("Invalid URL", 400); + } + let res: Response; if (user != null) { @@ -56,7 +67,16 @@ export async function apGet(url: string, user?: ILocalUser): Promise<IObject> { url, method: req.request.method, headers: req.request.headers, + redirect: redirects ? "manual" : "error", }); + + if (redirects && [301, 302, 307, 308].includes(res.status)) { + const newUrl = res.headers.get("location"); + if (newUrl == null) + throw new Error("apGet got redirect but no target location"); + apLogger.debug(`apGet is redirecting to ${newUrl}`); + return apGet(newUrl, user, false); + } } else { res = await getResponse({ url, @@ -66,12 +86,23 @@ export async function apGet(url: string, user?: ILocalUser): Promise<IObject> { 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', "User-Agent": config.userAgent, }, + redirect: redirects ? "manual" : "error", }); + + if (redirects && [301, 302, 307, 308].includes(res.status)) { + const newUrl = res.headers.get("location"); + if (newUrl == null) + throw new Error("apGet got redirect but no target location"); + apLogger.debug(`apGet is redirecting to ${newUrl}`); + return apGet(newUrl, undefined, false); + } } const contentType = res.headers.get("content-type"); if (contentType == null || !validateContentType(contentType)) { - throw new Error("Invalid Content Type"); + throw new Error( + `apGet response had unexpected content-type: ${contentType}`, + ); } if (res.body == null) throw new Error("body is null"); @@ -79,7 +110,10 @@ export async function apGet(url: string, user?: ILocalUser): Promise<IObject> { const text = await res.text(); if (text.length > 65536) throw new Error("too big result"); - return JSON.parse(text) as IObject; + return { + finalUrl: res.url, + content: JSON.parse(text) as IObject, + }; } function validateContentType(contentType: string): boolean { diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts index 7ddb4b8022..7c0bd6e102 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -6,7 +6,13 @@ import { extractDbHost, isSelfHost } from "@/misc/convert-host.js"; import { apGet } from "./request.js"; import type { IObject, ICollection, IOrderedCollection } from "./type.js"; import { isCollectionOrOrderedCollection, getApId } from "./type.js"; -import { Notes, NoteReactions, Polls, Users } from "@/models/index.js"; +import { + FollowRequests, + Notes, + NoteReactions, + Polls, + Users, +} from "@/models/index.js"; import { parseUri } from "./db-resolver.js"; import renderNote from "@/remote/activitypub/renderer/note.js"; import { renderLike } from "@/remote/activitypub/renderer/like.js"; @@ -17,7 +23,7 @@ import { renderActivity } from "@/remote/activitypub/renderer/index.js"; import renderFollow from "@/remote/activitypub/renderer/follow.js"; import { shouldBlockInstance } from "@/misc/should-block-instance.js"; import { apLogger } from "@/remote/activitypub/logger.js"; -import { In, IsNull, Not } from "typeorm"; +import { IsNull, Not } from "typeorm"; export default class Resolver { private history: Set<string>; @@ -114,7 +120,7 @@ export default class Resolver { apLogger.debug("Getting object from remote, authenticated as user:"); apLogger.debug(JSON.stringify(this.user, null, 2)); - const object = await apGet(value, this.user); + const { finalUrl, content: object } = await apGet(value, this.user); if ( object == null || @@ -127,61 +133,73 @@ export default class Resolver { throw new Error("invalid response"); } - return object; + if (object.id == null) { + throw new Error("Object has no ID"); + } + + if (finalUrl === object.id) return object; + + if (new URL(finalUrl).host !== new URL(object.id).host) { + throw new Error("Object ID host doesn't match final url host"); + } + + const finalRes = await apGet(object.id, this.user); + + if (finalRes.finalUrl !== finalRes.content.id) + throw new Error( + "Object ID still doesn't match final URL after second fetch attempt", + ); + + return finalRes.content; } - private resolveLocal(url: string): Promise<IObject> { + private async resolveLocal(url: string): Promise<IObject> { const parsed = parseUri(url); if (!parsed.local) throw new Error("resolveLocal: not local"); switch (parsed.type) { case "notes": - return Notes.findOneByOrFail({ id: parsed.id }).then((note) => { - if (parsed.rest === "activity") { - // this refers to the create activity and not the note itself - return renderActivity(renderCreate(renderNote(note), note)); - } else { - return renderNote(note); - } - }); + const note = await Notes.findOneByOrFail({ id: parsed.id }); + if (parsed.rest === "activity") { + // this refers to the create activity and not the note itself + return renderActivity(renderCreate(renderNote(note), note)); + } else { + return renderNote(note); + } case "users": - return Users.findOneByOrFail({ id: parsed.id }).then((user) => - renderPerson(user as ILocalUser), - ); + const user = await Users.findOneByOrFail({ id: parsed.id }); + return await renderPerson(user as ILocalUser); case "questions": // Polls are indexed by the note they are attached to. - return Promise.all([ + const [pollNote, poll] = await Promise.all([ Notes.findOneByOrFail({ id: parsed.id }), Polls.findOneByOrFail({ noteId: parsed.id }), - ]).then(([note, poll]) => - renderQuestion({ id: note.userId }, note, poll), - ); + ]); + return await renderQuestion({ id: pollNote.userId }, pollNote, poll); case "likes": - return NoteReactions.findOneByOrFail({ id: parsed.id }).then( - (reaction) => renderActivity(renderLike(reaction, { uri: null })), - ); + const reaction = await NoteReactions.findOneByOrFail({ id: parsed.id }); + return renderActivity(renderLike(reaction, { uri: null })); case "follows": // if rest is a <followee id> if (parsed.rest != null && /^\w+$/.test(parsed.rest)) { - return Promise.all( + const [follower, followee] = await Promise.all( [parsed.id, parsed.rest].map((id) => Users.findOneByOrFail({ id })), - ).then(([follower, followee]) => - renderActivity(renderFollow(follower, followee, url)), ); + return renderActivity(renderFollow(follower, followee, url)); } // Another situation is there is only requestId, then obtained object from database. - const followRequest = FollowRequests.findOneBy({ + const followRequest = await FollowRequests.findOneBy({ id: parsed.id, }); if (followRequest == null) { throw new Error("resolveLocal: invalid follow URI"); } - const follower = Users.findOneBy({ + const follower = await Users.findOneBy({ id: followRequest.followerId, host: IsNull(), }); - const followee = Users.findOneBy({ + const followee = await Users.findOneBy({ id: followRequest.followeeId, host: Not(IsNull()), }); @@ -190,7 +208,7 @@ export default class Resolver { } return renderActivity(renderFollow(follower, followee, url)); default: - throw new Error(`resolveLocal: type ${type} unhandled`); + throw new Error(`resolveLocal: type ${parsed.type} unhandled`); } } }