NIP-11 relay information document is not CORS-accessible when BUZZ_CORS_ORIGINS is set
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
### Summary
NIP-11 requires that the relay information document be readable cross-origin:
> Relays MUST accept CORS requests by sending `Access-Control-Allow-Origin`, `Access-Control-Allow-Headers`, and `Access-Control-Allow-Methods` headers.
When `BUZZ_CORS_ORIGINS` is set, the relay stops sending `Access-Control-Allow-Origin` for any origin outside the allowlist — including on `GET /` with `Accept: application/nostr+json` and on `GET /info`. Browser-based Nostr clients hosted anywhere else can no longer read the document.
This matters because **the official deployment guide tells operators to set that variable**: [`deploy/compose/.env.example`](https://github.com/block/buzz/blob/main/deploy/compose/.env.example#L13) ships `BUZZ_CORS_ORIGINS=https://buzz.example.com`. Following the documented VPS path therefore produces a relay that is non-conformant on this MUST.
### Reproduction
Deploy with `deploy/compose` and `BUZZ_CORS_ORIGINS=https://relay.example.com` (as `.env.example` prescribes), then:
```console
$ curl -sSI -H 'Origin: https://relay.example.com' -H 'Accept: application/nostr+json' https://relay.example.com/ | grep -i access-control
access-control-allow-origin: https://relay.example.com
$ curl -sSI -H 'Origin: https://some-web-client.example' -H 'Accept: application/nostr+json' https://relay.example.com/ | grep -i access-control
# (nothing — request succeeds with 200, but the browser blocks the read)
```
The response is `200` in both cases; only the CORS header differs, so this fails silently from the server's perspective.
Observed on `ghcr.io/block/buzz:0.2.1`.
### Cause
`build_cors_layer` is applied to the whole merged router, which includes the NIP-11 document route:
- [`router.rs:192`](https://github.com/block/buzz/blob/main/crates/buzz-relay/src/router.rs#L192) — `.layer(build_cors_layer(&state.config.cors_origins))` wraps every route
- [`router.rs:423-427`](https://github.com/block/buzz/blob/main/crates/buzz-relay/src/router.rs#L423) — `CorsLayer::permissive()` is returned **only** when the allowlist is empty; otherwise `AllowOrigin::list(origins)` applies to the relay-info endpoint too
So the allowlist that (correctly) protects the authenticated REST surface also narrows a document that is public by design.
### Suggested fix
Exempt the relay information document from the origin allowlist and always serve it with permissive CORS, while leaving `BUZZ_CORS_ORIGINS` governing the rest of the surface. Concretely: split the NIP-11 document route (`GET /` with `Accept: application/nostr+json`, and `GET /info`) into its own `Router` layered with `CorsLayer::permissive()` before merging, so operators get spec conformance without widening CORS on `/events`, `/query`, `/count` or the media endpoints.
The document contains no private data — capabilities, limits, and the relay's public key — so serving it to any origin does not weaken a closed relay. `auth_required`/`restricted_writes` continue to gate everything that matters.
Happy to open a PR if the approach sounds right.
### Not part of this report
Two things I checked and concluded are **not** bugs, in case they come up:
- The response `Content-Type` is `application/json` rather than `application/nostr+json`. NIP-11 constrains the request `Accept` header, not the response type, so this looks conformant.
- No `charset=utf-8` on that content type. Per RFC 8259, `application/json` defines no `charset` parameter and UTF-8 is implied; the bytes on the wire are valid UTF-8.
Contributor guide
Assessment
This issue has not been assessed yet.