freenet 0.2.97

Freenet core software
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
//! Hosted-mode "export my data" HTTP endpoint (P3-live of #4381).
//!
//! `GET /v{1,2}/hosted/export` lets a hosted user download their per-user
//! delegate secrets — live, from the running node — as a single encrypted P3
//! [`crate::wasm_runtime::secret_export`] bundle (`FNSX` format). The user
//! re-imports it on their own peer with the token they already hold
//! (`freenet secrets import --use-token-key`), completing the
//! hosted → self-host migration.
//!
//! # Why an HTTP-only operation (no new wire variant)
//!
//! The bundle is raw bytes, not a `HostResponse`. Routing it through the
//! WebSocket `ClientRequest`/`HostResponse` path would force a new
//! freenet-stdlib wire-format variant (and the stdlib-first release dance).
//! Instead this is a self-contained freenet-core HTTP operation: the handler
//! runs the export on the node's executor (which owns the `SecretsStore`) via
//! [`crate::contract::ContractHandlerEvent::ExportUserSecrets`] and streams the
//! bytes straight back as an `application/octet-stream` download.
//!
//! # Security
//!
//! The endpoint returns a user's private secrets, so it applies the SAME gate
//! as the WebSocket `userToken` (the refuse-plaintext-token invariant): hosted
//! mode ON + a loopback source + `X-Forwarded-Proto: https`. The token is read
//! from the `X-Freenet-User-Token` HEADER (never a query param, so it cannot
//! land in an access log or the URL). The gate primitives
//! ([`decide_user_token`], [`is_loopback_source`], [`derive_user_context`]) are
//! shared verbatim with the WS middleware — this module only adds the
//! export-specific twist that a no-token / `Local` outcome is REJECTED (an
//! export of "no user scope" is meaningless here), whereas the WS path lets a
//! tokenless connection fall through to `Local`.
//!
//! ## Threat model: the token is the entire secret
//!
//! The per-user DEK and the bundle key are derived SOLELY from the user token
//! and are node-KEK-independent BY DESIGN (export portability — a self-hosting
//! user can decrypt their bundle on a fresh peer that never had the operator's
//! KEK). There is therefore NO node-side second factor: anyone who presents a
//! valid token over a secure connection gets that user's data. This is the
//! intended hosted model (the token already names the per-user namespace and
//! derives the storage DEK), but it means token confidentiality is the whole
//! ballgame — hence the strict refuse-plaintext-token gate above.
//!
//! ## DoS hardening — off-loop execution + per-user bound (#4381 P5)
//!
//! An export enumerates AND AEAD-decrypts EVERY secret in the user's scope. To
//! keep an AUTHENTICATED token-holder (valid token + secure connection) from
//! using a large or repeated export to wedge the node, two guards are in place:
//!
//! 1. **Off-loop execution.** The export no longer runs synchronously on the
//!    single-threaded contract-handling loop. The handler arm
//!    (`ContractHandlerEvent::ExportUserSecrets`) hands off to
//!    `RuntimePool::export_user_secrets`, which runs the synchronous
//!    enumerate+decrypt+re-encrypt on a blocking thread (`spawn_blocking`,
//!    runtime-flavor-gated like the WASM-compile offload of #4441). The loop is
//!    free to drain other PUT/GET/UPDATE/delegate ops while an export runs, so a
//!    single user's export can no longer block the node's contract loop.
//!
//! 2. **Per-user bound.** `secret_export::export_bundle` rejects — BEFORE the
//!    heavy work — any export exceeding `MAX_EXPORT_SECRET_COUNT` or
//!    `MAX_EXPORT_TOTAL_PLAINTEXT_BYTES`, bounding the worst-case work of a
//!    single export. The rejection surfaces here as HTTP 413.
//!
//! What remains for the broader P5 abuse work (#4381): per-user RATE limiting
//! across many requests over time (e.g. N exports/minute) and node-wide export
//! concurrency caps. The two guards above remove the head-of-line blocking and
//! bound a single request; a full quota/rate-limiting system is tracked
//! separately and is not required for the endpoint behind the default-off flag.

