import { URL } from 'node:url'; import config from '@/config/index.js'; import { getUserKeypair } from '@/misc/keypair-store.js'; import { User } from '@/models/entities/user.js'; import { UserKeypair } from '@/models/entities/user-keypair.js'; import { getResponse } from '@/misc/fetch.js'; import { getApId } from './type.js'; import { createSignedPost, createSignedGet } from './ap-request.js'; import { apRequestChart, federationChart, instanceChart } from '@/services/chart/index.js'; /** * Post an activity to an inbox. Automatically updates the statistics * on succeeded or failed delivery attempts. * * @param user http-signature user * @param url The URL of the inbox. * @param object The Activity or other object to be posted to the inbox. */ export async function request(user: { id: User['id'] }, url: string, object: any, keypair: UserKeypair): Promise { const body = JSON.stringify(object); const req = createSignedPost({ key: { privateKeyPem: keypair.privateKey, keyId: `${config.url}/users/${user.id}#main-key`, }, url, body, additionalHeaders: { 'User-Agent': config.userAgent, }, }); const { host } = new URL(url); try { await getResponse({ url, method: req.request.method, headers: req.request.headers, body, // don't allow redirects on the inbox redirect: 'error', }); instanceChart.requestSent(host, true); apRequestChart.deliverSucc(); federationChart.deliverd(host, true); } catch (err) { instanceChart.requestSent(host, false); apRequestChart.deliverFail(); federationChart.deliverd(host, false); throw err; } } // Determine that the content type actually is an activitypub object. // // This defends against the possibility of a user of a remote instance uploading // something that looks like an ActivityPub object and thus masquerading as any // other user on that same instance. It in turn depends on the server not // returning that content as an ActivityPub MIME type. // // Ref: GHSA-jhrq-qvrm-qr36 function isActivitypub(_contentType: string): boolean { const contentType = _contentType.toLowerCase() if (contentType.startsWith('application/activity+json')) { return true; } if (contentType.startsWith('application/ld+json')) { // oh lord, actually parsing the MIME type // Ref: ยง 5.1 // Ref: let start = contentType.indexOf('profile="'); if (start === -1) return false; // profile is required for our purposes start += 'profile="'.length; let end = contentType.indexOf('"', start); if (end === -1) return false; // malformed MIME type let profiles = contentType.substring(start, end).split(/\s+/); if (profiles.includes('https://www.w3.org/ns/activitystreams')) { return true; } } return false; }; /** * Get AP object with http-signature * @param user http-signature user * @param url URL to fetch */ export async function signedGet(_url: string, user: { id: User['id'] }): Promise { let url = _url; const keypair = await getUserKeypair(user.id); for (let redirects = 3; redirects > 0; redirects--) { const req = createSignedGet({ key: { privateKeyPem: keypair.privateKey, keyId: `${config.url}/users/${user.id}#main-key`, }, url, additionalHeaders: { 'User-Agent': config.userAgent, }, }); const res = await getResponse({ url, method: req.request.method, headers: req.request.headers, redirect: 'manual', }); if (res.status >= 300 && res.status < 400) { // Have been redirected, need to make a new signature. // Use Location header and fetched URL as the base URL. let newUrl = new URL(res.headers.get('Location'), url); // Check that we have not been redirected to a different host. if (newUrl.host !== new URL(url).host) { throw new Error('cross-origin redirect not allowed'); } url = newUrl.href; } else { if (!isActivitypub(res.headers.get('Content-Type'))) { throw new Error('invalid response content type'); } const data = await res.json(); // In theory, activitypub allows for `id` to be null for ephemeral // objects, but we wouldn't be fetching those with signed get, since // they are... ephemeral. const id = new URL(getApId(data)); if (id.href !== url.href) { // if the id and fetched url mismatch, treat it as if it was a redirect // SECURITY: this is to prevent impersonation via improper media files url = id; // if this kind of "redirect" happens, there should be at most one more // redirect since we now have the canonical url. setting to 2 because it // will be decremented to 1 right away by the for loop. if (redirects > 2) { redirects = 2; } } else { return data; } } } throw new Error('too many redirects'); }