harden proxy auth and event ordering
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire workspace (~35k lines, 18 crates + `desktop` + `web`)
|
||||
|
||||
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
|
||||
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
|
||||
> See §9 for the per-fix log.
|
||||
|
||||
## 1. Scope and Method
|
||||
|
||||
Every crate was audited file-by-file against the project conventions (`AGENTS.md`), Rust best
|
||||
@@ -823,3 +827,81 @@ claims were verified against the vendored `gpui-pre 0.3.12`, `gpui-base 0.9.1`,
|
||||
`gpui-component 0.9.1`, and rust-nostr sources; the Critical and top-High findings were
|
||||
independently re-verified by hand against the working tree at `8fa79aef`. No files were modified
|
||||
during the audit.*
|
||||
|
||||
## 9. P0 Remediation Log (2026-11-12)
|
||||
|
||||
All ten P0 items fixed. Validation after the full batch: `cargo clippy --workspace --all-targets`
|
||||
zero warnings, `cargo fmt --all --check` clean, `cargo test --workspace` 35 passed / 0 failed.
|
||||
Diff: 15 files, +816/−273.
|
||||
|
||||
1. **nip60 deletion spoofing** — `nip60/src/lib.rs`: precedence fixed to
|
||||
`ours && (is_cashu || is_token_deletion(..))`; `absorb`/`absorb_deletion` now take `me` and
|
||||
reject foreign-authored kind 5 events (defense in depth; the live notification gate was the
|
||||
only protection before, and `absorb_deletion` had no author check). The DB load path was
|
||||
already author-filtered and is unchanged.
|
||||
2. **browser-signer-proxy hardening** — per-session UUID token generated at startup, embedded in
|
||||
the served page (`inject_session_token`), required on `/api/*` (403 otherwise); exact `Host`
|
||||
match (DNS-rebinding defense); all CORS headers and the OPTIONS preflight removed;
|
||||
`cancel_request` removes timed-out requests from both maps so plaintext is dropped;
|
||||
centralized best-effort `write_response`. `proxy.js` authenticates all three fetches. Token
|
||||
never appears in the URL the app opens.
|
||||
3. **state startup crash loop** — `NostrLmdb::open` failure now logs and falls back to
|
||||
`MemoryDatabase::unbounded()` (nostr-memory added to the desktop deps), emitting
|
||||
`StateEvent::error` via the existing deferred path; the `block_on` stays (safe: the LMDB open
|
||||
schedules nothing back onto the GPUI executor — now commented).
|
||||
4. **state web localStorage key** — `import_bunker` on wasm32 no longer persists the bunker
|
||||
credential (session is memory-only; desktop keyring unchanged); `restore_session` degrades to
|
||||
`NoSigner` when nothing is stored (verified). **Known behavior change: web users re-enter their
|
||||
bunker URI after each reload.**
|
||||
5. **state file-cache collision** — cache filename now derived from the decrypted content's
|
||||
sha256 instead of the filename; the metadata guard is gone and every store goes through
|
||||
temp-write + atomic rename.
|
||||
6. **nip60 reconcile money-safety** — `ops.rs::reconcile` rewritten: relay tokens are converged
|
||||
into the store BEFORE any publish (closes the `del`-before-converge race); relay proofs already
|
||||
tracked locally (unspent or in-flight Pending/Reserved/PendingSpent) are never re-added, and
|
||||
unseen proofs are checked against the mint (NUT-08 `check_proofs_spent`, best-effort) before
|
||||
being accepted as Unspent; when a prior job errored after mutating the store
|
||||
(`publish_failed` flag), reconcile republishes the store's unspent set instead of trusting
|
||||
relay state — healing stranded sends/receives; without the flag the relay-trust removal path
|
||||
(cross-device spend convergence) is preserved. A failing Reconcile does not reschedule itself
|
||||
(no infinite loop).
|
||||
7. **nip60 stuck nutzap redemptions** — `apply_outcome` now receives the failed job; on
|
||||
`RedeemNutzap` error the id is removed from `redeeming` and requeued up to
|
||||
`MAX_REDEEM_ATTEMPTS = 3` (cleared on success, reset on signer change), so transient mint
|
||||
failures no longer wedge redemptions until restart.
|
||||
8. **Reactions double-count** — `chat_ui::insert_reaction` replaces per (author, target) per
|
||||
NIP-25, making the backup gift-wrap echo idempotent (verified: optimistic rumor and the
|
||||
re-delivered seal carry the same event id) and history reloads non-duplicating.
|
||||
9. **Whitespace collapse** — `common/media_extractor.rs` rewritten: whitespace is tidied only at
|
||||
URL-removal boundaries; line breaks in untouched regions survive; regex hoisted to
|
||||
`LazyLock`; 9 unit tests added (multi-line preservation, URL-only messages, digit/emoji edges).
|
||||
10. **Contact-list data loss** — `contact_list.rs` now parses the stored event into full
|
||||
`nostr::nips::nip02::Contact` values (relay hint + alias preserved through load → add/remove
|
||||
→ republish); new contacts still get `Contact::new`.
|
||||
11. **Speech worker lifetime** — `parakeet.rs`: the `OnceLock<Sender>` became a resettable
|
||||
`Mutex<Option<Sender>>`; a dead worker (30 s idle exit) is detected via `is_disconnected`, the
|
||||
slot cleared, and a replacement thread spawned lazily on the next command; spawn failure no
|
||||
longer caches a dead sender; failures surface through `sink.error` / returned `Err` instead
|
||||
of a debug-level swallow (one retry covers the liveness-check/send race).
|
||||
12. **Settings durability** — a failed load logs and keeps defaults in memory without
|
||||
persisting (`suppress_next_save`, consumed by the next real change); fresh-install NotFound is
|
||||
no longer conflated with read/parse failures; saves are serialized through a single
|
||||
`save_task` slot (chained, FIFO order) with temp-file + atomic rename; serialization and
|
||||
write failures are logged.
|
||||
13. **nip29 ordering guards** — shared `is_newer_event` (strict `(created_at, id)` total order):
|
||||
`Group::absorb_state` keeps a per-kind stamp (written only after a successful apply, so a
|
||||
malformed newer event can't poison the guard) and rejects stale replays while ascending cache
|
||||
hydration still applies; the registry's `list_stamp` seeds from the cached list event at
|
||||
startup, is checked in `merge` before applying, updated by `publish_list` (closing the
|
||||
leave→resurrect window), and reset on signer change. 3 new tests across protocol/group.
|
||||
14. **nip60_ui invoice flow** — *not* in this batch: it is P1 (the audit lists it as High but the
|
||||
P0 list ranks it 16th via `nip60_ui/wallet.rs`); `mint_quotes()`/`claim_mint_quote()` remain
|
||||
dead until the P1 UI work lands. No other P0 code paths depend on it.
|
||||
|
||||
Behavior changes to be aware of when testing:
|
||||
- Web: bunker credentials are no longer persisted (re-connect after each reload).
|
||||
- Desktop: a corrupted/unreadable local database now starts with an empty in-memory history and
|
||||
shows an error event instead of crash-looping; relays re-sync as before.
|
||||
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
|
||||
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
|
||||
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.
|
||||
|
||||
Reference in New Issue
Block a user