-
-
Notifications
You must be signed in to change notification settings - Fork 99
Normalize outgoing JSON-LD for Pixelfed interop #721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
f2ebf3d
Normalize outgoing JSON-LD for Pixelfed interop
dahlia 895f30e
Avoid needless attachment canonization
dahlia 9774a7b
Tighten attachment normalization fast path
dahlia e7aced1
Skip JSON-LD value payloads in attachment walker
dahlia 076b7bb
Clarify activity transformer configuration
dahlia 94292cc
Avoid array clones in attachment walker
dahlia dae75bc
Defer normalized proof verification fallback
dahlia a4d8901
Preserve pre-signed activity proofs
dahlia 8ddd745
Avoid canonicalizing overly deep JSON-LD
dahlia 297413c
Harden JSON-LD normalization
dahlia 5cc2fbc
Expose proof normalization opt-in
dahlia ae6f220
Tighten JSON-LD proof normalization
dahlia 359d113
Harden JSON-LD context safety checks
dahlia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| import { mockDocumentLoader, test } from "@fedify/fixture"; | ||
| import { Create, Document, Note, PUBLIC_COLLECTION } from "@fedify/vocab"; | ||
| import { assertEquals } from "@std/assert/assert-equals"; | ||
| import { | ||
| normalizeAttachmentArrays, | ||
| normalizeOutgoingActivityJsonLd, | ||
| } from "./outgoing-jsonld.ts"; | ||
|
|
||
| test("normalizeAttachmentArrays() wraps scalar attachments", async () => { | ||
| const input = { | ||
| "@context": "https://www.w3.org/ns/activitystreams", | ||
| type: "Create", | ||
| object: { | ||
| type: "Note", | ||
| attachment: { | ||
| type: "Document", | ||
| mediaType: "image/png", | ||
| url: "https://example.com/image.png", | ||
| }, | ||
| }, | ||
| }; | ||
| const output = await normalizeAttachmentArrays(input) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| const object = output.object as Record<string, unknown>; | ||
| assertEquals(object.attachment, [ | ||
| { | ||
| type: "Document", | ||
| mediaType: "image/png", | ||
| url: "https://example.com/image.png", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| test("normalizeAttachmentArrays() skips canonicalization for ActivityStreams-only context", async () => { | ||
| const input = { | ||
| "@context": "https://www.w3.org/ns/activitystreams", | ||
| type: "Note", | ||
| attachment: { | ||
| type: "Document", | ||
| mediaType: "image/png", | ||
| url: "https://example.com/image.png", | ||
| }, | ||
| }; | ||
| const output = await normalizeAttachmentArrays(input, () => { | ||
| throw new Error("context loader should not be called"); | ||
| }) as Record<string, unknown>; | ||
| assertEquals(output.attachment, [ | ||
| { | ||
| type: "Document", | ||
| mediaType: "image/png", | ||
| url: "https://example.com/image.png", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| test("normalizeAttachmentArrays() leaves attachment arrays unchanged", async () => { | ||
| const attachment = [ | ||
| { | ||
| type: "Document", | ||
| mediaType: "image/png", | ||
| url: "https://example.com/image.png", | ||
| }, | ||
| ]; | ||
| const input = { | ||
| "@context": "https://www.w3.org/ns/activitystreams", | ||
| type: "Note", | ||
| attachment, | ||
| }; | ||
| const output = await normalizeAttachmentArrays(input) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| assertEquals(output.attachment, attachment); | ||
| }); | ||
|
|
||
| test("normalizeAttachmentArrays() leaves documents without attachments unchanged", async () => { | ||
| const input = { | ||
| "@context": "https://www.w3.org/ns/activitystreams", | ||
| type: "Note", | ||
| content: "Hello", | ||
| }; | ||
| assertEquals(await normalizeAttachmentArrays(input), input); | ||
| }); | ||
|
|
||
| test("normalizeAttachmentArrays() leaves @context subtrees untouched", async () => { | ||
| const input = { | ||
| "@context": [ | ||
| "https://www.w3.org/ns/activitystreams", | ||
| { attachment: "https://example.com/custom-attachment" }, | ||
| ], | ||
| type: "Note", | ||
| attachment: "https://example.com/attachment", | ||
| }; | ||
| const output = await normalizeAttachmentArrays(input) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| const context = output["@context"] as unknown[]; | ||
| assertEquals(context[1], { | ||
| attachment: "https://example.com/custom-attachment", | ||
| }); | ||
| assertEquals(output.attachment, ["https://example.com/attachment"]); | ||
| }); | ||
|
|
||
| test("normalizeAttachmentArrays() bails out when wrapping changes semantics", async () => { | ||
| const input = { | ||
| "@context": { | ||
| attachment: { | ||
| "@id": "https://example.com/custom-attachment", | ||
| "@type": "@json", | ||
| }, | ||
| }, | ||
| attachment: { | ||
| custom: true, | ||
| }, | ||
| }; | ||
| const output = await normalizeAttachmentArrays(input) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| assertEquals(output.attachment, { custom: true }); | ||
| }); | ||
|
|
||
| test("normalizeAttachmentArrays() does not poison the global prototype via a __proto__ key", async () => { | ||
| const input = JSON.parse(`{ | ||
| "@context": "https://www.w3.org/ns/activitystreams", | ||
| "type": "Note", | ||
| "attachment": { "type": "Document" }, | ||
| "__proto__": { "polluted": true } | ||
| }`); | ||
| await normalizeAttachmentArrays(input); | ||
| assertEquals( | ||
| (Object.prototype as Record<string, unknown>).polluted, | ||
| undefined, | ||
| ); | ||
| }); | ||
|
|
||
| test("normalizeAttachmentArrays() stops before blowing the stack on pathological nesting", async () => { | ||
| let deep: Record<string, unknown> = { attachment: { type: "Document" } }; | ||
| for (let i = 0; i < 256; i++) deep = { object: deep }; | ||
| const input = { | ||
| "@context": "https://www.w3.org/ns/activitystreams", | ||
| type: "Create", | ||
| object: deep, | ||
| }; | ||
| const output = await normalizeAttachmentArrays(input); | ||
| assertEquals(typeof output, "object"); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| test("normalizeOutgoingActivityJsonLd() applies outgoing JSON-LD workarounds", async () => { | ||
| const activity = new Create({ | ||
| id: new URL("https://example.com/activities/1"), | ||
| actor: new URL("https://example.com/alice"), | ||
| object: new Note({ | ||
| id: new URL("https://example.com/notes/1"), | ||
| tos: [PUBLIC_COLLECTION], | ||
| attachments: [ | ||
| new Document({ | ||
| mediaType: "image/png", | ||
| url: new URL("https://example.com/image.png"), | ||
| }), | ||
| ], | ||
| }), | ||
| tos: [PUBLIC_COLLECTION], | ||
| }); | ||
| const compact = await activity.toJsonLd({ format: "compact" }) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| assertEquals(compact.to, "as:Public"); | ||
| const compactObject = compact.object as Record<string, unknown>; | ||
| assertEquals(Array.isArray(compactObject.attachment), false); | ||
|
|
||
| const normalized = await normalizeOutgoingActivityJsonLd( | ||
| compact, | ||
| mockDocumentLoader, | ||
| ) as Record<string, unknown>; | ||
| assertEquals(normalized.to, PUBLIC_COLLECTION.href); | ||
| const normalizedObject = normalized.object as Record<string, unknown>; | ||
| assertEquals(Array.isArray(normalizedObject.attachment), true); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| import { type DocumentLoader, preloadedContexts } from "@fedify/vocab-runtime"; | ||
| import jsonld from "@fedify/vocab-runtime/jsonld"; | ||
| import { getLogger } from "@logtape/logtape"; | ||
| import { normalizePublicAudience } from "./public-audience.ts"; | ||
|
|
||
| const logger = getLogger(["fedify", "compat", "outgoing-jsonld"]); | ||
|
|
||
| const ATTACHMENT_FIELDS = new Set([ | ||
| "attachment", | ||
| "https://www.w3.org/ns/activitystreams#attachment", | ||
| ]); | ||
|
|
||
| const AS_CONTEXT_URL = "https://www.w3.org/ns/activitystreams"; | ||
|
|
||
| // Keep the traversal bounded for adversarial JSON-LD passed through proof | ||
| // verification fallback paths. | ||
| const MAX_TRAVERSAL_DEPTH = 64; | ||
|
dahlia marked this conversation as resolved.
|
||
|
|
||
| const preloadedOnlyDocumentLoader: DocumentLoader = (url: string) => { | ||
| if (Object.hasOwn(preloadedContexts, url)) { | ||
| return Promise.resolve({ | ||
| contextUrl: null, | ||
| documentUrl: url, | ||
| document: preloadedContexts[url], | ||
| }); | ||
| } | ||
| return Promise.reject( | ||
| new Error( | ||
| "Refusing to fetch a non-preloaded JSON-LD context: " + url, | ||
| ), | ||
| ); | ||
| }; | ||
|
dahlia marked this conversation as resolved.
Outdated
|
||
|
|
||
| /** | ||
| * Wraps scalar ActivityStreams attachment properties in arrays. | ||
| */ | ||
| function wrapScalarAttachments( | ||
| jsonLd: unknown, | ||
| depth: number = 0, | ||
| ): unknown { | ||
| if (depth >= MAX_TRAVERSAL_DEPTH) return jsonLd; | ||
|
|
||
| if (Array.isArray(jsonLd)) { | ||
| let changed = false; | ||
| const normalized = jsonLd.map((item) => { | ||
| const next = wrapScalarAttachments(item, depth + 1); | ||
| if (next !== item) changed = true; | ||
| return next; | ||
| }); | ||
| return changed ? normalized : jsonLd; | ||
|
dahlia marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| if (typeof jsonLd !== "object" || jsonLd == null) return jsonLd; | ||
|
|
||
| const record = jsonLd as Record<string, unknown>; | ||
| let changed = false; | ||
| const normalized: Record<string, unknown> = Object.create(null); | ||
| for (const key of Object.keys(record)) { | ||
| const value = record[key]; | ||
| const next = key === "@context" | ||
| ? value | ||
| : wrapScalarAttachments(value, depth + 1); | ||
|
dahlia marked this conversation as resolved.
|
||
| if ( | ||
| ATTACHMENT_FIELDS.has(key) && | ||
| next != null && | ||
| !Array.isArray(next) | ||
| ) { | ||
| normalized[key] = [next]; | ||
| changed = true; | ||
| } else { | ||
|
dahlia marked this conversation as resolved.
Outdated
|
||
| normalized[key] = next; | ||
| if (next !== value) changed = true; | ||
| } | ||
| } | ||
|
|
||
| return changed ? normalized : jsonLd; | ||
|
dahlia marked this conversation as resolved.
Outdated
|
||
| } | ||
|
dahlia marked this conversation as resolved.
|
||
|
|
||
| function hasNestedContext(value: unknown, depth: number = 0): boolean { | ||
| 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 as Record<string, unknown>; | ||
| for (const key of Object.keys(record)) { | ||
| if (key === "@context") return true; | ||
|
dahlia marked this conversation as resolved.
|
||
| if (hasNestedContext(record[key], depth + 1)) return true; | ||
| } | ||
| return false; | ||
| } | ||
|
dahlia marked this conversation as resolved.
|
||
|
|
||
| function hasActivityStreamsOnlyContext(jsonLd: unknown): boolean { | ||
| if (typeof jsonLd !== "object" || jsonLd == null) return false; | ||
| const record = jsonLd as Record<string, unknown>; | ||
| if (!Object.hasOwn(record, "@context")) return false; | ||
| const context = record["@context"]; | ||
| const entries = typeof context === "string" | ||
| ? [context] | ||
| : Array.isArray(context) | ||
| ? context | ||
| : null; | ||
| if (entries == null || entries.length < 1) return false; | ||
| if (!entries.every((entry) => entry === AS_CONTEXT_URL)) return false; | ||
|
dahlia marked this conversation as resolved.
Outdated
|
||
| for (const key of Object.keys(record)) { | ||
| if (key === "@context") continue; | ||
| if (hasNestedContext(record[key])) return false; | ||
| } | ||
| return true; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Ensures ActivityStreams attachment properties are represented as arrays | ||
| * when doing so preserves the JSON-LD semantics. | ||
| * | ||
| * JSON-LD compaction collapses single-item arrays into scalar values by | ||
| * default. Some ActivityPub implementations, Pixelfed among them, parse | ||
| * `attachment` as a plain JSON array rather than a JSON-LD property and reject | ||
| * otherwise valid objects whose single attachment is emitted as a scalar. | ||
| */ | ||
| export async function normalizeAttachmentArrays( | ||
| jsonLd: unknown, | ||
| contextLoader?: DocumentLoader, | ||
| ): Promise<unknown> { | ||
| const normalized = wrapScalarAttachments(jsonLd); | ||
| if (normalized === jsonLd) return jsonLd; | ||
| if (hasActivityStreamsOnlyContext(jsonLd)) return normalized; | ||
| const loader = contextLoader ?? preloadedOnlyDocumentLoader; | ||
| try { | ||
| const [before, after] = await Promise.all([ | ||
| jsonld.canonize(jsonLd, { | ||
| format: "application/n-quads", | ||
| documentLoader: loader, | ||
| }), | ||
|
dahlia marked this conversation as resolved.
|
||
| jsonld.canonize(normalized, { | ||
| format: "application/n-quads", | ||
| documentLoader: loader, | ||
| }), | ||
| ]); | ||
|
dahlia marked this conversation as resolved.
|
||
| if (before === after) return normalized; | ||
| logger.warn( | ||
|
dahlia marked this conversation as resolved.
|
||
| "Wrapping scalar attachment values in arrays would change the " + | ||
| "canonical form of the JSON-LD document; leaving it unchanged. " + | ||
| "This usually means the active JSON-LD context redefines the " + | ||
| "`attachment` term.", | ||
| ); | ||
| } catch (error) { | ||
| logger.debug( | ||
| "Failed to verify attachment array normalization equivalence via " + | ||
| "JSON-LD canonicalization; leaving the JSON-LD document " + | ||
| "unchanged.\n{error}", | ||
| { error }, | ||
| ); | ||
| } | ||
| return jsonLd; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Applies Fedify's internal JSON-LD wire-format interoperability workarounds | ||
| * to locally generated outgoing activities before they are signed, enqueued, | ||
| * or sent. | ||
| */ | ||
| export async function normalizeOutgoingActivityJsonLd( | ||
| jsonLd: unknown, | ||
| contextLoader?: DocumentLoader, | ||
| ): Promise<unknown> { | ||
| jsonLd = await normalizePublicAudience(jsonLd, contextLoader); | ||
| return await normalizeAttachmentArrays(jsonLd, contextLoader); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.