use axum::{
    Router,
    extract::ConnectInfo,
    http::{HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    routing::get,
};
use std::net::SocketAddr;
use std::sync::{Arc, OnceLock, Weak};

use crate::client_events::websocket::{UserTokenDecision, decide_user_token, derive_user_context};
use crate::node::OpManager;
use crate::wasm_runtime::UserSecretContext;

use super::ApiVersion;

/// Header carrying the hosted user's token. A HEADER (not a query param) so the
/// high-value credential never lands in the request URI / access logs. Mirrors
/// the WS path, which also accepts `X-Freenet-User-Token` (taking precedence
/// over the `userToken` query form).
const USER_TOKEN_HEADER: &str = "x-freenet-user-token";

/// Download filename + bundle extension. `.fnsx` matches the P3 bundle's `FNSX`
/// magic and the offline `freenet secrets export` default.
const DOWNLOAD_FILENAME: &str = "freenet-data.fnsx";

/// Per-node handle the export HTTP handler uses to reach the executor (which
/// owns the `SecretsStore`) through the contract handler.
///
/// Created and injected into the HTTP router as an `Extension` in
/// [`HttpClientApi::as_router_with_origin_contracts`](super::HttpClientApi)
/// (`client_api.rs`), with the SAME `Arc` also stored on the returned
/// `HttpClientApi`. The node fills it ONCE at startup via
/// [`ClientEventsProxy::set_op_manager`], called from `node::p2p_impl` on each
/// boxed client just before the `ClientEventsCombinator` consumes them — the
/// first place the live `op_manager` meets the client proxies.
///
/// `Weak` so a torn-down node does not keep its `OpManager` alive (and a stale
/// handle self-heals to a 503 rather than a use-after-free). Because each node
/// owns its OWN handle (it is an Extension on that node's router, not a
/// process-global), multiple hosted nodes in one process — e.g. parallel
/// integration tests — never clobber each other. The `OnceLock` makes the wiring
/// write-once (a second `set_op_manager` is ignored).
#[derive(Clone, Default)]
pub(crate) struct ExportOpManagerHandle(Arc<OnceLock<Weak<OpManager>>>);

impl ExportOpManagerHandle {
    /// Wire this handle to the running node's `OpManager`. Idempotent
    /// (write-once): a repeat call is ignored, so node startup can call it
    /// freely without racing.
    pub(crate) fn set(&self, op_manager: &Arc<OpManager>) {
        // OnceLock::set returns Err if already set; the first writer wins. A
        // repeat is benign (same node), so the duplicate is intentionally
        // ignored.
        if self.0.set(Arc::downgrade(op_manager)).is_err() {
            tracing::debug!("export op_manager handle already set; ignoring repeat wiring");
        }
    }

    /// Resolve the live `OpManager`, if the node is up and not torn down.
    ///
    /// `pub(crate)` so the live-import endpoint (`hosted_import`) can REUSE this
    /// same per-node handle to reach the executor (it submits an `ImportSecrets`
    /// event on the same op_manager the export endpoint uses).
    pub(crate) fn current(&self) -> Option<Arc<OpManager>> {
        self.0.get().and_then(Weak::upgrade)
    }
}

/// Registers the hosted-export route for `version`. The handler reaches the node
/// through the per-node [`ExportOpManagerHandle`] carried as a request
/// `Extension` (injected in `HttpClientApi::as_router_with_origin_contracts`).
pub(super) fn routes(version: ApiVersion) -> Router {
    let path = format!("/{}/hosted/export", version.prefix());
    Router::new().route(&path, get(export_handler))
}

/// Apply the hosted-mode token gate to an export request and, on success,
/// return the per-user secret context PLUS the raw token bytes (the bundle-key
/// material).
///
/// This reuses the WS gate verbatim ([`decide_user_token`] /
/// [`is_loopback_source`] / [`derive_user_context`]) so the export endpoint and
/// the WebSocket `userToken` honor IDENTICAL security rules. The one
/// difference, intrinsic to export: `Local` (no/empty token) is REJECTED here,
/// because exporting "no user scope" is meaningless — the user must present
/// their token. The WS path instead lets `Local` through (a tokenless
/// connection legitimately uses the single-user namespace).
///
/// On rejection returns `(StatusCode::FORBIDDEN, reason)`; the reason is a
/// fixed, non-secret string (never echoes the token).
pub(crate) fn export_user_context_or_reject(
    req_headers: &HeaderMap,
    source_ip: Option<std::net::IpAddr>,
    hosted_mode: bool,
) -> Result<(UserSecretContext, Vec<u8>), (StatusCode, &'static str)> {
    // Hosted mode is the master switch. Off ⇒ no per-user namespaces exist, so
    // there is nothing to export under a user scope. Reject (do NOT export the
    // node-local secrets to an unauthenticated caller).
    if !hosted_mode {
        return Err((
            StatusCode::FORBIDDEN,
            "hosted-mode export is disabled on this node",
        ));
    }

    // Token from the HEADER only — never a query param (keeps the credential
    // out of the URI / access logs).
    let token = req_headers
        .get(USER_TOKEN_HEADER)
        .and_then(|v| v.to_str().ok())
        .map(str::to_owned);
    let xfp_https = req_headers
        .get("x-forwarded-proto")
        .and_then(|v| v.to_str().ok())
        .is_some_and(|v| v.eq_ignore_ascii_case("https"));

    // `has_token` is a NON-EMPTY check, matching `derive_user_context`'s
    // empty-is-absent rule (an empty header is treated as no token).
    let has_token = token.as_deref().is_some_and(|t| !t.is_empty());

    match decide_user_token(hosted_mode, has_token, source_ip, xfp_https) {
        UserTokenDecision::Honor => {
            // `derive_user_context` returns `Some` here (Honor implies hosted +
            // non-empty token); the token is the bundle-key material.
            let token = token.expect("Honor decision implies a non-empty token");
            let ctx = derive_user_context(hosted_mode, Some(token.as_str()))
                .expect("derive_user_context returns Some for a honored hosted non-empty token");
            Ok((ctx, token.into_bytes()))
        }
        UserTokenDecision::RejectInsecure => Err((
            StatusCode::FORBIDDEN,
            "hosted user token requires a secure (TLS/loopback) connection",
        )),
        UserTokenDecision::Local => Err((
            StatusCode::FORBIDDEN,
            "hosted export requires a user token (X-Freenet-User-Token header)",
        )),
    }
}

/// Decide whether this export is over the per-user export rate limit.
///
/// Factored out of the handler so the throttle decision is unit-testable
/// without standing up a node. Returns `true` to REJECT (over-rate). A `None`
/// limiter (standalone composition / export limiting disabled) never rejects.
/// On an admitted export this RECORDS the export time (advances the per-user
/// interval clock), so the call has a side effect and must run exactly once per
/// request.
///
/// `pub(crate)` so the magic-link migration mint endpoint (`hosted_migrate`),
/// which runs the SAME per-user export, shares the identical throttle.
pub(crate) fn export_rate_limited(
    limiter: Option<&crate::client_events::user_op_rate_limit::UserOpRateLimiter>,
    user_context: &UserSecretContext,
) -> bool {
    match limiter {
        Some(limiter) => !limiter.try_acquire_export(user_context.user_id()),
        None => false,
    }
}

/// `GET /v{1,2}/hosted/export` — export this hosted user's per-user delegate
/// secrets as an encrypted bundle download.
///
/// Takes the whole `Request` (rather than typed `Extension`/`ConnectInfo`
/// extractors) so it can read `HostedMode` and the `ConnectInfo<SocketAddr>`
/// source from `extensions()` directly — the same approach `connection_info`
/// uses, and necessary because this crate builds `axum` without the feature
/// that provides the `ConnectInfo` extractor.
///
/// PERFORMANCE / DoS (#4381 P5): the export is bounded (per-user secret-count /
/// byte cap, returned here as 413) AND runs OFF the contract-handling loop on a
/// blocking thread, so a single authenticated token-holder's export can neither
/// do unbounded work nor stall other contract ops. See the module-level
/// "DoS hardening" section for what remains (cross-request rate limiting).
async fn export_handler(req: axum::extract::Request) -> Response {
    // Tolerant: the standalone `as_router` composition path has no `HostedMode`
    // layer, so a missing extension means hosted-off ⇒ the gate below 403s (it
    // never silently exports). The WS gate stays strict (fail-loud) precisely
    // because there a dropped flag could put users on a shared namespace; here a
    // dropped flag only ever DENIES, so failing safe-to-off is correct.
    let hosted_mode = req
        .extensions()
        .get::<crate::server::HostedMode>()
        .map(|hm| hm.0)
        .unwrap_or(false);

    // Source IP from the `ConnectInfo<SocketAddr>` request extension that
    // `into_make_service_with_connect_info::<SocketAddr>()` injects for every
    // connection (kernel-set from the accepted socket; not spoofable off-host).
    // Read from `extensions()` rather than as a typed extractor — same as
    // `connection_info` — so a missing ConnectInfo (only in unit tests that omit
    // it) yields `None` and the gate fails closed (cannot prove loopback).
    let source_ip = req
        .extensions()
        .get::<ConnectInfo<SocketAddr>>()
        .map(|ci| ci.0.ip());

    let req_headers = req.headers();

    let (user_context, token) =
        match export_user_context_or_reject(req_headers, source_ip, hosted_mode) {
            Ok(v) => v,
            Err((status, reason)) => {
                // Do NOT log the token. Log only the non-secret source IP and
                // the rejection reason.
                tracing::warn!(
                    source_ip = ?source_ip,
                    reason,
                    "Rejected hosted export request"
                );
                return (status, reason).into_response();
            }
        };

    // Per-user export rate limit (#4561, P5 of #4381). Export enumerates AND
    // re-encrypts every secret in the user's scope, so it is far more expensive
    // than a single op and gets a SEPARATE, tighter limit (min-interval, not a
    // token bucket). Checked right AFTER the token gate — so only an
    // authenticated token-holder is throttled, keyed by the gate-derived
    // (unforgeable) `user_id` — and BEFORE op_manager resolution so a flooding
    // client gets the 429 back-off signal regardless of node readiness, and a
    // rejected request never reaches the executor. The limiter Extension is
    // layered by `serve_client_api_in_impl`; absence (standalone composition)
    // ⇒ no export limiting. Over-rate ⇒ HTTP 429.
    let limiter = req
        .extensions()
        .get::<crate::client_events::user_op_rate_limit::UserOpRateLimiter>();
    if export_rate_limited(limiter, &user_context) {
        tracing::warn!(
            user_id = ?user_context.user_id(),
            "Rejected hosted export: per-user export rate limit exceeded"
        );
        return (
            StatusCode::TOO_MANY_REQUESTS,
            "export rate limit exceeded; retry shortly",
        )
            .into_response();
    }

    // Per-node route to the executor, carried as an Extension and filled by the
    // node at startup. Absent (standalone `as_router` composition with no node)
    // or not-yet-filled (startup race) ⇒ 503.
    let op_manager = req
        .extensions()
        .get::<ExportOpManagerHandle>()
        .and_then(ExportOpManagerHandle::current);
    let Some(op_manager) = op_manager else {
        tracing::warn!("Hosted export requested but no running node is registered");
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            "node is not ready to serve exports",
        )
            .into_response();
    };

    match run_export(&op_manager, user_context, token).await {
        Ok(bundle) => {
            // `Content-Length` is set by axum from the body. Force a download
            // with a stable filename; octet-stream so browsers don't sniff.
            (
                StatusCode::OK,
                [
                    (axum::http::header::CONTENT_TYPE, "application/octet-stream"),
                    (
                        axum::http::header::CONTENT_DISPOSITION,
                        // Static filename, no user input interpolated, so no
                        // header-injection surface.
                        &format!("attachment; filename=\"{DOWNLOAD_FILENAME}\""),
                    ),
                ],
                bundle,
            )
                .into_response()
        }
        Err(status_and_msg) => status_and_msg.into_response(),
    }
}

