fix(sdk): reject malformed mention pubkeys before signing p tags - #6372
fix(sdk): reject malformed mention pubkeys before signing p tags#6372holmes wants to merge 1 commit into
Conversation
`builders::mention_tags` lowercased whatever it was handed and pushed it straight into a `p` tag, so `""`, `"not-a-pubkey"`, `"../../etc/passwd"` and 63/65-character strings were all signed onto real events. A composer that mentioned someone this way looked successful while notifying nobody, and the malformed tag propagated to every relay and client that read it. Validate with the existing `check_pubkey_hex` inside `mention_tags`. That helper is the SDK's established structural pubkey contract (64 ASCII hex, returned lowercased) and already guards the other builders that emit `p` tags, so mentions now agree with the rest of the module and surface the existing `SdkError::InvalidInput`. Desktop's own `events.rs::mention_tags` already applies the identical rule, so this aligns the SDK with shipped behavior rather than inventing a new one. The check goes in the private `mention_tags` rather than the public `normalize_mention_pubkeys`: `mention_tags` is the single choke point shared by `build_message`, `build_forum_post` and `build_forum_comment`, and callers can hand those builders an explicit list without ever going through the normalizer. Fixing it here closes that bypass without changing a public signature. One malformed entry refuses the whole list instead of being filtered out, because a silently dropped mention is the failure being fixed. The cap check stays ahead of validation so an over-cap list still reports `TooManyMentions` rather than having that error masked by whichever entry happens to be malformed; both behaviors are pinned by tests. Adopting `nostr::PublicKey::from_hex`'s stricter on-curve check was considered and left out: it would change every `check_pubkey_hex` call site, which is a repo-wide consistency decision rather than part of this fix. Also enumerate `buzz-sdk` in `just test-unit` and the `run-tests.sh` fallback. The crate is a workspace member, so it was getting clippy and check, but no CI job ran a single one of its tests — verified with a control: a deliberately panicking buzz-sdk test still let `just test-unit` exit 0 and report "All tests passed!" before this change, and fails it after. Without those two mirrored entries the regression tests added here would never gate anything. Fixes block#6291 Signed-off-by: rust worker <cb8459a6e4c936835834cef03b8331fe806108dd03da97d39c7036a7d710e860@buzz.block.builderlab.xyz> Co-authored-by: Jason Holmes <holmes@squareup.com> Signed-off-by: Jason Holmes <holmes@squareup.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Non-blocking documentation correction: nostr::PublicKey::from_hex is not stricter and does not perform an on-curve check in nostr 0.44.7; it only hex-decodes 32 bytes into PublicKey. Curve validation happens only when PublicKey::xonly() is called. The implementation here is still correct for the SDK’s established structural 64-hex contract, but the mention_tags comment should describe the deferred xonly() check rather than attributing it to from_hex.
No blocking defects found at 5fe5929de39388cada354593ac875d3c42c3d830. I traced all three SDK builders and repository call sites, verified Desktop parity, cap/error precedence, case canonicalization, deduplication and tag ordering, and checked the test-runner additions against current main. cargo test -p buzz-sdk passed 267 tests; cargo fmt --check -p buzz-sdk, git diff --check, the file-size ratchet, and a conflict-free merge-tree with current origin/main also passed. GitHub CI remains unexecuted pending maintainer approval of the fork workflows.
Summary
buzz_sdk::builders::mention_tagslowercased whatever string it was handed and pushed it straight into aptag, so malformed mention pubkeys were signed onto real events. A composer that mentioned someone this way looked successful while notifying nobody, and the bad tag propagated to every relay and client that read it.Reproduced before fixing:
"","not-a-pubkey","zzzz","../../etc/passwd", and 63/65-character strings were all accepted and emitted verbatim asptags bybuild_messageandbuild_forum_post.Fixes #6291
Acceptance criteria → evidence
Following @kiranmagic7's recommendation on the issue:
mention_tags, not the publicnormalize_mention_pubkeyscrates/buzz-sdk/src/builders.rs— the single changed line insidefn mention_tags.normalize_mention_pubkeysis untouched, so no public signature changes.check_pubkey_hexhelperlet lower = check_pubkey_hex(hex, "mention pubkey")?;replaceshex.to_ascii_lowercase(). No new helper added.SdkError::InvalidInputcheck_pubkey_hexalready returnsInvalidInput; no new error variant is introduced.malformed_mention_pubkeys_are_refused_by_every_builderasserts againstbuild_message,build_forum_post, andbuild_forum_comment— all three sharemention_tags."","not-a-pubkey","zzzz","../../etc/passwd","a"*63,"a"*65, plus"z"*64and a 64-char path-like string so a length-only check cannot pass.valid_mentions_are_canonicalized_and_deduped_across_case— uppercase hex is accepted and the emittedptag is lowercase, across all three builders.ptag. Pre-existingmessage_mentions_dedupedstill passes.TooManyMentionsprecedence for >50too_many_mentions_takes_precedence_over_validation— a 51-entry list that also contains a malformed entry still returnsTooManyMentions. The cap check stays ahead of validation.mentions_at_the_cap_still_signpins that the boundary itself did not move.Justfile+scripts/run-tests.sh— see "Why the runner change is here".Out of scope by design: switching to
nostr::PublicKey::from_hex. Its stricter on-curve rule would change every one ofcheck_pubkey_hex's ~26 call sites, which is a repo-wide consistency decision rather than part of this fix. The doc comment records that reasoning.Why validation lives in
mention_tagsmention_tagsis the single choke point every mention reaches, shared by all three builders, and callers can hand those builders an explicit list without ever going throughnormalize_mention_pubkeys. Validating here closes that bypass without breaking the normalizer's public API.This also aligns the SDK with behavior already shipping elsewhere: desktop's own
events.rs::mention_tagsalready validates withcheck_pubkeyon the identical structural rule (64 ASCII hex). The SDK was the inconsistent one.One malformed entry refuses the whole list rather than being filtered out — a silently dropped mention is precisely the failure being fixed, and filtering would preserve it.
Why the runner change is here
Adding
buzz-sdktojust test-unitand the mirroredscripts/run-tests.shfallback is a CI-behavior change, so it deserves its own justification: without it the regression tests above would never run in CI.buzz-sdkis a workspace member, so it was gettingclippyandcheck, but no CI job executed a single one of its tests. This is the exact hazard theJustfilealready warns about for other crates — "nothing in CI runscargo test --workspace— workspace membership alone buys clippy/check, not a single executed test."Verified with a positive/negative control rather than by reading the config:
mainand a deliberately panickingbuzz-sdktest in the tree,just test-unitexited 0 and printed "All tests passed!". The crate name never appeared in the log.just test-unitexited 1 withbuzz-sdk tests FAILED.The sabotage test was removed afterward; it is not part of this PR. Both entries are needed because the two lists (nextest path and the no-nextest fallback) must stay in step. This is scoped strictly to making the crate's existing tests execute — no other CI behavior is touched.
Compatibility with #3036
#3036 (
fix/nip10-reply-p-tags) also editsmention_tags: it addsparent_author: Option<PublicKey>toThreadRef, emits a parent-authorptag inthread_tags, and seedsmention_tags's dedup set fromptags already ontags. This PR replaces only thelet lower = ...line inside the loop, so the two changes are compatible in substance. Mechanically, whichever lands second needs the trivial merge fixup: #3036 addsparent_author: Noneto everyThreadRefliteral, including the ones in the tests added here.One correction to the paragraph above, from an independent merge trial: the fixup is not only the
parent_author: Noneadditions. Merging #3036 into this head also produces one textual conflict incrates/buzz-sdk/src/builders.rs, confined to themention_tagsdoc comment — both branches rewrote it, this PR to explain the validation and #3036 to explain the seeded dedup set. The resolution is to keep both blocks; there is no logic conflict, since this PR changes thelet lower = ...line and #3036 changes theseeninitializer. For reference, merging #3036 into plainmainleavesbuilders.rsauto-merging cleanly, so this conflict is specific to the combination. Once the doc comment is resolved andparent_author: Noneis added to the three newThreadRefliterals here,buzz-sdkcompiles and the only failing test is #3036's ownself_reply_drops_the_parent_p_tag— which fails identically with #3036 merged intomainwithout this PR, becausebuild_messagegained.allow_self_tagging()in #4975 after #3036's merge base.Test plan
cargo test -p buzz-sdk— 267 passed, 0 failed (5 new tests; pre-existingmessage_mentions_deduped,message_too_many_mentions, and the three*_preserves_self_mention_p_tagtests still green)cargo fmt --all -- --check— cleanjust clippy(workspace,-D warnings) — cleanjust test-unit— all 10 steps pass, now includingbuzz-sdkjust file-size-check— passjust desktop-tauri-fmt-check/desktop-tauri-check/desktop-tauri-test— pass (2684 tests;desktop/src-tauriconsumesbuzz-sdk)just ci— the remaining steps are JS/Dart (desktop-test,desktop-build,web-build,mobile-test); this change touches only Rust + two runner scripts, so CI covers them hereAll gate results above were confirmed at commit
5fe5929de39388cada354593ac875d3c42c3d830.Open questions for maintainers
Out of draft and ready for review. Two calls I'd like from a maintainer:
buzz-sdktojust test-unitandscripts/run-tests.shis a CI-behavior change riding along with a bug fix. I kept it because without it the regression tests above never execute in CI (controls in the section above), but I'll split it into its own PR on request.check_pubkey_hexalone right for now? Switching tonostr::PublicKey::from_hexwould add an on-curve check but changes the contract at all ~26 call sites, which reads as a separate repo-wide decision rather than part of this fix.A note on CI
The only check reporting here is DCO Check (pass).
CI,Desktop Release Candidate, andDocker imageare all sitting inaction_requiredbecause this PR comes from a fork — a maintainer has to approve the workflow run before they execute. That's the standard first-time-contributor gate, not a failure, and I can't clear it from my side. Every gate result in the test plan above is from local runs at5fe5929de39388cada354593ac875d3c42c3d830, so CI has not independently confirmed them yet.