freenet 0.2.94

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
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
//! Live "import my data" HTTP endpoint (P3-live of #4592, CHUNK 1).
//!
//! `POST /v{1,2}/import` writes delegate secrets from an encrypted P3
//! [`crate::wasm_runtime::secret_export`] bundle (`FNSX` format) into THIS
//! node's local single-user [`crate::wasm_runtime::secrets_store::SecretsStore`]
//! — LIVE, while the node keeps running. It is the durable counterpart of the
//! live EXPORT (`hosted_export`) and the backend for both the magic-link pull
//! and the file-upload fallback of #4592.
//!
//! # Why route through the running node (not open ReDb directly)
//!
//! The offline `freenet secrets import` CLI opens the secrets ReDb directly,
//! which is single-writer — so it requires the node to be STOPPED. This endpoint
//! instead submits a [`crate::contract::ContractHandlerEvent::ImportSecrets`]
//! event to the running node's contract loop, which routes it to the ONE
//! legitimate `SecretsStore` (owned by a pooled executor). Unlike the read-only
//! export (which runs off-loop), the import runs ON the contract loop (see
//! `RuntimePool::import_secrets`): it WRITES, and the store write path assumes
//! node-wide write serialization, so on-loop keeps every secret write in one
//! serialization domain (with delegate `store_secret`). The brief loop-block is
//! acceptable because this endpoint is loopback + dashboard-gated (a one-shot
//! operator migration), not the authenticated-remote DoS surface of the export.
//!
//! # Security — the import GATE (fail-closed)
//!
//! This endpoint WRITES secrets, so it is the highest-value write surface in the
//! HTTP API. The receiving node is a NORMAL single-user node (hosted mode OFF)
//! importing into [`crate::wasm_runtime::secrets_store::SecretScope::Local`], so
//! it does NOT reuse the hosted-export token gate (which needs hosted ON + a
//! per-user token). Instead it must be reachable ONLY from the node's own
//! dashboard/shell origin over loopback — NEVER from a per-contract `AuthToken`
//! origin or a sandboxed contract iframe. The gate ([`import_gate_or_reject`])
//! enforces, in order:
//!
//! 1. **Loopback source IP**, read from the kernel-set `ConnectInfo<SocketAddr>`
//!    (not spoofable off-host). A MISSING `ConnectInfo` (only in unit tests that
//!    omit it) fails CLOSED — we cannot prove loopback, so we reject. Mirrors
//!    the export handler's source-IP read.
//! 2. **Trusted dashboard/shell origin**, via the SAME
//!    [`super::permission_prompts::is_origin_trusted`] decision the
//!    permission-prompt CSRF guard uses: the `Origin` header must be present and
//!    match a loopback / LAN-CIDR / allowed-host origin. That helper REJECTS
//!    `Origin: null`, which is exactly what a sandboxed contract iframe presents
//!    (the sandbox omits `allow-same-origin`), so the per-contract iframe is
//!    excluded here.
//! 3. **No per-contract `AuthToken`** (best-effort defense-in-depth, NOT the
//!    primary iframe exclusion). A per-contract webapp's shell page is
//!    same-origin with the node, so it would pass check #2; it carries a
//!    per-contract `AuthToken` registered in the [`OriginContractMap`], while the
//!    node's own first-party dashboard carries none. So a request presenting a
//!    registered origin-contract token — via the `Authorization` bearer header,
//!    the `authToken` query param, or the `Authorization` cookie — is rejected.
//!    But this only CLASSIFIES a *well-behaved* client that actually presents its
//!    token: a hostile script could simply omit the token to evade this check, so
//!    #3 cannot be relied on to exclude a malicious contract web app on its own.
//!
//! The load-bearing exclusion of sandboxed contract iframes is **check #2**: the
//! sandbox omits `allow-same-origin`, so the iframe presents `Origin: null`,
//! which `is_origin_trusted` rejects. Check #3 is an extra layer that catches a
//! cooperative same-origin shell page; it does NOT backstop #2. A future
//! maintainer MUST NOT weaken check #2 (e.g. start admitting `Origin: null` or a
//! cross-site origin) on the belief that #3 will still keep contract web apps
//! out — it will not.
//!
//! The bundle's decryption key is the real authorization for the DATA: a caller
//! who passes the gate still cannot import anything they cannot decrypt. The
//! decrypt is all-or-nothing and happens BEFORE any write
//! ([`crate::wasm_runtime::secret_export::import_bundle`] → `open_bundle`), so a
//! wrong key surfaces as a 4xx with NOTHING written — never a 500, never a
//! partial write. The key material rides in a HEADER (never the URL/logs), in a
//! redacted/zeroizing wrapper, and is never logged.