/// Drive the export on the node's executor and return the bundle bytes.
///
/// The `token` is moved into a redacted, zeroizing wrapper on the handler event
/// so it is wiped after the bundle key is derived and never reaches a log.
///
/// `pub(crate)` so the migration mint endpoint (`hosted_migrate`) reuses the
/// exact same executor round-trip, passing a FRESH EPHEMERAL key as the
/// `token` (bundle-key material) instead of the durable user token — see the
/// `export_user_secrets` rustdoc on why scope and key are decoupled.
pub(crate) async fn run_export(
    op_manager: &OpManager,
    user_context: UserSecretContext,
    token: Vec<u8>,
) -> Result<Vec<u8>, (StatusCode, &'static str)> {
    use crate::contract::ContractHandlerEvent;

    let event = ContractHandlerEvent::ExportUserSecrets {
        user_context,
        token: crate::contract::RedactedToken::new(token),
    };

    // Hosted export is initiated by a local HTTP client → ClientLocal lane (#4534).
    match op_manager
        .notify_contract_handler_prioritized(event, crate::contract::Priority::ClientLocal)
        .await
    {
        Ok(ContractHandlerEvent::ExportUserSecretsResponse(Ok(bundle))) => Ok(bundle),
        Ok(ContractHandlerEvent::ExportUserSecretsResponse(Err(e))) => {
            // An over-limit export is a client condition, not a node fault:
            // surface it as 413 (Payload Too Large) so the caller can tell it
            // apart from a genuine 500. The message is non-secret (sizes only).
            // See the per-user export bound in `secret_export` (#4381 P5).
            if e.is_export_too_large() {
                tracing::warn!(error = %e, "Rejected hosted export: over per-user limit");
                return Err((
                    StatusCode::PAYLOAD_TOO_LARGE,
                    "export exceeds the per-user size limit",
                ));
            }
            // The node is at its concurrent-export cap: a transient "retry
            // later", not a failure. 503 so the caller can distinguish it.
            if e.is_export_busy() {
                tracing::warn!(error = %e, "Rejected hosted export: node busy (concurrent-export cap)");
                return Err((
                    StatusCode::SERVICE_UNAVAILABLE,
                    "node is busy with other exports; retry shortly",
                ));
            }
            // Executor-side failure (e.g. a secret failed to decrypt). Do not
            // leak internals to the client; log the detail, return a generic 500.
            tracing::error!(error = %e, "Hosted export failed on the executor");
            Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                "export failed on the node",
            ))
        }
        Ok(other) => {
            tracing::error!(
                response = %other,
                "Unexpected contract-handler response to ExportUserSecrets"
            );
            Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                "export failed on the node",
            ))
        }
        Err(e) => {
            tracing::error!(error = %e, "Contract handler unavailable for hosted export");
            Err((
                StatusCode::SERVICE_UNAVAILABLE,
                "node is not ready to serve exports",
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::client_events::websocket::is_loopback_source;
    use axum::http::HeaderValue;
    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

    fn headers_with(token: Option<&str>, xfp: Option<&str>) -> HeaderMap {
        let mut h = HeaderMap::new();
        if let Some(t) = token {
            h.insert(USER_TOKEN_HEADER, HeaderValue::from_str(t).unwrap());
        }
        if let Some(x) = xfp {
            h.insert("x-forwarded-proto", HeaderValue::from_str(x).unwrap());
        }
        h
    }

    const LOOPBACK: Option<IpAddr> = Some(IpAddr::V4(Ipv4Addr::LOCALHOST));

    #[test]
    fn gate_honors_secure_request_and_returns_token_bytes() {
        let headers = headers_with(Some("tok-abc"), Some("https"));
        let (ctx, token) =
            export_user_context_or_reject(&headers, LOOPBACK, true).expect("must honor");
        // The returned token bytes are the raw header value (the bundle key).
        assert_eq!(token, b"tok-abc");
        // The context scopes to the same token's derived user id.
        let expected = UserSecretContext::from_token(b"tok-abc");
        assert_eq!(ctx.user_id(), expected.user_id());
    }

    #[test]
    fn gate_rejects_when_hosted_mode_off() {
        let headers = headers_with(Some("tok-abc"), Some("https"));
        let err = export_user_context_or_reject(&headers, LOOPBACK, false).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    #[test]
    fn gate_rejects_missing_token() {
        let headers = headers_with(None, Some("https"));
        let err = export_user_context_or_reject(&headers, LOOPBACK, true).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    #[test]
    fn gate_rejects_empty_token() {
        // An empty header is treated as no token (matches derive_user_context).
        let headers = headers_with(Some(""), Some("https"));
        let err = export_user_context_or_reject(&headers, LOOPBACK, true).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    #[test]
    fn gate_rejects_token_without_https() {
        // Loopback but no X-Forwarded-Proto: https ⇒ RejectInsecure.
        let headers = headers_with(Some("tok-abc"), None);
        let err = export_user_context_or_reject(&headers, LOOPBACK, true).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    #[test]
    fn gate_rejects_token_from_non_loopback_source() {
        // A token arriving from off-host is never honored, even with XFP:https
        // (the header is client-spoofable from a non-loopback source).
        let headers = headers_with(Some("tok-abc"), Some("https"));
        let public = Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)));
        let err = export_user_context_or_reject(&headers, public, true).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    #[test]
    fn gate_rejects_missing_source_ip() {
        // No ConnectInfo ⇒ cannot prove loopback ⇒ fail closed.
        let headers = headers_with(Some("tok-abc"), Some("https"));
        let err = export_user_context_or_reject(&headers, None, true).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    #[test]
    fn gate_honors_ipv4_mapped_loopback() {
        // `::ffff:127.0.0.1` from a dual-stack socket normalizes to loopback.
        let headers = headers_with(Some("tok-abc"), Some("https"));
        let addr = IpAddr::V6(Ipv4Addr::LOCALHOST.to_ipv6_mapped());
        let mapped = Some(addr);
        assert!(is_loopback_source(addr));
        let (_, token) =
            export_user_context_or_reject(&headers, mapped, true).expect("mapped loopback honored");
        assert_eq!(token, b"tok-abc");
    }

    #[test]
    fn gate_rejects_ipv6_non_loopback() {
        let headers = headers_with(Some("tok-abc"), Some("https"));
        let public6 = Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)));
        let err = export_user_context_or_reject(&headers, public6, true).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    // ---- Per-user EXPORT rate limit (#4561) -------------------------------

    use crate::client_events::user_op_rate_limit::UserOpRateLimiter;

    /// No limiter (standalone composition / export limiting off) never rejects,
    /// no matter how many exports the same user requests.
    #[test]
    fn export_rate_limit_absent_limiter_never_rejects() {
        let ctx = UserSecretContext::from_token(b"tok-export");
        for _ in 0..10 {
            assert!(
                !export_rate_limited(None, &ctx),
                "absent limiter must never reject"
            );
        }
    }

    /// With a tight export interval the FIRST export passes (not limited) and an
    /// immediate SECOND export from the same user is rejected (over-rate). This
    /// is the 429 path the handler maps.
    #[test]
    fn export_rate_limit_second_immediate_export_rejected() {
        // 60s min interval, op rate/burst irrelevant to export limiting.
        let limiter = UserOpRateLimiter::new(10, 50, 60);
        let ctx = UserSecretContext::from_token(b"tok-export");
        assert!(
            !export_rate_limited(Some(&limiter), &ctx),
            "first export must pass"
        );
        assert!(
            export_rate_limited(Some(&limiter), &ctx),
            "immediate second export must be rejected (429 path)"
        );
    }

    /// Export interval is per-user: throttling user A does not throttle user B.
    #[test]
    fn export_rate_limit_is_per_user() {
        let limiter = UserOpRateLimiter::new(10, 50, 60);
        let a = UserSecretContext::from_token(b"user-a");
        let b = UserSecretContext::from_token(b"user-b");
        assert!(!export_rate_limited(Some(&limiter), &a), "A first passes");
        assert!(export_rate_limited(Some(&limiter), &a), "A throttled");
        assert!(
            !export_rate_limited(Some(&limiter), &b),
            "B independent of A"
        );
    }

    /// Export interval of 0 disables export limiting: a flood always passes.
    #[test]
    fn export_rate_limit_disabled_passes_all() {
        let limiter = UserOpRateLimiter::new(10, 50, 0);
        let ctx = UserSecretContext::from_token(b"tok-export");
        for _ in 0..10 {
            assert!(
                !export_rate_limited(Some(&limiter), &ctx),
                "export limiting disabled (0): every export passes"
            );
        }
    }
}