FoundKey/packages/backend/src/remote/activitypub/request.ts

156 lines
4.7 KiB
TypeScript

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<void> {
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: <urn:ietf:rfc:2045> § 5.1
// Ref: <https://www.iana.org/assignments/media-types/application/ld+json>
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<any> {
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');
}