use axum::{
    Json, Router,
    extract::{ConnectInfo, DefaultBodyLimit},
    http::{HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    routing::post,
};
use std::net::{IpAddr, SocketAddr};

use crate::client_events::AuthToken;
use crate::client_events::websocket::is_loopback_source;
use crate::contract::{
    BundleKeyKind, ContractHandlerEvent, ImportBundle, ImportTargetScope, Priority, RedactedToken,
};
use crate::node::OpManager;
use crate::server::{AllowedHosts, AllowedSourceCidrs};
use crate::wasm_runtime::secret_export::MAX_EXPORT_TOTAL_PLAINTEXT_BYTES;

use super::hosted_export::ExportOpManagerHandle;
use super::permission_prompts::is_origin_trusted;
use super::{ApiVersion, OriginContractMap};

/// Header carrying the bundle decryption key (the token, or passphrase). A
/// HEADER (not a query param) so this high-value secret never lands in the
/// request URI / access logs — same discipline as the export's
/// `X-Freenet-User-Token`.
pub(crate) const BUNDLE_KEY_HEADER: &str = "x-freenet-bundle-key";

/// Header selecting how to interpret [`BUNDLE_KEY_HEADER`]: `token` (default,
/// the hosted-export form) or `passphrase` (the offline `--passphrase` form).
///
/// `pub(crate)` (with [`BUNDLE_KEY_HEADER`]) so the migration `pull` endpoint
/// (`hosted_migrate`) returns the ephemeral bundle key under the SAME header
/// names the local `pull-import` then forwards into the import path.
pub(crate) const BUNDLE_KEY_KIND_HEADER: &str = "x-freenet-bundle-key-kind";

/// Header controlling collision handling: `true` overwrites an existing secret,
/// anything else (default) skips + reports it. Off unless explicitly opted in.
const OVERWRITE_HEADER: &str = "x-freenet-import-overwrite";

/// `authToken` cookie / bearer name used by the per-contract web shell (see
/// `client_api.rs` — the cookie is set under the `Authorization` header name,
/// lowercased on the wire).
const AUTHORIZATION_NAME: &str = "authorization";

/// Request-body cap for the uploaded bundle, sized to accept ANY bundle the
/// existing `export_bundle` can produce (so a valid max-size export is never
/// rejected with a spurious 413), while still bounding what one import buffers.
///
/// The export limit ([`MAX_EXPORT_TOTAL_PLAINTEXT_BYTES`]) counts DECRYPTED
/// plaintext bytes, but the bundle body is CBOR-serialized BEFORE encryption and
/// `secret_export` encodes each secret's `Vec<u8>` as a CBOR ARRAY OF INTEGERS,
/// not a byte string (a deliberate choice — see its `BundleEntry` note), so a
/// plaintext byte costs up to 2 bytes on the wire. A legitimate export can thus
/// serialize to ~2x its plaintext, plus per-entry delegate-key/code-hash/
/// secret-hash CBOR arrays (bounded by `MAX_EXPORT_SECRET_COUNT`) and the small
/// header + AEAD tag. We size the cap to `2x plaintext + 64 MiB` — the doubling
/// covers the CBOR integer-array overhead and the 64 MiB margin generously
/// covers the per-entry key/hash overhead (~200 B x up to 10k entries) plus
/// framing. Over the cap → HTTP 413.
pub(crate) const MAX_IMPORT_BUNDLE_BYTES: usize =
    2 * MAX_EXPORT_TOTAL_PLAINTEXT_BYTES + 64 * 1024 * 1024;

/// Registers the live-import route for `version`. The handler reaches the node
/// through the per-node [`ExportOpManagerHandle`] carried as a request
/// `Extension` (the SAME handle the export endpoint uses, injected in
/// `HttpClientApi::as_router_with_origin_contracts`). A [`DefaultBodyLimit`] of
/// [`MAX_IMPORT_BUNDLE_BYTES`] bounds the uploaded bundle.
pub(super) fn routes(version: ApiVersion) -> Router {
    let path = format!("/{}/import", version.prefix());
    Router::new()
        .route(&path, post(import_handler))
        .layer(DefaultBodyLimit::max(MAX_IMPORT_BUNDLE_BYTES))
}

/// Apply the live-import gate. Pure + unit-testable: takes the request's headers,
/// the kernel-set source IP, the host/CIDR allowlists, the (raw) query string,
/// and the origin-contract map, and decides whether the request may import.
///
/// On rejection returns `(StatusCode::FORBIDDEN, reason)` with a fixed,
/// non-secret reason string (never echoes the key or any token). See the module
/// docs for the three layered checks. FAIL-CLOSED throughout.
///
/// `pub(crate)` so the magic-link migration's local `pull-import` endpoint
/// (`hosted_migrate`) — which also WRITES secrets into the local store from a
/// same-origin dashboard POST — reuses this EXACT gate rather than duplicating
/// (and drifting from) the loopback + trusted-origin + no-contract-token checks.
pub(crate) fn import_gate_or_reject(
    headers: &HeaderMap,
    source_ip: Option<IpAddr>,
    allowed_hosts: Option<&AllowedHosts>,
    allowed_source_cidrs: Option<&AllowedSourceCidrs>,
    query: Option<&str>,
    origin_contracts: &OriginContractMap,
) -> Result<(), (StatusCode, &'static str)> {
    // (1) Loopback source. A missing ConnectInfo cannot prove loopback ⇒ reject
    // (fail closed), mirroring the export handler.
    let Some(ip) = source_ip else {
        return Err((
            StatusCode::FORBIDDEN,
            "import requires a loopback connection",
        ));
    };
    if !is_loopback_source(ip) {
        return Err((
            StatusCode::FORBIDDEN,
            "import requires a loopback connection",
        ));
    }

    // (2) Trusted dashboard/shell origin. The Origin header must be present and
    // pass the shared `is_origin_trusted` decision, which rejects `Origin: null`
    // (the sandboxed contract iframe) and cross-site origins, and requires a
    // loopback / LAN-CIDR / allowed-host origin.
    let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
        return Err((
            StatusCode::FORBIDDEN,
            "import requires a trusted same-origin request (missing Origin)",
        ));
    };
    if !is_origin_trusted(headers, origin, allowed_hosts, allowed_source_cidrs) {
        return Err((
            StatusCode::FORBIDDEN,
            "import is only available from the node's own dashboard origin",
        ));
    }

    // (3) Exclude per-contract AuthToken origins. The node's first-party
    // dashboard carries no per-contract token; a per-contract web shell does.
    // Reject if ANY presented credential (bearer header / authToken query /
    // Authorization cookie) is a registered origin-contract token.
    if presented_tokens(headers, query).any(|tok| origin_contracts.contains_key(&tok)) {
        return Err((
            StatusCode::FORBIDDEN,
            "import is not available to contract web apps",
        ));
    }

    // DEFERRED (Sec-Fetch-Site hardening, #4592 follow-up): browsers attach
    // `Sec-Fetch-Site: same-origin` to a first-party fetch and `cross-site` to a
    // cross-origin one. Checking it would add a fetch-metadata CSRF layer on top
    // of the Origin gate (#2). We do NOT enforce it here on purpose: the header
    // is browser-only, so a legitimate non-browser client (a future
    // `freenet secrets import --live` CLI, curl in a migration script) omits it
    // entirely. "Reject when missing" would break those clients; "enforce only
    // when present" is weaker and needs a deliberate call. Left as a follow-up
    // decision rather than a silent default. Until then the Origin gate (#2) is
    // the CSRF boundary.

    Ok(())
}

/// Collect every `AuthToken` candidate the request presents, across the three
/// channels a per-contract web shell could carry one on: the `Authorization`
/// bearer header, the `authToken` query param, and the `Authorization` cookie
/// (value `Bearer <token>`). Pure (no map lookup) so it is unit-testable on its
/// own; the gate checks each against the origin-contract map.
fn presented_tokens<'a>(
    headers: &'a HeaderMap,
    query: Option<&'a str>,
) -> impl Iterator<Item = AuthToken> + 'a {
    let mut tokens: Vec<AuthToken> = Vec::new();

    // (a) Authorization: Bearer <token>
    if let Some(v) = headers
        .get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        && let Some(tok) = strip_bearer(v)
    {
        tokens.push(AuthToken::from(tok.to_owned()));
    }

    // (b) ?authToken=<token>
    if let Some(q) = query {
        for (k, val) in url_query_pairs(q) {
            if k == "authToken" && !val.is_empty() {
                tokens.push(AuthToken::from(val));
            }
        }
    }

    // (c) Cookie: authorization=Bearer <token> (the per-contract shell cookie).
    if let Some(cookies) = headers
        .get(axum::http::header::COOKIE)
        .and_then(|v| v.to_str().ok())
    {
        for part in cookies.split(';') {
            let part = part.trim();
            if let Some((name, value)) = part.split_once('=')
                && name.trim().eq_ignore_ascii_case(AUTHORIZATION_NAME)
            {
                // The value may be `Bearer <tok>` (possibly quoted / %20-encoded
                // for the space). Strip a Bearer prefix in any of those forms;
                // otherwise treat the whole value as the token.
                let value = value.trim().trim_matches('"');
                let tok = strip_bearer(value)
                    .or_else(|| value.strip_prefix("Bearer%20"))
                    .unwrap_or(value);
                if !tok.is_empty() {
                    tokens.push(AuthToken::from(tok.to_owned()));
                }
            }
        }
    }

    tokens.into_iter()
}

