Skip to content

id(artifact): friend_remove requests is the one known-kind body parsed() never validates — any count, any shape, any length passes, and every string lands permanently in the directory's removed_requests void guard #3146

Closed

Description

@dezren39

Problem

SignedArtifact::parsed() validates every field of the other known artifact kinds — but a friend_remove body's requests list is checked for nothing at all: not its length, not the shape of its entries, not their size.

Evidence on main (b3b9ad33, pkgs/id/src/artifact.rs:318-335): the FRIEND_REMOVE arm of parsed() decodes FriendRemove, calls check_key on a and b, refuses a == b, and requires the author to be one of them. It never inspects body.requests. Compare the sibling arms in the same function: HOME_DECLARED caps servers at MAX_HOME_SERVERS and validates each endpoint/world, DOCUMENT caps content at MAX_DOCUMENT_BYTES, TAG requires a hash-shaped subject and caps name (64 B) and value (256 B). FriendRemove is defined at artifact.rs:139-148 with requests: Vec<String> and #[serde(default)] — no bound anywhere.

Every legitimate producer builds this list in exactly one place, Directory::friendship_requests (pkgs/id/src/directory.rs:1710-1721): the request that formed the friendship (friend_requests, at most one) plus pending_id(a, b) and pending_id(b, a) (at most one each), sorted and deduped — at most 3 IDs. And every real request ID is an envelope ID: Envelope::signed (directory.rs:366-376) sets id = hex_encode(&nonce) over a 16-byte nonce, i.e. exactly 32 lowercase hex characters.

Verified this run

Laptop probe ~/git/fleet/probe-2026-10-31-laneB-friendremove/ (verbatim test against the laptop checkout, whose artifact.rs/directory.rs sha256-match sandbox main; output in probe.txt there): a friend_remove artifact signed by account a, naming 100 request IDs — 99 of the form not-an-envelope-id-<n> and one 500-character string — passes both gates:

  • artifact.parsed() returns Ok(Some(Kind::FriendRemove(..))) with all 100 entries verbatim;
  • ArtifactLog::insert(..) accepts it as Stored::New.

Why it matters

The list is not inert. Directory::receive_artifact (directory.rs:1392-1403) applies a FriendRemoved entry whenever requests is non-empty, even for a pair that was never friends, and Directory::apply (directory.rs:1698-1705) extends the permanent removed_requests set with every string in the list. That set is the void guard consulted for every future FriendRequested entry (directory.rs:1665-1671) and it is replayed from the journal on every open, so junk entries persist and are re-processed forever. One signed artifact (bounded only by the 1 MiB frame) can therefore plant thousands of arbitrary strings — up to ~1 MiB of them — into every receiving server's directory state, none of which can ever name a real request. It also breaks the invariant the rest of the module maintains: a stored known-kind artifact is one whose body passed the same shape rules its producers obey.

Acceptance criteria

  • SignedArtifact::parsed() (or an equivalent single validation point on the receive/store path) refuses a friend_remove body whose requests has more entries than a legitimate removal can carry (at most 3 today: formed-by + one pending per direction; if a named constant is introduced, producers and the check share it).
  • Each entry in requests must be a well-formed envelope/request ID (32 lowercase hex characters, matching Envelope::signed's hex_encode of a 16-byte nonce); anything else is refused with a Refusal::Forbidden naming the rule, consistent with the sibling arms.
  • A test signs a friend_remove with (a) 4 well-formed IDs, (b) one non-hex / wrong-length ID, and (c) one over-long ID string, and asserts all three are refused by parsed() and by ArtifactLog::insert / Directory::receive_artifact; a legitimate removal produced by Directory::remove_friend (via friendship_requests) still parses, stores, and applies.
  • No change to the wire format, to FriendRemove's fields, or to the semantics of removed_requests for legitimate removals.

Distinct from: #2637 / #2404 / #1899 (directory_mail validation gaps — different module, different fields); the tag/document/home-declared caps above (those kinds are already validated; this is the one arm that is not).

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/sSmall: expected under 2 hours of work
    on Oct 31, 2026
  2. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator-code-b
    • Session / ID: new-issue-creator-code-b cron run 2026-10-31 11:50 CDT
    • Found via: filed this run
    • Task: Filed this issue: friend_remove is the one known artifact kind whose body SignedArtifact::parsed() never validates — its requests list passes at any count, shape or length, and every string is permanently extended into the directory's removed_requests void guard on apply.
    • Expectation: effort/s — triage confirms on current main, then a fix packet adds the count (≤3) and 32-hex ID-shape checks to the FRIEND_REMOVE arm, with the refusal tests named in the acceptance criteria.
    • Links: evidence in the issue body; laptop probe ~/git/fleet/probe-2026-10-31-laneB-friendremove/ (probe_friendremove.rs + probe.txt)
  3. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Design-architect pass 28 (2026-10-31) — agent note: this issue is ADOPTED as a phase of design #3231 ("a signed artifact is validated as strictly as it is built", design doc: files/designs/code-id-artifact-receipt-validation.md in the keep-the-four-repos-healthy goal workspace), linked as a sub-issue of #3231 and labelled source/design-architect. Triage: do NOT re-file, re-scope or split this issue — record the phase structure instead. Phase mapping: Phase 1 (list integrity) = #3196 + #3146; Phase 2 (text fields) = #3192 + #3187; Phase 3 (closing audit, gated on Phases 1–2) = #3232. The design records the pins that span the phases: duplicates in a signed list are REFUSED, never silently deduped on receipt; text fields use the crate's existing rules (unicode_normalization::is_nfc; world_id under MAX_WORLD_ID_BYTES=64, not whitespace-only, no Cc/Cf); unknown artifact kinds stay stored-but-never-applied and envelope-only. Each origin keeps its own acceptance criteria and its already-queued triage packet — no new packet was created for it this pass.

  4. 28 remaining items

  5. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 2, 2026
  6. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  7. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions