Read-only channel allowlist for NIP-42/43 viewers#896
Open
tlongwell-block wants to merge 6 commits into
Open
Conversation
added 3 commits
June 7, 2026 15:51
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
213e772 to
231c89f
Compare
added 3 commits
June 7, 2026 17:45
Mint a synthetic read-only viewer AuthContext for unauthenticated WebSocket REQ/COUNT when SPROUT_PUBLIC_VIEWER_CHANNELS is configured, scoped to exactly the listed channels. Empty (default) keeps public read disabled and rejects unauthenticated reads as before. The allowlist is config-driven (no DB schema): the public set is not identity, so it is not stored in relay_members. The synthetic context uses a reserved sentinel pubkey that is never authenticated, never looked up in relay_members, and never used for accounting; security comes entirely from the channel_ids pin plus read-only scopes. Public viewers' accessible set is the allowlist directly, bypassing the membership UNION (which would under-grant non-open channels). HTTP query/count stay NIP-98-gated; public read is WS-only. Reuses the existing #896 filter-scoping gate unchanged, so REQ/COUNT cannot read global, disallowed, or malformed-#h filters and cannot EVENT/write. Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Two fail-closed fixes to the unauthenticated public-viewer read path on the
NIP-42/43 gated relay, both reproduced live before and after:
1. Deleted-channel revival. Public viewers set accessible_channels directly
from SPROUT_PUBLIC_VIEWER_CHANNELS, bypassing the deleted_at IS NULL guard
every DB-backed path gets. Stale config could revive reads on a soft-deleted
channel. Add Db::filter_active_channel_ids and intersect the config allowlist
through it in the WS REQ/COUNT public-viewer branches. Fail closed on DB
error / empty set.
2. Global-subscription fanout leak. A restricted viewer's REQ naming distinct
allowed channels across filters ({#h:[A]} + {#h:[B]}) resolves to channel_id
== None and registered in the global fanout index, letting a stray-h-tagged
global event be live-delivered. Reject any restricted-viewer subscription
that does not pin to exactly one concrete allowed channel before
registration. (Builds on the existing per-filter multi-#h reject; the
distinct-filter case slipped past it.)
Tests: filter_active_channel_ids_excludes_deleted_and_unknown (Postgres),
restricted_viewer_distinct_allowed_channel_filters_resolve_global. Full
sprout-relay suite: 302 pass. Live breakout: control read OK; deleted-channel,
two-distinct-#h, and multi-#h all CLOSED with no secret/global delivery.
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
The HTTP bridge /query presence shortcut (synthesize_presence) ran before the per-filter channel-scope gate, so a restricted (viewer-role) caller could POST a presence filter (kind:20001/40902 + authors, no #h) and receive relay-signed presence for arbitrary pubkeys, bypassing the channel-scope rule. Presence is keyed by pubkey, not channel, so it cannot be channel-scoped. Fix: skip presence synthesis when restricted_to_channel_allowlist is set. The presence filter then falls through to filter_has_allowed_channel_scope, which rejects it (no allowed #h) with FORBIDDEN — fail closed. Full members are unaffected. Found by Perci and Dawn in the PR #896 security audit (authed-viewer channel-scope bypass; the unauthenticated WS path was never affected since verify_bridge_auth gates every bridge handler). Test: restricted_viewer_presence_filter_is_rejected. sprout-relay suite: 303 pass. Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Read-only channel allowlist for NIP-42/43 viewers
What
Adds a relay-level
viewerrole: a NIP-43 relay member who, upon NIP-42 auth,is granted read-only scopes restricted to an explicit per-viewer channel
allowlist. Viewers can read history, search, and receive live events for their
allowed channels only — and cannot publish anything, anywhere.
Why this is minimal & safe
The relay already had every enforcement seam this needs:
req.rs): WSREQintersects accessible channels withAuthContext.channel_ids. Thechannel_ids: Option<Vec<Uuid>>field alreadyexisted, documented "reserved for future per-channel access control" — this PR
is that future.
None= unrestricted (unchanged for normal members).ingest.rs): every event kind maps to a*:write/admin:*scope via
required_scope_for_kind; unknown kinds rejected. A connection whosescope set contains no write scopes cannot emit a single state-changing event.
join-request path (
9021, read-scoped) cannot escalate a viewer to a writer.So read-only is expressed as scopes, not as a new membership permission model.
That rides the existing, tested checks — it constructs a different
AuthContextat the auth call site rather than widening the enforcement surface.
Design
relay_members= admission. Unchanged.relay_member_channel_allowlist(pubkey, channel_id)table — the explicitper-viewer grant set (migration
0002,0001left immutable).handlers/auth.rs, aftercheck_relay_membershipsucceeds: if the member'srole is
viewer, build the context with read-only scopes(
messages:read, channels:read, users:read, files:read— no writes, no admin,no
proxy:submit) andchannel_ids = Some(<allowlist>). Normal members keepall_known()/channel_ids = None.vieweris added to therelay_members.roleTEXT CHECK constraint (not thechannel_members.member_roleenum — that's intentionally untouched;guestwas rejected because message writes are membership-binary today and
guestwould silently fall through to write behavior).
Membership-check refactor (why the diff touches several files)
The old
enforce_relay_membershipreturned only owner-or-deny — it could notexpress "admitted, but read-only and channel-restricted." It's replaced by
check_relay_membershipreturning aMembershipDecisionenum(
OpenRelay | Member | Viewer{channel_ids} | ViaOwner | ViaViewerOwner{owner,channel_ids} | Denied).Every entrypoint (WS auth, WS COUNT, HTTP
/query//count, bridge, git, media,mesh, audio) matches on this enum and handles viewers explicitly. The legacy
enforce_relay_membershipwrapper was deleted — apubhelper that admits aviewer while silently dropping the channel restriction is a fail-open footgun for
any future caller, so it's gone rather than left as dead code.
All entrypoints covered (not just WS)
"Zero enforcement change" is only true for WS unless HTTP mirrors it. This PR
applies the same restricted profile + fail-closed
#h-scope check across:WS
REQ, WSCOUNT, HTTP/query, HTTP/count, and bridge search/feed paths.Malformed/empty/unscoped/disallowed
#hfails closed for restricted viewers;search runs with
include_global=false. Viewers are denied git, media upload,mesh signaling, and relay admin; audio is bounded to an allowlisted parent channel.
Security audit (all closed)
channel_ids— a viewer can't register a sub outside the allowlist*:write/admin:*; membership ≠ scopeDecided policy (v1)
channels, no global subscriptions. Fail-closed.
This is channel presence, not listen-only media semantics; true listen-only is
a follow-up if product needs it.
viewers. No storage-layer channel ACL added here (no clean event→blob ownership
metadata to enforce against yet).
Testing
Verified independently on the reviewer's machine (Hermit toolchain):
bin/cargo fmt --check— cleanbin/cargo clippy -p sprout-relay -p sprout-auth -p sprout-db— zero warningsbin/cargo test -p sprout-relay— 296 passed, 0 failedbin/cargo test -p sprout-auth -p sprout-db— 36 + 64 passedUnit coverage: read-only scope set (all
:read, no write/proxy/repos); allowedchannel; disallowed channel; unscoped restricted filter; malformed
#h;unrestricted global behavior (normal members unaffected); viewer denied mesh;
viewer-owner delegation denied mesh.
Follow-up (not blocking the design)
end-to-end (the unit layer is covered; DB-backed admission is the remaining gap).
Co-developed by Max (implementation) and Eva (design/audit/review).