/// Strip a `Bearer ` (case-insensitive, single ASCII space) prefix from an
/// `Authorization` value, returning the bare token. `None` if the value is not a
/// bearer credential.
fn strip_bearer(value: &str) -> Option<&str> {
    let rest = value.strip_prefix("Bearer ").or_else(|| {
        value
            .get(..7)
            .filter(|p| p.eq_ignore_ascii_case("Bearer "))
            .map(|_| &value[7..])
    })?;
    Some(rest.trim())
}

/// Minimal `application/x-www-form-urlencoded` query splitter for the `authToken`
/// lookup. Does NOT percent-decode (the gate only needs to MATCH a token, and a
/// percent-encoded token would simply not match a registered one — fail safe).
fn url_query_pairs(query: &str) -> impl Iterator<Item = (&str, String)> {
    query.split('&').filter_map(|pair| {
        let (k, v) = pair.split_once('=')?;
        Some((k, v.to_owned()))
    })
}

/// Parse the bundle-key-kind header. Default [`BundleKeyKind::Token`] (the
/// hosted-export form). An unrecognized value is a client error (400).
fn parse_key_kind(headers: &HeaderMap) -> Result<BundleKeyKind, (StatusCode, &'static str)> {
    match headers
        .get(BUNDLE_KEY_KIND_HEADER)
        .and_then(|v| v.to_str().ok())
    {
        None => Ok(BundleKeyKind::Token),
        Some(s) if s.eq_ignore_ascii_case("token") => Ok(BundleKeyKind::Token),
        Some(s) if s.eq_ignore_ascii_case("passphrase") => Ok(BundleKeyKind::Passphrase),
        Some(_) => Err((
            StatusCode::BAD_REQUEST,
            "X-Freenet-Bundle-Key-Kind must be 'token' or 'passphrase'",
        )),
    }
}

