@fedify/fedify
Version:
An ActivityPub server framework
193 lines (192 loc) • 8.71 kB
JavaScript
import "@js-temporal/polyfill";
import "urlpattern-polyfill";
globalThis.addEventListener = () => {};
import { PUBLIC_COLLECTION } from "@fedify/vocab";
import { preloadedContexts } from "@fedify/vocab-runtime";
import { getLogger } from "@logtape/logtape";
import jsonld from "@fedify/vocab-runtime/jsonld";
//#region src/compat/preloaded-context-loader.ts
/**
* A restricted JSON-LD document loader that resolves only contexts bundled
* with Fedify.
*
* This is intentionally narrower than `getDocumentLoader()`: normalization
* helpers are also reached from verification paths that operate on inbound,
* attacker-controlled JSON-LD, so the default fallback must never fetch
* attacker-supplied context URLs.
*/
const preloadedOnlyDocumentLoader = (url) => {
if (Object.hasOwn(preloadedContexts, url)) return Promise.resolve({
contextUrl: null,
documentUrl: url,
document: preloadedContexts[url]
});
return Promise.reject(/* @__PURE__ */ new Error("Refusing to fetch a non-preloaded JSON-LD context: " + url));
};
//#endregion
//#region src/compat/public-audience.ts
const logger = getLogger([
"fedify",
"compat",
"public-audience"
]);
const PUBLIC_ADDRESSING_FIELDS = new Set([
"to",
"cc",
"bto",
"bcc",
"audience"
]);
const AS_CONTEXT_URL = "https://www.w3.org/ns/activitystreams";
const MAX_TRAVERSAL_DEPTH = 64;
const KNOWN_SAFE_CONTEXT_URLS = new Set(Object.keys(preloadedContexts));
function hasPublicCurieInAddressing(value, parentKey, depth = 0) {
if (typeof value === "string") return parentKey != null && PUBLIC_ADDRESSING_FIELDS.has(parentKey) && (value === "as:Public" || value === "Public");
if (depth >= MAX_TRAVERSAL_DEPTH) return false;
if (Array.isArray(value)) return value.some((item) => hasPublicCurieInAddressing(item, parentKey, depth + 1));
if (typeof value !== "object" || value == null) return false;
const record = value;
for (const key of Object.keys(record)) {
if (key === "@context") continue;
if (hasPublicCurieInAddressing(record[key], key, depth + 1)) return true;
}
return false;
}
function rewritePublicAudience(value, parentKey, depth = 0) {
if (typeof value === "string" && parentKey != null && PUBLIC_ADDRESSING_FIELDS.has(parentKey) && (value === "as:Public" || value === "Public")) return PUBLIC_COLLECTION.href;
if (depth >= MAX_TRAVERSAL_DEPTH) return value;
if (Array.isArray(value)) {
let changed = false;
const mapped = value.map((item) => {
const rewritten = rewritePublicAudience(item, parentKey, depth + 1);
if (rewritten !== item) changed = true;
return rewritten;
});
return changed ? mapped : value;
}
if (typeof value !== "object" || value == null) return value;
const record = value;
let changed = false;
const normalized = Object.create(null);
for (const key of Object.keys(record)) {
const rewritten = key === "@context" ? record[key] : rewritePublicAudience(record[key], key, depth + 1);
if (rewritten !== record[key]) changed = true;
normalized[key] = rewritten;
}
return changed ? normalized : value;
}
/**
* Reports whether `value` carries an `@context` property anywhere inside
* its subtree (not counting the value itself). A nested `@context` can
* introduce a local term-definition scope that redefines `as:` or `Public`
* even when the top-level `@context` is safe, so the fast path must defer
* to the URDNA2015 equivalence check whenever one is present.
*/
function hasNestedContext(value, depth = 0) {
if (depth >= MAX_TRAVERSAL_DEPTH) return true;
if (Array.isArray(value)) return value.some((item) => hasNestedContext(item, depth + 1));
if (typeof value !== "object" || value == null) return false;
const record = value;
for (const key of Object.keys(record)) {
if (key === "@context") return true;
if (hasNestedContext(record[key], depth + 1)) return true;
}
return false;
}
/**
* Checks whether the `@context` of a JSON-LD document is guaranteed not
* to redefine the `as:` prefix or the bare `Public` term. Only documents
* whose `@context` is a string, or an array of strings, drawn from Fedify's
* preloaded context set AND including the ActivityStreams URL qualify,
* AND no nested subtree carries its own `@context` that might redefine
* those terms within a local scope. When all of that holds the rewrite
* is provably semantics-preserving and the URDNA2015 equivalence check
* can be skipped. Any other shape (unknown external URLs, inline
* objects at the top level, nested `@context` blocks) is treated as
* potentially unsafe.
*/
function hasKnownSafeContext(jsonLd) {
if (typeof jsonLd !== "object" || jsonLd == null) return false;
const record = jsonLd;
if (!Object.hasOwn(record, "@context")) return false;
const ctx = record["@context"];
const entries = typeof ctx === "string" ? [ctx] : Array.isArray(ctx) ? ctx : null;
if (entries == null || entries.length === 0) return false;
let hasAs = false;
for (const entry of entries) {
if (typeof entry !== "string") return false;
if (!KNOWN_SAFE_CONTEXT_URLS.has(entry)) return false;
if (entry === AS_CONTEXT_URL) hasAs = true;
}
if (!hasAs) return false;
for (const key of Object.keys(record)) {
if (key === "@context") continue;
if (hasNestedContext(record[key])) return false;
}
return true;
}
/**
* Rewrites the compact `as:Public` / `Public` CURIE appearing in activity
* addressing fields (`to`, `cc`, `bto`, `bcc`, `audience`) to the fully
* expanded `https://www.w3.org/ns/activitystreams#Public` URI.
*
* Several ActivityPub implementations, Lemmy among them, match these
* fields as plain URLs without running JSON-LD expansion, and silently
* drop activities whose public addressing appears in CURIE form. This
* helper works around that gap.
*
* For documents whose `@context` is drawn entirely from Fedify's
* preloaded context set and includes the ActivityStreams URL, the
* rewrite is applied directly: the content of every preloaded non-AS
* context is known not to redefine the `as:` prefix or the bare `Public`
* term, so the semantics are preserved by construction. Any other
* shape (an inline object, an unknown external URL, and so on) is
* treated as potentially unsafe and gated on a JSON-LD equivalence
* check; both forms are canonicalized with URDNA2015 and the resulting
* N-Quads are compared. When they differ, the original document is
* returned unchanged. Canonicalization failures also fall back to the
* original document.
*
* When no `contextLoader` is supplied the helper falls back to an
* internal loader that resolves only the URLs in Fedify's
* preloaded-contexts set and rejects every other URL without issuing a
* network request. That behaviour is deliberately narrower than
* `@fedify/vocab-runtime`'s `getDocumentLoader()`, which after its
* `validatePublicUrl` check will happily fetch non-preloaded URLs: the
* helper is reached from verification paths (`verifyProof()` /
* `verifyObject()`) that operate on inbound, potentially adversarial
* JSON-LD, and a default loader that fetches attacker-supplied
* `@context` URLs on the caller's behalf would be an SSRF vector.
* Canonicalization failures against the restricted loader fall back to
* the original document, same as any other canonicalization error.
* Callers that genuinely need the remote-fetch loader (for example
* applications that sign local JSON-LD against a custom vocabulary)
* should pass a `contextLoader` explicitly.
*
* Must be called before any signing step that canonicalizes the
* compact form byte-for-byte (for example, Object Integrity Proofs
* using the `eddsa-jcs-2022` cryptosuite), so the signed payload
* matches what is sent on the wire.
*/
async function normalizePublicAudience(jsonLd, contextLoader) {
if (!hasPublicCurieInAddressing(jsonLd)) return jsonLd;
const normalized = rewritePublicAudience(jsonLd);
if (hasKnownSafeContext(jsonLd)) return normalized;
const loader = contextLoader ?? preloadedOnlyDocumentLoader;
try {
const [before, after] = await Promise.all([jsonld.canonize(jsonLd, {
format: "application/n-quads",
documentLoader: loader
}), jsonld.canonize(normalized, {
format: "application/n-quads",
documentLoader: loader
})]);
if (before === after) return normalized;
logger.warn("Expanding the public audience CURIE to its full URI would change the canonical form of the activity; sending the activity as is. This usually means the active JSON-LD context redefines the `as:` prefix or the bare `Public` term.");
} catch (error) {
logger.debug("Failed to verify public audience normalization equivalence via JSON-LD canonicalization; sending the activity as is.\n{error}", { error });
}
return jsonLd;
}
//#endregion
export { preloadedOnlyDocumentLoader as n, normalizePublicAudience as t };