From fea90e3bf1793fb3bd2308773f4c0ddc961fafe0 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 8 Jul 2026 08:58:50 -0400 Subject: [PATCH] fix(nip05): always serve the well-known directory with CORS `*` (federation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-05 directory (/.well-known/nostr.json) is public, read-only, unauth'd data that MUST be cross-origin fetchable — any Nostr client on any domain resolves `user@this-instance` by fetching it. It was covered by the single global CORS layer, so it only got `Access-Control-Allow-Origin: *` when CORS_ALLOWED_ORIGINS was empty (the default). An operator who restricts CORS to their own frontend would silently break NIP-05 resolution for every other domain/wallet — federation dies for that instance with no error. Split CORS into two route groups: the public surface (well-known + health + landing) always serves `*`; the /api/v1 surface keeps the operator's configurable policy. The API's CORS behavior is unchanged (it still gets the same `cors` layer); only the public directory is now unconditionally cross-origin. cargo check clean. --- src/lib.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6435acf..1f7ce92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,19 +117,33 @@ pub fn build_router(state: Arc) -> Router { .layer(normal), ); - Router::new() + // The NIP-05 directory (+ health / landing) is PUBLIC, read-only, unauth'd + // data that MUST be cross-origin fetchable for federation to work — any Nostr + // client on any domain resolves `user@this-instance` by fetching the + // well-known. So it ALWAYS serves `Access-Control-Allow-Origin: *`, regardless + // of the operator's API CORS restriction (`CORS_ALLOWED_ORIGINS`). We apply the + // two policies to two route groups rather than one outer layer, so a restrictive + // API policy can't silently break NIP-05 resolution. + let public_cors = CorsLayer::new() + .allow_methods([Method::GET]) + .allow_origin(Any); + let public = Router::new() .route("/health", get(api::health::health)) .route("/", get(api::landing::root)) .merge(api::nip05::routes()) - .nest("/api/v1", api_v1) + .layer(public_cors); + + Router::new() + .merge(public) + .nest("/api/v1", api_v1.layer(cors)) .with_state(state) // Layers apply outside-in (last added = outermost). Body cap + timeout - // sit closest to the handlers; CORS + compression wrap them; tracing is - // outermost so it observes every request (incl. rejected ones). + // sit closest to the handlers; compression wraps them; tracing is + // outermost so it observes every request (incl. rejected ones). CORS is + // applied PER GROUP above (public → `*`, API → operator policy). // Per-IP rate limiting is layered separately (it needs ConnectInfo). .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) .layer(TimeoutLayer::new(REQUEST_TIMEOUT)) - .layer(cors) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) }