Verified against RFC 9421, draft-cavage-12, Mastodon 4.7.3 source, FEP-521a/fe34, GitHub security advisories; code tested in examples/verify-requests on 2026-10-15.
Verifying incoming requests
Every POST to your inbox claims to come from some actor. Before you act on it you have to prove three things:
- The request was signed by a key, and the signature covers the method, the full URL, the host, a recent time and a digest of the body.
- The body you received is the body that was signed. Check the digest over the raw bytes.
- The key belongs to
activity.actor. A valid signature from some server proves nothing about this activity.
Most real-world ActivityPub security bugs skip one of these. Mastodon's CVE-2024-23832 and Fedify's CVE-2026-96625 both allowed impersonation. See Known vulnerability classes.
:::tip The code on this page is tested
Every code block below is imported straight from examples/verify-requests. Its test suite runs in CI on every change and covers each bypass listed under What the tests prove, plus the RFC 9421 test vector. It's still teaching code: in production, prefer a maintained framework such as Fedify or activitypub-federation-rust, and use this page to check what that framework does for you.
:::
Using it in an inbox
import express from 'express';
import { verifyRequest, parseActivityFrom, KeyStore, safeFetchJson,
authorizeActivity, SignatureError } from './src/index.js';
const keyStore = new KeyStore((url) => safeFetchJson(url, {
// Needed for servers in "secure mode" / authorized fetch:
signRequest: ({ method, url }) => signCavage({ method, url, keyId: INSTANCE_KEY_ID, privateKey: INSTANCE_KEY }),
}));
// Keep the raw bytes: the digest is over them, not over re-serialised JSON.
app.post('/users/:name/inbox', express.raw({ type: '*/*', limit: '1mb' }), async (req, res) => {
try {
const signer = await verifyRequest({
method: req.method,
url: `https://${MY_HOST}${req.originalUrl}`, // full URL, query string included
headers: req.headers, // Node lower-cases header names
rawBody: req.body,
}, { keyStore });
const activity = parseActivityFrom(signer, req.body); // signer must BE activity.actor
const { trustEmbedded } = authorizeActivity(activity); // see the origin model page
await queue.add({ activity, trustEmbedded }); // process asynchronously
res.status(202).end();
} catch (err) {
if (err instanceof SignatureError) return res.status(401).json({ error: err.message });
// Couldn't fetch the key: tell the sender to retry later, as Mastodon does since 4.5.4.
return res.status(503).json({ error: 'Temporarily unable to verify' });
}
});
Use your own public hostname when you build url. Don't trust a client-supplied Host or X-Forwarded-Host unless your reverse proxy sets it.
The rules, and why
| Check | Rule | Without it |
|---|---|---|
| Signed components (cavage) | (request-target), host, date (or (created)), and digest on POST | A signature covering only date can be replayed against any path with any body |
| Signed components (RFC 9421) | @method, @target-uri, and content-digest on POST, plus a created parameter | Same as above |
| Request target | Path and query string | A signature can be replayed on another URL |
| Body digest | Digest (cavage) or Content-Digest (RFC 9421), SHA-256, over the raw bytes | The body can be swapped, or valid requests fail because re-serialised JSON differs |
| Time window | Without expires, valid 5 minutes after creation; any expiry is capped at 12 hours; 1 hour of clock skew is allowed either way | Replays of old requests |
| Key owner | The keyId must resolve to a key listed in the owner actor's own document, and that actor must be activity.actor | Any server can post activities "from" anyone |
| Key fetch | SSRF-safe: public IPs only, re-validated on every redirect, size and time limits, ActivityPub Content-Type | Your server fetches internal URLs, or accepts a JSON file uploaded as media as an actor |
| Key rotation | If a cached key fails, refetch it once and retry | Senders that rotate keys are locked out, or a bad signature triggers unbounded refetches |
The time window copies Mastodon's SignedRequest (EXPIRATION_WINDOW_LIMIT = 12.hours, CLOCK_SKEW_MARGIN = 1.hour, default validity 5 minutes). The required signed headers match Mastodon's verify_signature_strength! in the same file, with one stricter rule: host is required on POST as well. Mastodon signs host on its own requests.
The code
verify.js: the entry point
// Verify an incoming signed ActivityPub request, end to end.
//
// const signer = await verifyRequest(req, { keyStore });
// const activity = parseActivityFrom(signer, rawBody);
//
// `req` is { method, url, headers, rawBody }:
// - url: the full URL as requested, including the query string
// - headers: lower-cased header names
// - rawBody: the exact bytes received (a Buffer), never re-serialised JSON
import { checkContentDigest, checkDigest } from './digest.js';
import { prepareCavage, SignatureError } from './cavage.js';
import { prepareRfc9421 } from './rfc9421.js';
export { SignatureError };
// Mastodon's window (app/lib/signed_request.rb): a signature without an
// explicit expiry is valid for 5 minutes after it was created, any expiry is
// capped at 12 hours, and 1 hour of clock skew is tolerated either way.
export const DEFAULT_VALIDITY_MS = 5 * 60 * 1000;
export const MAX_VALIDITY_MS = 12 * 60 * 60 * 1000;
export const CLOCK_SKEW_MS = 60 * 60 * 1000;
export function checkTimeWindow({ created, expires }, now = Date.now()) {
const c = created.getTime();
const e = Math.min(expires ? expires.getTime() : c + DEFAULT_VALIDITY_MS, c + MAX_VALIDITY_MS);
if (c > now + CLOCK_SKEW_MS) throw new SignatureError('Signature created in the future');
if (now > e + CLOCK_SKEW_MS) throw new SignatureError('Signature expired');
}
/**
* @returns {Promise<{actorId: string, keyId: string, scheme: string}>}
* Throws SignatureError (respond 401) or a fetch error (respond 503 so the
* sender retries later, as Mastodon does since 4.5.4).
*/
export async function verifyRequest(req, { keyStore, now = Date.now() }) {
const sig = req.headers['signature-input'] ? prepareRfc9421(req) : null;
const prepared = sig ?? (req.headers.signature ? prepareCavage(req) : null);
if (!prepared) throw new SignatureError('Request is not signed');
checkTimeWindow(prepared, now);
// The body must match the digest that was signed.
if (req.method.toUpperCase() === 'POST') {
const ok = prepared.scheme === 'rfc9421'
? checkContentDigest(req.headers['content-digest'], req.rawBody)
: checkDigest(req.headers.digest, req.rawBody);
if (!ok) throw new SignatureError('Body digest does not match');
}
// Verify with the cached key; on failure refetch the key once (the sender
// may have rotated it) and try again.
let key = await keyStore.get(prepared.keyId);
if (!prepared.verify(key.publicKey)) {
if (!key.cached) throw new SignatureError('Invalid signature');
key = await keyStore.get(prepared.keyId, { refresh: true });
if (!prepared.verify(key.publicKey)) throw new SignatureError('Invalid signature');
}
return { actorId: key.actorId, keyId: key.keyId, scheme: prepared.scheme };
}
/**
* Parse the activity from the raw body and require that the verified signer
* IS the activity's actor. Without this, any server with a valid key can
* post activities "from" anyone.
*/
export function parseActivityFrom(signer, rawBody) {
const activity = JSON.parse(rawBody.toString('utf8'));
const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
if (!actor || actor !== signer.actorId) {
throw new SignatureError(`Signed by ${signer.actorId} but activity.actor is ${actor}`);
}
return activity;
}
cavage.js: draft-cavage signatures
This is the scheme most of the Fediverse still sends. Mastodon 4.7 sends it first.
// draft-cavage-http-signatures-12: the `Signature` header scheme most of
// the Fediverse still sends. https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
import crypto from 'node:crypto';
export class SignatureError extends Error {}
/** Parse `keyId="…",algorithm="…",headers="…",signature="…"`. */
export function parseSignatureHeader(value) {
const params = {};
const re = /\s*([a-zA-Z]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|(\d+))\s*(?:,|$)/y;
let m;
while (re.lastIndex < value.length && (m = re.exec(value))) {
params[m[1]] = m[2] !== undefined ? m[2].replace(/\\(.)/g, '$1') : m[3];
}
if (re.lastIndex !== value.length) throw new SignatureError('Malformed Signature header');
if (!params.keyId || !params.signature) throw new SignatureError('keyId and signature are required');
return params;
}
/** The string that was signed, rebuilt from the request as received. */
export function signingString(signedHeaders, { method, url, headers }, params = {}) {
const u = new URL(url);
return signedHeaders.map((name) => {
if (name === '(request-target)') {
// Path AND query string, exactly as requested.
return `(request-target): ${method.toLowerCase()} ${u.pathname}${u.search}`;
}
if (name === '(created)' || name === '(expires)') {
const v = params[name.slice(1, -1)];
if (!v) throw new SignatureError(`${name} signed but parameter missing`);
return `${name}: ${v}`;
}
const value = headers[name];
if (value === undefined) throw new SignatureError(`Signed header "${name}" is missing`);
return `${name}: ${Array.isArray(value) ? value.join(', ') : value}`;
}).join('\n');
}
/**
* Verify a cavage signature. `headers` must have lower-case names.
* Returns {keyId, created, expires, signedHeaders}; throws on failure.
* Call `checkKey(publicKey)` style via the `verifyWith` callback so that the
* caller can retry with a refreshed key.
*/
export function prepareCavage(req) {
const params = parseSignatureHeader(req.headers.signature);
const algorithm = (params.algorithm || 'hs2019').toLowerCase();
if (!['rsa-sha256', 'hs2019'].includes(algorithm)) {
throw new SignatureError(`Unsupported algorithm ${algorithm}`);
}
const signedHeaders = (params.headers || (algorithm === 'hs2019' ? '(created)' : 'date'))
.toLowerCase().split(/\s+/).filter(Boolean);
// Required coverage. Without these an attacker can replay a signature on a
// different path, host or body.
const required = ['(request-target)', 'host'];
if (req.method.toUpperCase() === 'POST') required.push('digest');
for (const h of required) {
if (!signedHeaders.includes(h)) throw new SignatureError(`"${h}" must be signed`);
}
if (!signedHeaders.includes('date') && !signedHeaders.includes('(created)')) {
throw new SignatureError('"date" or "(created)" must be signed');
}
let created;
if (algorithm === 'hs2019' && params.created) created = new Date(Number(params.created) * 1000);
else if (req.headers.date) created = new Date(req.headers.date);
if (!created || Number.isNaN(created.getTime())) throw new SignatureError('No valid Date/(created)');
const expires = params.expires ? new Date(Number(params.expires) * 1000) : undefined;
const data = Buffer.from(signingString(signedHeaders, req, params));
const signature = Buffer.from(params.signature, 'base64');
return {
scheme: 'draft-cavage',
keyId: params.keyId,
created,
expires,
covers: signedHeaders,
verify(publicKey) {
// Ed25519 keys sign the raw string; RSA uses RSASSA-PKCS1-v1_5 SHA-256.
if (publicKey.asymmetricKeyType === 'ed25519') {
if (algorithm !== 'hs2019') return false;
return crypto.verify(null, data, publicKey, signature);
}
if (publicKey.asymmetricKeyType !== 'rsa') return false;
return crypto.verify('sha256', data, publicKey, signature);
},
};
}
rfc9421.js: RFC 9421 HTTP Message Signatures
Mastodon has verified RFC 9421 since 4.5. Since 4.7 it also retries with RFC 9421 when a draft-cavage delivery gets a 400 or 401.
// RFC 9421 HTTP Message Signatures (`Signature-Input` + `Signature`).
// https://www.rfc-editor.org/rfc/rfc9421
import crypto from 'node:crypto';
import { parseDictionary, serializeInnerList } from 'structured-headers';
import { SignatureError } from './cavage.js';
function componentValue(name, params, { method, url, headers }) {
if (params.size > 0) throw new SignatureError(`Component parameters on "${name}" are not supported`);
const u = new URL(url);
switch (name) {
case '@method': return method.toUpperCase();
case '@target-uri': return u.href;
case '@authority': return u.host.toLowerCase();
case '@scheme': return u.protocol.slice(0, -1);
case '@path': return u.pathname;
case '@query': return u.search || '?';
case '@request-target': return `${u.pathname}${u.search}`;
default: {
if (name.startsWith('@')) throw new SignatureError(`Unsupported component ${name}`);
const value = headers[name];
if (value === undefined) throw new SignatureError(`Signed field "${name}" is missing`);
// RFC 9421 §2.1: trim, and join multiple field lines with ", ".
return (Array.isArray(value) ? value : [value]).map((v) => String(v).trim()).join(', ');
}
}
}
/** Build the RFC 9421 §2.5 signature base for one signature. */
export function signatureBase(innerList, req) {
const [items] = innerList;
const lines = items.map(([name, params]) =>
`"${name}": ${componentValue(name, params, req)}`);
lines.push(`"@signature-params": ${serializeInnerList(innerList)}`);
return lines.join('\n');
}
/**
* Parse and prepare verification of an RFC 9421 signature. If several
* labels are present, the first one is used. Pass `label` to choose.
*/
export function prepareRfc9421(req, { label, requireCoverage = true } = {}) {
let inputs, sigs;
try {
inputs = parseDictionary(req.headers['signature-input']);
sigs = parseDictionary(req.headers.signature);
} catch {
throw new SignatureError('Malformed Signature-Input or Signature');
}
const name = label ?? [...inputs.keys()][0];
const input = inputs.get(name);
const sig = sigs.get(name);
if (!input || !sig || !(sig[0] instanceof ArrayBuffer)) {
throw new SignatureError(`No signature labelled "${name}"`);
}
const [items, params] = input;
const covers = items.map(([n]) => n);
// ActivityPub policy (matches Mastodon). requireCoverage: false exists only
// to check the cryptography against the RFC's own test vectors.
const required = requireCoverage ? ['@method', '@target-uri'] : [];
if (requireCoverage && req.method.toUpperCase() === 'POST') required.push('content-digest');
for (const c of required) {
if (!covers.includes(c)) throw new SignatureError(`"${c}" must be covered`);
}
const keyId = params.get('keyid');
const created = params.get('created');
if (typeof keyId !== 'string') throw new SignatureError('keyid parameter is required');
if (typeof created !== 'number') throw new SignatureError('created parameter is required');
const expires = params.get('expires');
const alg = params.get('alg'); // optional; otherwise inferred from the key
const data = Buffer.from(signatureBase(input, req));
const signature = Buffer.from(sig[0]);
return {
scheme: 'rfc9421',
keyId,
created: new Date(created * 1000),
expires: typeof expires === 'number' ? new Date(expires * 1000) : undefined,
covers,
verify(publicKey) {
const type = publicKey.asymmetricKeyType;
if (type === 'ed25519' && (!alg || alg === 'ed25519')) {
return crypto.verify(null, data, publicKey, signature);
}
if (type === 'rsa' && (!alg || alg === 'rsa-v1_5-sha256')) {
return crypto.verify('sha256', data, publicKey, signature);
}
if ((type === 'rsa' || type === 'rsa-pss') && alg === 'rsa-pss-sha512') {
return crypto.verify('sha512', data, {
key: publicKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 64,
}, signature);
}
return false;
},
};
}
digest.js: body digests over raw bytes
// Body digests, always computed over the RAW request bytes.
//
// Never recompute a digest from parsed-and-reserialised JSON
// (JSON.stringify(req.body)): key order, whitespace and escaping differ, so
// valid requests fail, and you are no longer checking what was signed.
import crypto from 'node:crypto';
import { parseDictionary } from 'structured-headers';
const sha = (alg, raw) => crypto.createHash(alg).update(raw).digest();
/** Value for the legacy `Digest` header (RFC 3230), used with draft-cavage. */
export function digestHeader(raw) {
return `SHA-256=${sha('sha256', raw).toString('base64')}`;
}
/** Value for the `Content-Digest` header (RFC 9530), used with RFC 9421. */
export function contentDigestHeader(raw) {
return `sha-256=:${sha('sha256', raw).toString('base64')}:`;
}
/** Check a `Digest` header against the raw body. Only SHA-256 is accepted. */
export function checkDigest(header, raw) {
if (!header) return false;
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
if (part.slice(0, i).trim().toLowerCase() !== 'sha-256') continue;
const given = Buffer.from(part.slice(i + 1).trim(), 'base64');
const actual = sha('sha256', raw);
return given.length === actual.length && crypto.timingSafeEqual(given, actual);
}
return false;
}
/** Check a `Content-Digest` header (sha-256 or sha-512) against the raw body. */
export function checkContentDigest(header, raw) {
if (!header) return false;
let dict;
try { dict = parseDictionary(header); } catch { return false; }
for (const [alg, hashAlg] of [['sha-256', 'sha256'], ['sha-512', 'sha512']]) {
const entry = dict.get(alg);
if (!entry || !(entry[0] instanceof ArrayBuffer)) continue;
const given = Buffer.from(entry[0]);
const actual = sha(hashAlg, raw);
return given.length === actual.length && crypto.timingSafeEqual(given, actual);
}
return false;
}
keys.js: from keyId to a key and its owner
// Resolve a signature's keyId to a public key AND the actor that owns it.
//
// The owner is what you compare with `activity.actor`. Never trust a key
// document's `owner`/`controller` on its own: an attacker can publish a key
// claiming any owner. The owner's actor document must list the key itself.
import crypto from 'node:crypto';
export class KeyResolutionError extends Error {}
const stripFragment = (u) => u.split('#')[0];
const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
const idOf = (v) => (typeof v === 'string' ? v : v && v.id);
// --- FEP-521a Multikey (publicKeyMultibase) --------------------------------
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58btcDecode(str) {
let n = 0n;
for (const ch of str) {
const i = B58.indexOf(ch);
if (i < 0) throw new KeyResolutionError('Invalid base58 character');
n = n * 58n + BigInt(i);
}
const bytes = [];
while (n > 0n) { bytes.unshift(Number(n & 0xffn)); n >>= 8n; }
for (const ch of str) { if (ch === '1') bytes.unshift(0); else break; }
return Buffer.from(bytes);
}
export function multikeyToKeyObject(publicKeyMultibase) {
if (!publicKeyMultibase.startsWith('z')) {
throw new KeyResolutionError('Only base58btc (z) multibase is supported');
}
const bytes = base58btcDecode(publicKeyMultibase.slice(1));
// Multicodec prefixes: ed25519-pub = 0xed (varint ed 01),
// rsa-pub = 0x1205 (varint 85 24, PKCS#1 DER).
if (bytes[0] === 0xed && bytes[1] === 0x01) {
const x = bytes.subarray(2);
if (x.length !== 32) throw new KeyResolutionError('Bad Ed25519 key length');
return crypto.createPublicKey({
key: { kty: 'OKP', crv: 'Ed25519', x: x.toString('base64url') },
format: 'jwk',
});
}
if (bytes[0] === 0x85 && bytes[1] === 0x24) {
return crypto.createPublicKey({ key: bytes.subarray(2), format: 'der', type: 'pkcs1' });
}
throw new KeyResolutionError('Unsupported multikey type');
}
/** Find `keyId` among an actor's publicKey and assertionMethod entries. */
export function findKeyInActor(actor, keyId) {
for (const k of asArray(actor.publicKey)) {
if (k && typeof k === 'object' && k.id === keyId && k.publicKeyPem) {
if (idOf(k.owner) !== actor.id) return null;
return crypto.createPublicKey(k.publicKeyPem);
}
}
for (const k of asArray(actor.assertionMethod)) {
if (k && typeof k === 'object' && k.id === keyId && k.publicKeyMultibase) {
if (idOf(k.controller) !== actor.id) return null;
return multikeyToKeyObject(k.publicKeyMultibase);
}
}
return null;
}
/**
* Fetch the key and its owner.
* @param {string} keyId
* @param {(url: string) => Promise<{url: string, json: object}>} fetchJson
* An SSRF-safe fetcher such as safeFetchJson.
* @returns {Promise<{keyId: string, actorId: string, publicKey: crypto.KeyObject}>}
*/
export async function resolveKey(keyId, fetchJson) {
const first = await fetchJson(stripFragment(keyId));
let actor;
if (first.json.id === stripFragment(keyId) && !first.json.owner && !first.json.controller) {
// keyId is a fragment of the actor document (the Mastodon `#main-key` style).
actor = first.json;
assertServedFromOwnOrigin(actor, first.url);
} else if (first.json.id === keyId && (first.json.owner || first.json.controller)) {
// keyId is a standalone key document. Fetch the claimed owner and make
// sure the owner lists this key: a key cannot vouch for itself.
assertServedFromOwnOrigin(first.json, first.url);
const ownerId = idOf(first.json.owner || first.json.controller);
const second = await fetchJson(ownerId);
actor = second.json;
if (actor.id !== ownerId) throw new KeyResolutionError('Owner document id mismatch');
assertServedFromOwnOrigin(actor, second.url);
} else {
throw new KeyResolutionError(`Document at ${keyId} is neither the key nor its owner`);
}
const publicKey = findKeyInActor(actor, keyId);
if (!publicKey) throw new KeyResolutionError(`Actor ${actor.id} does not list key ${keyId}`);
return { keyId, actorId: actor.id, publicKey };
}
// A document is only authoritative for ids on the origin it was served from
// (after redirects). Otherwise evil.example could serve a "victim" actor.
function assertServedFromOwnOrigin(doc, servedFrom) {
if (typeof doc.id !== 'string' || new URL(doc.id).origin !== new URL(servedFrom).origin) {
throw new KeyResolutionError(`Document id ${doc.id} does not match origin of ${servedFrom}`);
}
}
/**
* A tiny key cache with the "refetch once" behaviour remote servers rely on
* for key rotation: a cached key that fails verification is refreshed once.
*/
export class KeyStore {
constructor(fetchJson) {
this.fetchJson = fetchJson;
this.cache = new Map();
}
async get(keyId, { refresh = false } = {}) {
if (!refresh && this.cache.has(keyId)) return { ...this.cache.get(keyId), cached: true };
const key = await resolveKey(keyId, this.fetchJson);
this.cache.set(keyId, key);
return { ...key, cached: false };
}
}
safe-fetch.js: fetching untrusted URLs
// SSRF-safe fetching of remote ActivityPub documents (actors, keys).
//
// Every URL we fetch while verifying a request (keyId, actor) is chosen by
// the sender, so it must never reach internal services. This module:
// - only allows https: (http: optionally, for local development)
// - resolves DNS itself and rejects private, loopback, link-local,
// multicast and other special-purpose addresses (IPv4 and IPv6,
// including IPv4-mapped and NAT64 forms)
// - connects to the exact address it validated (no second DNS lookup,
// so DNS rebinding cannot swap in a private address)
// - follows redirects manually, re-validating every hop
// - caps response size and total time
// - requires an ActivityPub Content-Type (FEP-fe34; Mastodon CVE-2024-25623:
// a JSON file uploaded as media must not be accepted as an actor)
import dns from 'node:dns/promises';
import http from 'node:http';
import https from 'node:https';
import net from 'node:net';
export class UnsafeUrlError extends Error {}
// Separate lists: a BlockList also matches IPv4 addresses against IPv6
// rules (as IPv4-mapped), which would make ::/96 block everything.
const blocked4 = new net.BlockList();
const blocked6 = new net.BlockList();
for (const [addr, prefix] of [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
]) blocked4.addSubnet(addr, prefix, 'ipv4');
for (const [addr, prefix] of [
['::', 96], // unspecified, loopback and IPv4-compatible
['::ffff:0:0', 96], // IPv4-mapped (also checked as IPv4 below)
['64:ff9b::', 96], ['64:ff9b:1::', 48], // NAT64
['100::', 64], ['2001::', 23], ['2001:db8::', 32], ['2002::', 16],
['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8],
]) blocked6.addSubnet(addr, prefix, 'ipv6');
export function isActivityPubContentType(value = '') {
const [type, ...params] = String(value).toLowerCase().split(';').map((s) => s.trim());
if (type === 'application/activity+json') return true;
return type === 'application/ld+json' &&
params.some((p) => p.replace(/\s/g, '') === 'profile="https://www.w3.org/ns/activitystreams"');
}
/** True if the address must never be fetched. */
export function isBlockedAddress(address) {
const family = net.isIP(address);
if (family === 4) return blocked4.check(address, 'ipv4');
if (family !== 6) return true;
const mapped = address.toLowerCase().match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return blocked4.check(mapped[1], 'ipv4');
return blocked6.check(address, 'ipv6');
}
async function resolvePublic(hostname, lookup) {
const host = hostname.replace(/^\[|\]$/g, '');
const addresses = net.isIP(host)
? [{ address: host, family: net.isIP(host) }]
: await lookup(host, { all: true, verbatim: true });
if (addresses.length === 0) throw new UnsafeUrlError(`No addresses for ${host}`);
// Reject if *any* address is private: an attacker controls which one a
// client library would pick.
for (const { address } of addresses) {
if (isBlockedAddress(address)) {
throw new UnsafeUrlError(`Refusing to fetch ${host}: ${address} is not a public address`);
}
}
return addresses[0];
}
// Default transport: node:https / node:http, pinned to the validated address.
function nodeTransport({ url, address, headers, timeoutMs, maxBytes }) {
return new Promise((resolve, reject) => {
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, {
method: 'GET',
headers,
timeout: timeoutMs,
// Connect to the address we already validated.
lookup: (_host, opts, cb) =>
opts && opts.all
? cb(null, [{ address: address.address, family: address.family }])
: cb(null, address.address, address.family),
}, (res) => {
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > maxBytes) {
req.destroy(new UnsafeUrlError(`Response larger than ${maxBytes} bytes`));
return;
}
chunks.push(chunk);
});
res.on('end', () => resolve({
status: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks),
}));
res.on('error', reject);
});
req.on('timeout', () => req.destroy(new Error(`Timed out after ${timeoutMs} ms`)));
req.on('error', reject);
req.end();
});
}
/**
* Fetch a URL and return its parsed JSON body, refusing anything unsafe.
* `lookup` and `transport` are injectable for tests.
*/
export async function safeFetchJson(rawUrl, {
accept = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
headers = {},
// Some servers (e.g. lemmy.ml) drop requests without a User-Agent.
userAgent = 'socialdocs-verify-requests/1.0 (+https://socialdocs.org)',
allowHttp = false,
maxRedirects = 3,
maxBytes = 1024 * 1024, // Mastodon's HTTP client also defaults to a 1 MB body limit
timeoutMs = 10_000,
lookup = dns.lookup,
transport = nodeTransport,
// Servers in "secure mode" / authorized fetch (e.g. mastodon.social)
// answer 401 to unsigned GETs. Pass a function that returns signature
// headers for your instance actor, e.g.
// ({ url }) => signCavage({ method: 'GET', url, keyId, privateKey })
signRequest,
} = {}) {
let url = new URL(rawUrl);
const deadline = Date.now() + timeoutMs;
for (let hop = 0; ; hop++) {
if (url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:')) {
throw new UnsafeUrlError(`Refusing scheme ${url.protocol}`);
}
if (url.username || url.password) throw new UnsafeUrlError('Refusing URL with credentials');
const address = await resolvePublic(url.hostname, lookup);
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error(`Timed out after ${timeoutMs} ms`);
const res = await transport({
url, address, timeoutMs: remaining, maxBytes,
headers: {
'user-agent': userAgent,
...headers,
...(signRequest ? await signRequest({ method: 'GET', url: url.href }) : {}),
accept,
},
});
if (res.body && res.body.length > maxBytes) {
throw new UnsafeUrlError(`Response larger than ${maxBytes} bytes`);
}
if ([301, 302, 303, 307, 308].includes(res.status)) {
if (hop >= maxRedirects) throw new UnsafeUrlError('Too many redirects');
const location = res.headers.location;
if (!location) throw new Error('Redirect without Location');
url = new URL(location, url); // re-validated at the top of the loop
continue;
}
if (res.status !== 200) {
const err = new Error(`GET ${url} returned ${res.status}`);
err.status = res.status;
throw err;
}
if (!isActivityPubContentType(res.headers['content-type'])) {
throw new UnsafeUrlError(`Unexpected Content-Type ${res.headers['content-type']} from ${url}`);
}
return { url: url.href, json: JSON.parse(res.body.toString('utf8')) };
}
}
What the tests prove
The test suite generates keys at runtime and uses an in-memory network. It checks that the code:
- accepts valid draft-cavage and RFC 9421 requests, including an Ed25519 FEP-521a Multikey;
- reproduces the Ed25519 example from RFC 9421 Appendix B.2.6;
- rejects:
- a signature covering only
date - a POST whose signature doesn't cover the digest
- a swapped body
- a digest computed over re-serialised JSON
- a signature replayed with a different query string
- an activity whose
actorisn't the key owner - a key document claiming an owner that doesn't list it
- an actor document served from another origin
- requests more than about 65 minutes old or more than 1 hour in the future
- keyIds that resolve to private, loopback, link-local, IPv4-mapped or IPv4-compatible IPv6 addresses
- redirects to private addresses
- oversized responses
- non-ActivityPub content types
- a signature covering only
- refetches a rotated key exactly once.
Interop notes
- Authorized fetch. Some servers, including mastodon.social, answer 401 to unsigned GETs, even for public actors. Sign your key and actor fetches with an instance actor key using the
signRequestoption. - User-Agent. Some servers drop requests that have no
User-Agent(lemmy.ml does).safeFetchJsonalways sends one. - Respond quickly. Verify, enqueue, return
202, and do the work in a background job. Senders retry on timeouts.
See also
- HTTP signatures in 2026: the schemes, double-knocking, and "why did I get a 401?"
- Origin-based security: what an authenticated actor is allowed to do
- Known vulnerability classes