Skip to content

Verification picks by signedPropertyNames without checking the record against it, so a record can skip crosspost recursion #249

Description

@Rinse12

Verification picks the record down to signature.signedPropertyNames before checking anything. A field present in the bytes but absent from that list is dropped and never inspected, while remaining in what gets stored and rendered. signedPropertyNames lives inside signature, which is not part of the signed bytes, so the list is chosen by whoever signs the record.

So what gets verified is a strict subset of what clients display, and the record chooses the subset.

The concrete exploit today: crosspost

Leave crosspost out of signedPropertyNames on a record that has one. pick() drops it, so _verifyCrosspost is never called for that level. The nested embedded record then gets no cid check, no signature check and no reserved-field check, while still being present in the bytes, persisted to the db, and available for a client to render by walking comment.crosspost.comment.crosspost.comment.

The outer record's own cid stays consistent, since it hashes the whole record either way, so check 1 of tier 1 passes.

This is the sharp case because crosspost is the only field whose presence in the picked object triggers verification of a subtree signed by a different key. Every other field is one the attacker already controls.

Two ways such a record reaches a client:

Why the other fields are not exploits

Worth writing down, since it is the first place this investigation went wrong.

Shrinking signedPropertyNames on someone else's genuine record changes the encoded bytes, so their signature stops verifying. The attacker therefore always signs with a key they control, which means they can set every field value anyway. Leaving a field unsigned changes accountability, not capability.

  • content / title / link — the attacker authored them either way. The only delta is repudiation.
  • author — impersonation via author.name is not gated by the signature even when author is signed. It is gated by name resolution, which runs in the background and sets nameResolved (src/clients/base-client-manager.ts, src/publications/comment/comment.ts), outside verification entirely. Note author.address is runtime-only, derived as name || publicKey, and is in AuthorReservedFields, so it is never on the wire. See docs/protocol/wire-vs-runtime.md.
  • communityName / communityPublicKey — still just an author claim, resolved at tier 2 either way.

The general hazard is forward-looking: any field added later whose verification is gated on it being present will inherit the crosspost bypass unless this is fixed.

Mechanism

_verifyJsonSignature (src/signer/signatures.ts) rebuilds the signed object by reading signedPropertyNames off the record itself. JsonSignatureSchema types it as z.string().array() with no refinement pinning it to CommentSignedPropertyNames.

The check that closes this is _allFieldsOfRecordInSignedPropertyNames, and it only helps when it runs on the un-picked record:

  • Community acceptance: covered. respondWithErrorIfSignatureOfPublicationIsInvalid passes request.comment un-picked, so the guard fires (ERR_COMMENT_PUBSUB_RECORD_INCLUDES_FIELD_NOT_IN_SIGNED_PROPERTY_NAMES). An honest community never mints a cid for such a record.
  • Client verification: not covered. verifyCommentIpfs and _verifyCrosspost both pick(record, ["signature", ...signedPropertyNames]) before delegating, because a legitimate CommentIpfs carries community-generated fields (depth, thumbnailUrl*, previousCid, pseudonymityMode) that no author signs. After the pick the guard can never fail.

On the normal load path the cid's provenance substitutes for the guard, since the cid came from a community-signed page or CommentUpdate and the community ran the guard. For an embedded crosspost record there is no substitute, because tier 1 is offline by design.

Candidate fix

A targeted guard on both picking paths, over the signable set only:

for (const name of CommentSignedPropertyNames)
    if (record[name] != null && !record.signature.signedPropertyNames.includes(name))
        return { valid: false, reason: messages.ERR_... };

Targeted rather than a blanket allowlist: every read site uses CommentIpfsSchema.loose() and backward.compatibility.comment.test.ts pins that unknown extra props survive a load, so a future protocol version can add fields without old clients rejecting records. Restricting the rule to CommentSignedPropertyNames keeps that intact.

Safe for old records, since it only fires when a signable field is present and unsigned. A record predating a field does not carry it. Author-signed extra props already land in signedPropertyNames.

A UI warning is not a substitute for the crosspost case specifically: the library never recursed, so there is no signal for a client to surface without reimplementing _verifyCrosspost.

Open questions

  1. Fix just the crosspost bypass in _verifyCrosspost, or the general subset problem on every picking path?
  2. Reject, or expose a signal? Rejecting is a client-visible behavior change.
  3. Apply to Vote / CommentEdit / CommunityEdit too?
  4. Constrain signedPropertyNames at the schema level instead, so a list that is not a subset of the canonical set fails parsing? Broader wire-format decision.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions