freenet 0.2.119

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
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use freenet_stdlib::client_api::{ErrorKind, RequestError};
use freenet_stdlib::prelude::ContractInstanceId;
use std::fmt::{Display, Formatter};

#[derive(Debug)]
pub(super) enum WebSocketApiError {
    /// Something went wrong when calling the user repo.
    InvalidParam {
        error_cause: String,
    },
    NodeError {
        error_cause: String,
    },
    AxumError {
        error: ErrorKind,
    },
    MissingContract {
        instance_id: ContractInstanceId,
    },
}

impl WebSocketApiError {
    // `status_code()` lived here and was used only by `From<_> for Response`,
    // which now delegates to `IntoResponse`. It had also drifted: it answered
    // 500 for a `NodeError` that `into_response` renders as 404 ("Contract not
    // found"), and knew nothing of the 503 retry/connecting pages. Deleted
    // rather than kept in sync — one status decision, in `into_response`.

    pub fn error_message(&self) -> String {
        match self {
            WebSocketApiError::InvalidParam { error_cause } => {
                format!("Invalid request params: {error_cause}")
            }
            WebSocketApiError::NodeError { error_cause } => format!("Node error: {error_cause}"),
            WebSocketApiError::AxumError { error } => format!("Server error: {error}"),
            WebSocketApiError::MissingContract { instance_id } => {
                format!("Missing contract {}", instance_id.encode())
            }
        }
    }
}

impl Display for WebSocketApiError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.error_message())
    }
}

impl From<WebSocketApiError> for Response {
    /// Delegates to `IntoResponse` rather than rendering its own body.
    ///
    /// It used to build `Html(error.error_message())` directly, which is a
    /// SECOND rendering path for the same type that skipped the escaping in
    /// `into_response` — so a future handler returning `Result<_, Response>`
    /// and reaching this `impl` via `?` would have silently reintroduced the
    /// injection that escaping closes. It also skipped the retry/connecting
    /// pages and the no-store headers. No caller uses it today; keeping the two
    /// paths in sync by construction is cheaper than remembering to.
    fn from(error: WebSocketApiError) -> Self {
        error.into_response()
    }
}

impl IntoResponse for WebSocketApiError {
    fn into_response(self) -> Response {
        // Check for errors that indicate the peer is still connecting to the network
        if let WebSocketApiError::AxumError { ref error } = self {
            if matches!(error, ErrorKind::EmptyRing | ErrorKind::PeerNotJoined) {
                return (StatusCode::SERVICE_UNAVAILABLE, Html(connecting_page())).into_response();
            }
        }

        // Transient errors during contract fetch: the node has peers but the
        // GET hasn't completed yet (fresh node, sparse ring, etc.).  Show a
        // retry page that auto-refreshes the SAME URL so the user doesn't
        // have to manually reload (#3472).
        //
        // Note: `ErrorKind` is `#[non_exhaustive]` so new stdlib variants
        // will NOT match here.  Each new release must explicitly decide
        // whether the new variant is transient.  The wildcard `_` at the
        // bottom of the `match` falls through to 500.
        // NOTE: `ErrorKind::OperationError` is deliberately NOT listed here.
        // It is dual-use: the web GET path synthesizes it for transient
        // timeouts AND the node emits it for terminal failures (e.g. a locally
        // banned contract, exhausted GET). Treating it as transient would
        // serve an infinite auto-refresh page for those terminal errors. The
        // transient GET cases now use RequestError(Timeout) / ChannelClosed
        // (see handle_get_response in path_handlers.rs), which are caught below.
        let is_transient = matches!(
            &self,
            WebSocketApiError::AxumError {
                error:
                    // Dead in current core (no raisers), but stdlib can emit
                    // it; defensive include so a stdlib bump doesn't silently
                    // lose retry behaviour.
                    ErrorKind::FailedOperation
                    // Node-recovery races: channel teardown, cold-start.
                    | ErrorKind::ChannelClosed
                    | ErrorKind::TransportProtocolDisconnect
                    | ErrorKind::NodeUnavailable
                    // The 30s GET fetch wrapper elapsed — the canonical #3472
                    // transient case. Synthesized by handle_get_response.
                    | ErrorKind::RequestError(RequestError::Timeout)
            }
        );

        // `true` when the body below is node-authored MARKUP (the retry page)
        // rather than a message built from request-derived text. Only the
        // latter is escaped — escaping the retry page would render its
        // meta-refresh tag as visible text and break the auto-reload.
        let mut body_is_trusted_markup = false;
        let (status, error_message) = if is_transient {
            // Log the cause so operators can distinguish a fast op error
            // from a slow-loading contract without changing the user-facing
            // retry page.
            if let WebSocketApiError::AxumError { error } = &self {
                tracing::info!(%error, "serving retry page for transient contract-fetch error");
            }
            body_is_trusted_markup = true;
            (StatusCode::SERVICE_UNAVAILABLE, retry_loading_page())
        } else {
            match self {
                WebSocketApiError::InvalidParam { error_cause } => {
                    (StatusCode::BAD_REQUEST, error_cause)
                }
                WebSocketApiError::NodeError { error_cause }
                    if error_cause.starts_with("Contract not found") =>
                {
                    (StatusCode::NOT_FOUND, error_cause)
                }
                WebSocketApiError::NodeError { error_cause } => {
                    (StatusCode::INTERNAL_SERVER_ERROR, error_cause)
                }
                err @ WebSocketApiError::MissingContract { .. } => {
                    (StatusCode::NOT_FOUND, err.error_message())
                }
                WebSocketApiError::AxumError { error } => {
                    // Already handled transient cases above; remaining
                    // AxumErrors are infrastructure failures.
                    (StatusCode::INTERNAL_SERVER_ERROR, format!("{error}"))
                }
            }
        };

        // These bodies are served as text/html at the NODE's own origin, and
        // several carry request-derived text: `web_subpages`' "Page not found:
        // {page}" reflects the requested sub-path verbatim, and an
        // `AxumError`'s `Display` can surface a rejected byte. Escape before
        // wrapping in `Html`, so a crafted URL cannot become markup on a
        // node-origin page — this is the one response class on the contract-web
        // routes that carries no CSP, no `nosniff` and no `X-Frame-Options`,
        // i.e. exactly where an injection would be worth the most.
        let body = if body_is_trusted_markup {
            Html(error_message)
        } else {
            Html(html_escape(&error_message))
        };

        // Prevent intermediaries/service-workers from pinning a stale retry
        // page, and signal the client when it may retry.
        let mut response = (status, body).into_response();
        if is_transient {
            response.headers_mut().insert(
                axum::http::header::CACHE_CONTROL,
                axum::http::HeaderValue::from_static("no-store"),
            );
            response.headers_mut().insert(
                axum::http::header::RETRY_AFTER,
                axum::http::HeaderValue::from_str(&RETRY_REFRESH_SECS.to_string())
                    .unwrap_or(axum::http::HeaderValue::from_static("60")),
            );
        }

        response
    }
}

/// Returns a short HTML page that redirects to the dashboard while the peer connects.
///
/// The homepage at `/` already shows full connection diagnostics, so rather than
/// duplicating that rendering here we redirect with a meta-refresh. The 503 status
/// code (set by the caller) tells programmatic clients the node is not yet ready.
fn connecting_page() -> String {
    include_str!("errors/assets/connecting.html").to_string()
}

/// How often the retry page reloads (seconds).  Long enough for a
/// cold GET to resolve on a sparse ring, short enough that the user
/// doesn't assume the page is dead.
const RETRY_REFRESH_SECS: u64 = 60;

