Generated vocabulary classes copy the trust sets in clone() but not the property value arrays, which stay shared between the original and the clone (constructor.ts L261–L264). The object accessors cache what they fetch by writing into that array in place (property.ts L468, property.ts L640). A fetch on the clone therefore also changes the original, which was never asked to fetch anything.
Reproduction
With @fedify/vocab 2.4.0:
import { Accept, Create } from "@fedify/vocab";
const original = await Create.fromJsonLd({
"@context": "https://www.w3.org/ns/activitystreams",
id: "https://a.example/activities/1",
type: "Create",
actor: "https://a.example/users/alice",
// Cross-origin, so the accessor dereferences it instead of trusting it:
object: { id: "https://b.example/notes/1", type: "Note", content: "embedded" },
});
const copy = original.clone();
await copy.getObject({
documentLoader: async (url) => ({
contextUrl: null,
documentUrl: url,
document: {
"@context": "https://www.w3.org/ns/activitystreams",
id: url,
type: "Note",
content: "fetched",
},
}),
});
const fromOriginal = await original.getObject({ crossOrigin: "trust" });
console.log(fromOriginal?.content?.toString());
// fetched
console.log(JSON.stringify((await original.toJsonLd()).object));
// {"id":"https://b.example/notes/1","type":"Note","content":"embedded"}
const accept = new Accept({
actor: new URL("https://b.example/users/bob"),
object: original,
});
console.log(JSON.stringify((await accept.toJsonLd()).object.object));
// {"id":"https://b.example/notes/1","type":"Note","content":"fetched"}
The original now returns the fetched note. Its serialization also disagrees with itself. Its own toJsonLd() still returns the cached original document, but the same object embedded in another activity serializes the fetched contents. The original's trust set doesn't include the replaced entry either, so without crossOrigin: "trust" it dereferences the URL again.
Impact
Hollo hit this while answering FEP-044f quote requests (fedify-dev/hollo#641). It resolves the request's instrument on a clone, then embeds the original request in the Accept it sends back. Because of this bug, the Accept echoed the fetched instrument instead of what the sender had sent. Hollo now works around it by re-parsing the request from JSON-LD rather than calling clone().
Any code that clones an activity to inspect it while keeping the original for later use is affected. Common cases are forwarding, echoing a request back in a response, or signing.
Expected behavior
A clone should be independent of its source. Dereferencing through the clone shouldn't change what the original returns or how it serializes.
Suggested fix
In the generated clone(), copy the arrays instead of sharing them:
clone.${fieldName} = [...this.${fieldName}];
The constructor and clone() also store a caller's plural-value array as-is (this.${fieldName} = values.${property.pluralName} at constructor.ts L182 and the same at constructor.ts L325). An accessor that caches a fetched object then writes into the caller's array as well. It is worth copying there too. The vocab-tools snapshots will need updating, and a regression test like the reproduction above would cover the change.
Generated vocabulary classes copy the trust sets in
clone()but not the property value arrays, which stay shared between the original and the clone (constructor.ts L261–L264). The object accessors cache what they fetch by writing into that array in place (property.ts L468, property.ts L640). A fetch on the clone therefore also changes the original, which was never asked to fetch anything.Reproduction
With @fedify/vocab 2.4.0:
The original now returns the fetched note. Its serialization also disagrees with itself. Its own
toJsonLd()still returns the cached original document, but the same object embedded in another activity serializes the fetched contents. The original's trust set doesn't include the replaced entry either, so withoutcrossOrigin: "trust"it dereferences the URL again.Impact
Hollo hit this while answering FEP-044f quote requests (fedify-dev/hollo#641). It resolves the request's
instrumenton a clone, then embeds the original request in theAcceptit sends back. Because of this bug, theAcceptechoed the fetched instrument instead of what the sender had sent. Hollo now works around it by re-parsing the request from JSON-LD rather than callingclone().Any code that clones an activity to inspect it while keeping the original for later use is affected. Common cases are forwarding, echoing a request back in a response, or signing.
Expected behavior
A clone should be independent of its source. Dereferencing through the clone shouldn't change what the original returns or how it serializes.
Suggested fix
In the generated
clone(), copy the arrays instead of sharing them:The constructor and
clone()also store a caller's plural-value array as-is (this.${fieldName} = values.${property.pluralName}at constructor.ts L182 and the same at constructor.ts L325). An accessor that caches a fetched object then writes into the caller's array as well. It is worth copying there too. The vocab-tools snapshots will need updating, and a regression test like the reproduction above would cover the change.