/// `POST /v{1,2}/import` — import delegate secrets from an encrypted bundle into
/// this node's Local scope, live.
///
/// Takes the whole `Request` (rather than typed extractors) so it can read the
/// `ConnectInfo<SocketAddr>` source and the host/CIDR/origin-contract extensions
/// from `extensions()` directly — the same approach the export handler uses, and
/// necessary because this crate builds `axum` without the `ConnectInfo`
/// extractor feature. The body is read AFTER the gate + readiness checks so an
/// unauthorized or not-ready request never makes the node buffer a large bundle.
async fn import_handler(req: axum::extract::Request) -> Response {
    let (parts, body) = req.into_parts();
    let headers = &parts.headers;

    // Source IP from the kernel-set ConnectInfo extension; missing ⇒ fail closed.
    let source_ip = parts
        .extensions
        .get::<ConnectInfo<SocketAddr>>()
        .map(|ci| ci.0.ip());

    let allowed_hosts = parts.extensions.get::<AllowedHosts>();
    let allowed_source_cidrs = parts.extensions.get::<AllowedSourceCidrs>();
    // Origin-contract map is always present (layered in
    // `as_router_with_origin_contracts`); absent only in a standalone test
    // composition, in which case there are no per-contract tokens to exclude.
    let empty_origin_contracts: OriginContractMap = std::sync::Arc::new(dashmap::DashMap::new());
    let origin_contracts = parts
        .extensions
        .get::<OriginContractMap>()
        .unwrap_or(&empty_origin_contracts);

    if let Err((status, reason)) = import_gate_or_reject(
        headers,
        source_ip,
        allowed_hosts,
        allowed_source_cidrs,
        parts.uri.query(),
        origin_contracts,
    ) {
        // Never log the key. Log only the non-secret source IP + reason.
        tracing::warn!(source_ip = ?source_ip, reason, "Rejected live import request");
        return (status, reason).into_response();
    }

    // Bundle key material (required, non-empty) from the HEADER only.
    let key = match headers.get(BUNDLE_KEY_HEADER).and_then(|v| v.to_str().ok()) {
        Some(k) if !k.is_empty() => k.to_owned(),
        _ => {
            return (
                StatusCode::BAD_REQUEST,
                "import requires a bundle key (X-Freenet-Bundle-Key header)",
            )
                .into_response();
        }
    };
    let key_kind = match parse_key_kind(headers) {
        Ok(k) => k,
        Err((status, reason)) => return (status, reason).into_response(),
    };
    let overwrite = headers
        .get(OVERWRITE_HEADER)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|v| v.eq_ignore_ascii_case("true"));

    // Per-node route to the executor (the SAME handle the export uses). Absent /
    // not-yet-filled ⇒ 503.
    let op_manager = parts
        .extensions
        .get::<ExportOpManagerHandle>()
        .and_then(ExportOpManagerHandle::current);
    let Some(op_manager) = op_manager else {
        tracing::warn!("Live import requested but no running node is registered");
        return (
            StatusCode::SERVICE_UNAVAILABLE,
            "node is not ready to serve imports",
        )
            .into_response();
    };

    // Read the body only now that the request is authorized and the node is
    // ready. `to_bytes` enforces the same cap as the DefaultBodyLimit layer; an
    // over-cap body surfaces as 413.
    let bundle = match axum::body::to_bytes(body, MAX_IMPORT_BUNDLE_BYTES).await {
        Ok(b) => b.to_vec(),
        Err(_) => {
            return (
                StatusCode::PAYLOAD_TOO_LARGE,
                "import bundle exceeds the maximum allowed size",
            )
                .into_response();
        }
    };
    if bundle.is_empty() {
        return (StatusCode::BAD_REQUEST, "import bundle is empty").into_response();
    }

    match run_import(&op_manager, bundle, key, key_kind, overwrite).await {
        Ok(report) => (
            StatusCode::OK,
            Json(serde_json::json!({
                "imported": report.imported,
                "skipped": report.skipped,
            })),
        )
            .into_response(),
        Err(status_and_msg) => status_and_msg.into_response(),
    }
}