/// Returns an HTML page that auto-refreshes the current URL every
/// [`RETRY_REFRESH_SECS`] seconds.
///
/// Used when a contract-fetch operation timed out — the node has peers and
/// is making progress, but the specific GET hasn't resolved yet.  Rather
/// than showing a dead error, the page reloads itself.  Once the contract
/// is cached, `contract_home` serves normally and the refresh loop stops
/// (the success response carries no `<meta http-equiv="refresh">`).
fn retry_loading_page() -> String {
    format!(
        r##"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="refresh" content="{refresh}">
    <title>Loading contract…</title>
    <style>
        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
               display: flex; justify-content: center; align-items: center; min-height: 100vh;
               margin: 0; background: #0c0d0f; color: #edeeef; }}
        .container {{ text-align: center; padding: 2rem; }}
        h1 {{ font-size: 1.2rem; font-weight: 500; margin-bottom: 0.5rem; }}
        p {{ color: #94969a; font-size: 0.85rem; margin-bottom: 0.3rem; }}
    </style>
</head>
<body>
    <div class="container">
        <h1>Fetching contract from the network…</h1>
        <p>This page will reload automatically.</p>
        <p style="font-size:0.7rem;color:#585a5e">If this persists, check the <a href="/" style="color:#0abab5">dashboard</a>.</p>
    </div>
</body>
</html>"##,
        refresh = RETRY_REFRESH_SECS,
    )
}

#[cfg(test)]
mod tests {

    /// Regression test for the reflected-HTML gap the escaped-popup review
    /// found: these bodies are served as `text/html` at the NODE's own origin,
    /// and several carry request-derived text — `web_subpages` renders
    /// "Page not found: {page}" with the requested sub-path verbatim.
    #[tokio::test]
    async fn error_bodies_escape_request_derived_text() {
        for error in [
            WebSocketApiError::InvalidParam {
                error_cause: "Page not found: <script>alert(1)</script>".to_string(),
            },
            WebSocketApiError::NodeError {
                error_cause: "<img src=x onerror=alert(1)>".to_string(),
            },
        ] {
            let raw = axum::body::to_bytes(error.into_response().into_body(), usize::MAX)
                .await
                .unwrap();
            let body = String::from_utf8_lossy(&raw).to_string();
            assert!(
                !body.contains("<script>") && !body.contains("<img "),
                "error bodies must not render request-derived text as markup; got: {body}"
            );
            assert!(
                body.contains("&lt;") && body.contains("&gt;"),
                "the payload must survive, escaped, so the message is still \
                 readable; got: {body}"
            );
        }
    }

    /// `From<WebSocketApiError> for Response` must go through the same
    /// rendering as `IntoResponse`, or it is a second path that skips the
    /// escaping above.
    #[tokio::test]
    async fn from_impl_renders_identically_to_into_response() {
        let payload = "<script>alert(1)</script>";
        let via_from: axum::response::Response = WebSocketApiError::InvalidParam {
            error_cause: payload.to_string(),
        }
        .into();
        let raw = axum::body::to_bytes(via_from.into_body(), usize::MAX)
            .await
            .unwrap();
        let body = String::from_utf8_lossy(&raw).to_string();
        assert!(
            !body.contains("<script>"),
            "the `From` conversion must escape too; got: {body}"
        );
    }

    use super::*;

    #[test]
    fn empty_ring_returns_service_unavailable() {
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::EmptyRing,
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
    }

    #[test]
    fn connecting_page_redirects_to_root_only_at_top_level() {
        // Regression (peer-restart breaks a framed webapp): the connecting page
        // is served (503, EmptyRing/PeerNotJoined) while the peer rejoins the
        // ring, and it can render INSIDE a webapp's sandboxed shell iframe as
        // well as top-level. It must NOT unconditionally navigate to the node
        // root `/`: doing so inside the iframe yanks the running webapp to a
        // page that denies framing on hardened deployments ("Refused to
        // display ... X-Frame-Options: deny" -> broken tab, exactly the River
        // peer-restart symptom). The redirect to `/` must be conditioned on
        // being the top document; a framed instance reloads its OWN url in
        // place. See the head comment in connecting.html.
        let page = connecting_page();
        assert!(
            page.contains("window.top === window.self"),
            "connecting page must condition the /-redirect on being the top document"
        );
        assert!(
            page.contains("window.location.reload()"),
            "a framed connecting page must reload its own url instead of navigating to /"
        );
        // The only meta-refresh to `/` must be the <noscript> no-JS fallback,
        // never an unconditional redirect that also fires in a sandboxed frame.
        let noscript_fallback =
            r#"<noscript><meta http-equiv="refresh" content="3;url=/" /></noscript>"#;
        assert!(
            page.contains(noscript_fallback),
            "url=/ meta-refresh must be wrapped in <noscript> as the no-JS fallback"
        );
        assert!(
            !page.replace(noscript_fallback, "").contains("url=/"),
            "connecting page must not contain an unconditional top-level url=/ redirect"
        );
        // MAJOR #1 (PR #4781 review): a TOP-LEVEL connecting page that carries
        // the shell's `_freload` recovery marker is a recovery reload that
        // momentarily landed here during ring rejoin. It must NOT go to `/`
        // (that abandons the app for the dashboard — the app-loss this recovery
        // exists to prevent); it must retry the contract URL in place. The
        // decision is centralised in the pure `connectingRecoveryDecision`.
        assert!(
            page.contains("_freload"),
            "connecting page must recognize the shell's _freload recovery reload"
        );
        assert!(
            page.contains("function connectingRecoveryDecision("),
            "the redirect decision must be a pure, testable function of (_freload, atTop, now)"
        );
        // MAJOR #3 (PR #4781 review): the retry-in-place must be BOUNDED, not a
        // 3s-forever loop. `_freload` carries a start timestamp; after the window
        // the page stops looping (top-level -> `/`, framed -> a stop message).
        assert!(
            page.contains("RECOVERY_MAX_MS"),
            "the recovery retry must be time-bounded, not an unconditional forever loop"
        );
        assert!(
            page.contains("window.location.replace('/')"),
            "top-level recovery must fall back to the dashboard once the window elapses"
        );
    }

    #[test]
    fn peer_not_joined_returns_service_unavailable() {
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::PeerNotJoined,
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
    }

    #[tokio::test]
    async fn failed_operation_returns_retry_page() {
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::FailedOperation,
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
        assert_eq!(
            response
                .headers()
                .get(axum::http::header::CACHE_CONTROL)
                .map(|v| v.as_bytes()),
            Some(&b"no-store"[..]),
        );
        assert!(
            response
                .headers()
                .get(axum::http::header::RETRY_AFTER)
                .is_some(),
        );
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .unwrap();
        let text = String::from_utf8_lossy(&body);
        assert!(
            text.contains(r#"<meta http-equiv="refresh" content="60"#),
            "retry page must contain meta-refresh tag"
        );
        // The CSS must use single braces — a `format!` brace-escaping bug
        // ({{{{ instead of {{) would emit `body {{ … }}`, invalid CSS that
        // strips the page's styling.
        assert!(
            text.contains("body {") && !text.contains("body {{"),
            "retry page CSS must emit single braces, got: {text}"
        );
    }

    #[test]
    fn operation_error_returns_internal_server_error() {
        // OperationError is dual-use: the node emits it for TERMINAL failures
        // (banned contract, exhausted GET) as well as transient ones, so it
        // must NOT be classified transient — otherwise a banned contract would
        // infinite-reload instead of showing an error. The transient GET cases
        // use RequestError(Timeout)/ChannelClosed instead (#3472).
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::OperationError {
                cause: "contract banned".into(),
            },
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
        // And it must NOT carry the retry-page caching headers.
        assert!(
            response
                .headers()
                .get(axum::http::header::RETRY_AFTER)
                .is_none(),
        );
    }

    #[test]
    fn channel_closed_returns_retry_page() {
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::ChannelClosed,
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
    }

    #[test]
    fn transport_disconnect_returns_retry_page() {
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::TransportProtocolDisconnect,
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
    }

    #[test]
    fn node_unavailable_returns_retry_page() {
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::NodeUnavailable,
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
    }

    #[test]
    fn request_error_timeout_returns_retry_page() {
        // A RequestError(Timeout) is the same class of transient GET-fetch
        // failure as OperationError("…timed out…") and must serve the 503
        // auto-refresh page rather than a dead error (#3472).
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::RequestError(RequestError::Timeout),
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
    }

    #[test]
    fn other_axum_error_returns_internal_server_error() {
        let err = WebSocketApiError::AxumError {
            error: ErrorKind::Unhandled {
                cause: "something broke".into(),
            },
        };
        let response = err.into_response();
        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
    }
}

/// Minimal HTML entity escaping for error bodies rendered at the node origin.
///
/// Deliberately duplicated from `permission_prompts::html_escape` rather than
/// shared: this module is on the error path of every route and must not depend
/// on a sibling that may itself fail to build.
fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#x27;")
}