/// Drive the import on the node's executor and return the [`ImportReport`].
///
/// The bundle bytes and the key are moved into the (redacted/zeroizing) event
/// wrappers so the high-value key is wiped after the bundle is decrypted and
/// never reaches a log; the bundle's `Debug` prints only its length.
///
/// `pub(crate)` so the magic-link migration's local `pull-import` endpoint
/// (`hosted_migrate`) drives the import through the SAME executor round-trip
/// (and the same 4xx/5xx classification) after fetching the bundle over HTTPS.
pub(crate) async fn run_import(
    op_manager: &OpManager,
    bundle: Vec<u8>,
    key: String,
    key_kind: BundleKeyKind,
    overwrite: bool,
) -> Result<crate::wasm_runtime::secret_export::ImportReport, (StatusCode, &'static str)> {
    let event = ContractHandlerEvent::ImportSecrets {
        // v1 always targets the node-local single-user scope.
        target_scope: ImportTargetScope::Local,
        bundle: ImportBundle::new(bundle),
        key_material: RedactedToken::new(key.into_bytes()),
        key_kind,
        overwrite,
    };

    // Initiated by a local HTTP client → ClientLocal lane (#4534).
    match op_manager
        .notify_contract_handler_prioritized(event, Priority::ClientLocal)
        .await
    {
        Ok(ContractHandlerEvent::ImportSecretsResponse(Ok(report))) => Ok(report),
        Ok(ContractHandlerEvent::ImportSecretsResponse(Err(e))) => {
            // A wrong key / corrupt bundle is a CLIENT-input fault: surface 4xx
            // (with NOTHING written — the decrypt fails before any write), so the
            // caller can tell it apart from a genuine 500. The message is
            // non-secret (never echoes the key or any plaintext).
            if e.is_import_bad_bundle() {
                tracing::warn!(error = %e, "Rejected live import: bad bundle/key");
                return Err((
                    StatusCode::UNPROCESSABLE_ENTITY,
                    "could not decrypt the bundle: wrong key or corrupt bundle",
                ));
            }
            // The contract loop's fair queue is full (#4534): a transient
            // back-pressure condition, not a real failure. 503 so the caller can
            // distinguish "retry later" from a genuine error.
            if e.is_contract_queue_full() {
                tracing::warn!(error = %e, "Rejected live import: contract queue full");
                return Err((
                    StatusCode::SERVICE_UNAVAILABLE,
                    "node is busy; retry the import shortly",
                ));
            }
            // Executor-side failure (store/IO). Do not leak internals; log the
            // detail, return a generic 500.
            tracing::error!(error = %e, "Live import failed on the executor");
            Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                "import failed on the node",
            ))
        }
        Ok(other) => {
            tracing::error!(
                response = %other,
                "Unexpected contract-handler response to ImportSecrets"
            );
            Err((
                StatusCode::INTERNAL_SERVER_ERROR,
                "import failed on the node",
            ))
        }
        Err(e) => {
            tracing::error!(error = %e, "Contract handler unavailable for live import");
            Err((
                StatusCode::SERVICE_UNAVAILABLE,
                "node is not ready to serve imports",
            ))
        }
    }
}

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

    const LOOPBACK: Option<IpAddr> = Some(IpAddr::V4(Ipv4Addr::LOCALHOST));
    const LOCAL_ORIGIN: &str = "http://127.0.0.1:50509";

    fn headers_with(origin: Option<&str>) -> HeaderMap {
        let mut h = HeaderMap::new();
        if let Some(o) = origin {
            h.insert("origin", HeaderValue::from_str(o).unwrap());
        }
        h
    }

    fn empty_map() -> OriginContractMap {
        std::sync::Arc::new(dashmap::DashMap::new())
    }

    /// Headline: loopback source + a localhost dashboard origin + no
    /// per-contract token ⇒ the gate admits.
    #[test]
    fn gate_admits_loopback_dashboard_origin() {
        let headers = headers_with(Some(LOCAL_ORIGIN));
        import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map())
            .expect("loopback dashboard request must be admitted");
    }

    /// A missing ConnectInfo cannot prove loopback ⇒ fail closed (403).
    #[test]
    fn gate_rejects_missing_source_ip() {
        let headers = headers_with(Some(LOCAL_ORIGIN));
        let err =
            import_gate_or_reject(&headers, None, None, None, None, &empty_map()).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    /// A non-loopback source IP is never honored (403), even with a good origin.
    #[test]
    fn gate_rejects_non_loopback_source() {
        let headers = headers_with(Some(LOCAL_ORIGIN));
        let public = Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)));
        let err =
            import_gate_or_reject(&headers, public, None, None, None, &empty_map()).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    /// `::ffff:127.0.0.1` from a dual-stack socket normalizes to loopback and is
    /// admitted.
    #[test]
    fn gate_admits_ipv4_mapped_loopback() {
        let headers = headers_with(Some(LOCAL_ORIGIN));
        let mapped = Some(IpAddr::V6(Ipv4Addr::LOCALHOST.to_ipv6_mapped()));
        import_gate_or_reject(&headers, mapped, None, None, None, &empty_map())
            .expect("ipv4-mapped loopback must be admitted");
    }

    /// A missing Origin header is rejected (CSRF: a state-changing write needs a
    /// trusted origin).
    #[test]
    fn gate_rejects_missing_origin() {
        let headers = headers_with(None);
        let err =
            import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map()).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    /// `Origin: null` (the sandboxed contract iframe) is rejected — the
    /// per-contract iframe exclusion.
    #[test]
    fn gate_rejects_null_origin_sandbox_iframe() {
        let headers = headers_with(Some("null"));
        let err =
            import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map()).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    /// A cross-site origin (not a loopback/allowed host) is rejected.
    #[test]
    fn gate_rejects_cross_site_origin() {
        let headers = headers_with(Some("https://evil.example.com"));
        let err =
            import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map()).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    /// A request presenting a registered per-contract AuthToken (bearer header)
    /// is rejected even from a loopback dashboard origin — the AuthToken-origin
    /// exclusion.
    #[test]
    fn gate_rejects_registered_origin_contract_bearer() {
        let map: OriginContractMap = std::sync::Arc::new(dashmap::DashMap::new());
        let tok = AuthToken::from("contract-token-xyz".to_owned());
        map.insert(tok.clone(), dummy_origin_contract());

        let mut headers = headers_with(Some(LOCAL_ORIGIN));
        headers.insert(
            axum::http::header::AUTHORIZATION,
            HeaderValue::from_str("Bearer contract-token-xyz").unwrap(),
        );
        let err = import_gate_or_reject(&headers, LOOPBACK, None, None, None, &map).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    /// A request presenting a registered per-contract AuthToken via the
    /// `Authorization` cookie is likewise rejected.
    #[test]
    fn gate_rejects_registered_origin_contract_cookie() {
        let map: OriginContractMap = std::sync::Arc::new(dashmap::DashMap::new());
        let tok = AuthToken::from("cookie-token-abc".to_owned());
        map.insert(tok.clone(), dummy_origin_contract());

        let mut headers = headers_with(Some(LOCAL_ORIGIN));
        headers.insert(
            axum::http::header::COOKIE,
            HeaderValue::from_str("authorization=Bearer cookie-token-abc").unwrap(),
        );
        let err = import_gate_or_reject(&headers, LOOPBACK, None, None, None, &map).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    /// A bearer token NOT registered as an origin contract does not trip the
    /// exclusion (it is not a per-contract origin).
    #[test]
    fn gate_admits_unregistered_bearer() {
        let mut headers = headers_with(Some(LOCAL_ORIGIN));
        headers.insert(
            axum::http::header::AUTHORIZATION,
            HeaderValue::from_str("Bearer some-unrelated-token").unwrap(),
        );
        import_gate_or_reject(&headers, LOOPBACK, None, None, None, &empty_map())
            .expect("an unregistered bearer is not a contract origin");
    }

    #[test]
    fn presented_tokens_reads_header_query_and_cookie() {
        let mut headers = HeaderMap::new();
        headers.insert(
            axum::http::header::AUTHORIZATION,
            HeaderValue::from_str("Bearer header-tok").unwrap(),
        );
        headers.insert(
            axum::http::header::COOKIE,
            HeaderValue::from_str("foo=bar; authorization=Bearer cookie-tok").unwrap(),
        );
        let toks: Vec<String> = presented_tokens(&headers, Some("a=b&authToken=query-tok"))
            .map(|t| t.as_str().to_owned())
            .collect();
        assert!(toks.contains(&"header-tok".to_owned()));
        assert!(toks.contains(&"query-tok".to_owned()));
        assert!(toks.contains(&"cookie-tok".to_owned()));
    }

    #[test]
    fn parse_key_kind_defaults_and_variants() {
        let mut h = HeaderMap::new();
        assert_eq!(parse_key_kind(&h).unwrap(), BundleKeyKind::Token);
        h.insert(
            BUNDLE_KEY_KIND_HEADER,
            HeaderValue::from_static("passphrase"),
        );
        assert_eq!(parse_key_kind(&h).unwrap(), BundleKeyKind::Passphrase);
        h.insert(BUNDLE_KEY_KIND_HEADER, HeaderValue::from_static("TOKEN"));
        assert_eq!(parse_key_kind(&h).unwrap(), BundleKeyKind::Token);
        h.insert(BUNDLE_KEY_KIND_HEADER, HeaderValue::from_static("bogus"));
        assert_eq!(parse_key_kind(&h).unwrap_err().0, StatusCode::BAD_REQUEST);
    }

    #[test]
    fn strip_bearer_handles_case_and_absence() {
        assert_eq!(strip_bearer("Bearer abc"), Some("abc"));
        assert_eq!(strip_bearer("bearer abc"), Some("abc"));
        assert_eq!(strip_bearer("Token abc"), None);
    }

    #[test]
    fn gate_rejects_ipv6_non_loopback() {
        let headers = headers_with(Some(LOCAL_ORIGIN));
        let public6 = Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)));
        let err =
            import_gate_or_reject(&headers, public6, None, None, None, &empty_map()).unwrap_err();
        assert_eq!(err.0, StatusCode::FORBIDDEN);
    }

    fn dummy_origin_contract() -> super::super::OriginContract {
        use freenet_stdlib::prelude::ContractInstanceId;
        // Any contract id works; the gate only checks token membership.
        let id = ContractInstanceId::new([0u8; 32]);
        super::super::OriginContract::new(id, crate::client_events::ClientId::FIRST)
    }
}