we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement user-friendly error pages for navigation failures (Phase 17)

Add navigation_error_page() that generates specific, descriptive error
pages based on the LoadError type. Each error type gets a tailored title,
description, and technical details code:

- DNS resolution failures: hostname + DNS_RESOLUTION_FAILED
- Connection refused/timeout: host + CONNECTION_REFUSED/TIMED_OUT
- TLS errors: specific reason (expired cert, wrong host, etc.)
- HTTP 4xx/5xx: status code + reason phrase with friendly titles
- Redirect loops: ERR_TOO_MANY_REDIRECTS / ERR_REDIRECT_LOOP
- Invalid URLs, unsupported schemes, encoding errors, CSP/CORS blocks

Error pages include a styled reload link pointing back to the failed URL,
and set base_url to the failed URL so the address bar shows the intended
destination. All user-provided strings are HTML-escaped to prevent XSS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+362 -14
+362 -14
crates/browser/src/main.rs
··· 9 9 submission_params, FormEnctype, FormMethod, 10 10 }; 11 11 use we_browser::img_loader::{collect_images, ImageStore}; 12 - use we_browser::loader::{Resource, ResourceLoader, ABOUT_BLANK_HTML}; 12 + use we_browser::loader::{LoadError, Resource, ResourceLoader, ABOUT_BLANK_HTML}; 13 13 use we_browser::navigation_history::NavigationHistory; 14 14 use we_browser::script_loader::execute_page_scripts; 15 15 use we_css::parser::Stylesheet; ··· 564 564 let mut loader = ResourceLoader::new(); 565 565 match loader.post_form(&action_url, &body, &content_type) { 566 566 Ok(resource) => resource_to_loaded_html(resource, &loader), 567 - Err(e) => error_page(&format!("Form submission failed: {e}")), 567 + Err(e) => navigation_error_page(&action_url, &e), 568 568 } 569 569 } 570 570 }; ··· 601 601 let mut loader = ResourceLoader::new(); 602 602 match loader.fetch_url(&url.serialize(), None) { 603 603 Ok(resource) => resource_to_loaded_html(resource, &loader), 604 - Err(e) => error_page(&format!("Failed to load {}: {e}", url.serialize())), 604 + Err(e) => navigation_error_page(url, &e), 605 605 } 606 606 } 607 607 ··· 1984 1984 return resource_to_loaded_html(resource, &loader); 1985 1985 } 1986 1986 Err(e) => { 1987 - return error_page(&format!("Failed to load {arg}: {e}")); 1987 + let url = Url::parse(arg).unwrap_or_else(|_| { 1988 + Url::parse("about:blank").expect("about:blank is always valid") 1989 + }); 1990 + return navigation_error_page(&url, &e); 1988 1991 } 1989 1992 } 1990 1993 } ··· 2010 2013 } 2011 2014 } 2012 2015 2013 - /// Generate an HTML error page for display. 2016 + /// Generate a generic HTML error page for display. 2014 2017 fn error_page(message: &str) -> LoadedHtml { 2015 2018 eprintln!("{message}"); 2016 - let escaped = message 2017 - .replace('&', "&amp;") 2018 - .replace('<', "&lt;") 2019 - .replace('>', "&gt;"); 2019 + let escaped = html_escape(message); 2020 2020 let html = format!( 2021 2021 "<!DOCTYPE html>\ 2022 2022 <html><head><title>Error</title>\ 2023 - <style>\ 2024 - body {{ font-family: sans-serif; margin: 40px; color: #333; }}\ 2025 - h1 {{ color: #c00; }}\ 2026 - p {{ font-size: 16px; }}\ 2027 - </style></head>\ 2023 + <style>{ERROR_PAGE_STYLE}</style></head>\ 2028 2024 <body><h1>Error</h1><p>{escaped}</p></body></html>" 2029 2025 ); 2030 2026 let base_url = Url::parse("about:blank").expect("about:blank is always valid"); ··· 2036 2032 } 2037 2033 } 2038 2034 2035 + /// CSS shared by all error pages. 2036 + const ERROR_PAGE_STYLE: &str = "\ 2037 + body { font-family: sans-serif; margin: 40px auto; max-width: 600px; color: #333; }\ 2038 + h1 { color: #c00; font-size: 22px; margin-bottom: 8px; }\ 2039 + .description { font-size: 16px; margin: 16px 0; }\ 2040 + .details { font-size: 13px; color: #888; margin: 16px 0; }\ 2041 + .reload { display: inline-block; margin-top: 20px; padding: 8px 20px; \ 2042 + background: #4285f4; color: #fff; text-decoration: none; border-radius: 4px; \ 2043 + font-size: 14px; }\ 2044 + .reload:hover { background: #3367d6; }"; 2045 + 2046 + /// Escape HTML special characters. 2047 + fn html_escape(s: &str) -> String { 2048 + s.replace('&', "&amp;") 2049 + .replace('<', "&lt;") 2050 + .replace('>', "&gt;") 2051 + .replace('"', "&quot;") 2052 + } 2053 + 2054 + /// Generate a user-friendly error page for a navigation `LoadError`. 2055 + /// 2056 + /// The error page is an internally-generated HTML document rendered through 2057 + /// the normal pipeline. Its `base_url` is set to the failed URL so the 2058 + /// address bar shows the intended destination. 2059 + fn navigation_error_page(url: &Url, error: &LoadError) -> LoadedHtml { 2060 + use we_net::client::ClientError; 2061 + use we_net::tcp::NetError; 2062 + use we_net::tls::handshake::HandshakeError; 2063 + 2064 + let url_str = url.serialize(); 2065 + let escaped_url = html_escape(&url_str); 2066 + let host = url.host_str().unwrap_or_else(|| url_str.clone()); 2067 + let escaped_host = html_escape(&host); 2068 + 2069 + let (title, description, details) = match error { 2070 + LoadError::InvalidUrl(s) => ( 2071 + "Invalid URL".to_string(), 2072 + "The URL you entered is not valid.".to_string(), 2073 + format!("Invalid URL: {}", html_escape(s)), 2074 + ), 2075 + LoadError::Network(client_err) => match client_err { 2076 + ClientError::Tcp(NetError::DnsResolutionFailed(host)) => ( 2077 + "This site can\u{2019}t be reached".to_string(), 2078 + format!( 2079 + "<strong>{}</strong>\u{2019}s DNS address could not be found.", 2080 + html_escape(host) 2081 + ), 2082 + "DNS_RESOLUTION_FAILED".to_string(), 2083 + ), 2084 + ClientError::Tcp(NetError::ConnectionRefused) => ( 2085 + "This site can\u{2019}t be reached".to_string(), 2086 + format!("<strong>{escaped_host}</strong> refused to connect."), 2087 + "CONNECTION_REFUSED".to_string(), 2088 + ), 2089 + ClientError::Tcp(NetError::Timeout) => ( 2090 + "This site can\u{2019}t be reached".to_string(), 2091 + format!("<strong>{escaped_host}</strong> took too long to respond."), 2092 + "CONNECTION_TIMED_OUT".to_string(), 2093 + ), 2094 + ClientError::Tcp(NetError::Io(io_err)) => ( 2095 + "This site can\u{2019}t be reached".to_string(), 2096 + format!( 2097 + "A network error occurred while connecting to \ 2098 + <strong>{escaped_host}</strong>." 2099 + ), 2100 + format!("NETWORK_IO_ERROR: {}", html_escape(&io_err.to_string())), 2101 + ), 2102 + ClientError::Tls(tls_err) => { 2103 + let reason = match tls_err { 2104 + HandshakeError::CertificateError(msg) => { 2105 + format!("Certificate error: {}", html_escape(msg)) 2106 + } 2107 + HandshakeError::EmptyCertificateChain => { 2108 + "The server did not provide a certificate.".to_string() 2109 + } 2110 + HandshakeError::SignatureVerificationFailed => { 2111 + "Certificate signature verification failed.".to_string() 2112 + } 2113 + HandshakeError::UnsupportedVersion => { 2114 + "The server uses an unsupported TLS version.".to_string() 2115 + } 2116 + HandshakeError::UnsupportedCipherSuite => { 2117 + "No common cipher suite with the server.".to_string() 2118 + } 2119 + _ => html_escape(&tls_err.to_string()), 2120 + }; 2121 + ( 2122 + "Your connection is not private".to_string(), 2123 + format!("The secure connection to <strong>{escaped_host}</strong> failed."), 2124 + format!("TLS_ERROR: {reason}"), 2125 + ) 2126 + } 2127 + ClientError::TooManyRedirects => ( 2128 + "This page isn\u{2019}t working".to_string(), 2129 + format!("<strong>{escaped_host}</strong> redirected you too many times."), 2130 + "ERR_TOO_MANY_REDIRECTS".to_string(), 2131 + ), 2132 + ClientError::RedirectLoop => ( 2133 + "This page isn\u{2019}t working".to_string(), 2134 + format!("<strong>{escaped_host}</strong> redirected you too many times."), 2135 + "ERR_REDIRECT_LOOP".to_string(), 2136 + ), 2137 + ClientError::ConnectionClosed => ( 2138 + "This site can\u{2019}t be reached".to_string(), 2139 + format!( 2140 + "The connection to <strong>{escaped_host}</strong> was unexpectedly closed." 2141 + ), 2142 + "CONNECTION_CLOSED".to_string(), 2143 + ), 2144 + ClientError::InvalidUrl(s) => ( 2145 + "Invalid URL".to_string(), 2146 + "The URL could not be processed.".to_string(), 2147 + format!("INVALID_URL: {}", html_escape(s)), 2148 + ), 2149 + ClientError::UnsupportedScheme(s) => ( 2150 + "Unsupported protocol".to_string(), 2151 + format!( 2152 + "The <strong>{}</strong> protocol is not supported.", 2153 + html_escape(s) 2154 + ), 2155 + format!("UNSUPPORTED_SCHEME: {}", html_escape(s)), 2156 + ), 2157 + ClientError::Http(http_err) => ( 2158 + "This site can\u{2019}t be reached".to_string(), 2159 + format!( 2160 + "An HTTP error occurred while loading \ 2161 + <strong>{escaped_host}</strong>." 2162 + ), 2163 + format!("HTTP_ERROR: {}", html_escape(&http_err.to_string())), 2164 + ), 2165 + ClientError::Http2(h2_err) => ( 2166 + "This site can\u{2019}t be reached".to_string(), 2167 + format!( 2168 + "An HTTP/2 protocol error occurred while loading \ 2169 + <strong>{escaped_host}</strong>." 2170 + ), 2171 + format!("HTTP2_ERROR: {}", html_escape(&h2_err.to_string())), 2172 + ), 2173 + ClientError::Io(io_err) => ( 2174 + "This site can\u{2019}t be reached".to_string(), 2175 + format!( 2176 + "A network error occurred while loading \ 2177 + <strong>{escaped_host}</strong>." 2178 + ), 2179 + format!("IO_ERROR: {}", html_escape(&io_err.to_string())), 2180 + ), 2181 + }, 2182 + LoadError::HttpStatus { status, reason } => { 2183 + let title = match *status { 2184 + 404 => "Page not found".to_string(), 2185 + 403 => "Access denied".to_string(), 2186 + 500 => "Internal server error".to_string(), 2187 + 502 => "Bad gateway".to_string(), 2188 + 503 => "Service unavailable".to_string(), 2189 + _ if *status >= 400 && *status < 500 => "Client error".to_string(), 2190 + _ => "Server error".to_string(), 2191 + }; 2192 + ( 2193 + title, 2194 + format!("<strong>{escaped_host}</strong> returned an error."), 2195 + format!("HTTP_ERROR {status}: {}", html_escape(reason)), 2196 + ) 2197 + } 2198 + LoadError::Encoding(s) => ( 2199 + "Encoding error".to_string(), 2200 + format!("The page from <strong>{escaped_host}</strong> could not be decoded."), 2201 + format!("ENCODING_ERROR: {}", html_escape(s)), 2202 + ), 2203 + LoadError::CrossOriginBlocked { url: blocked_url } => ( 2204 + "Blocked".to_string(), 2205 + "The request was blocked by the Same-Origin Policy.".to_string(), 2206 + format!("CROSS_ORIGIN_BLOCKED: {}", html_escape(blocked_url)), 2207 + ), 2208 + LoadError::CspBlocked { 2209 + url: blocked_url, 2210 + directive, 2211 + } => ( 2212 + "Blocked".to_string(), 2213 + "The request was blocked by Content Security Policy.".to_string(), 2214 + format!( 2215 + "CSP_BLOCKED ({}): {}", 2216 + html_escape(directive), 2217 + html_escape(blocked_url) 2218 + ), 2219 + ), 2220 + }; 2221 + 2222 + let html = format!( 2223 + "<!DOCTYPE html>\ 2224 + <html><head><title>{title}</title>\ 2225 + <style>{ERROR_PAGE_STYLE}</style></head>\ 2226 + <body>\ 2227 + <h1>{title}</h1>\ 2228 + <p class=\"description\">{description}</p>\ 2229 + <p class=\"details\">{details}</p>\ 2230 + <a class=\"reload\" href=\"{escaped_url}\">Reload</a>\ 2231 + </body></html>" 2232 + ); 2233 + 2234 + eprintln!("[we] Error page for {url_str}: {error}"); 2235 + 2236 + LoadedHtml { 2237 + text: html, 2238 + base_url: url.clone(), 2239 + http_referrer_policy: None, 2240 + http_csp: PolicyList::new(), 2241 + } 2242 + } 2243 + 2039 2244 /// Convert any `Resource` into a `LoadedHtml` suitable for the rendering pipeline. 2040 2245 /// 2041 2246 /// For HTML resources, returns directly. For other types, generates a synthetic ··· 2653 2858 let loaded = plain_text_page("<script>alert('xss')</script>", &url, "text/plain"); 2654 2859 assert!(loaded.text.contains("&lt;script&gt;")); 2655 2860 assert!(!loaded.text.contains("<script>alert")); 2861 + } 2862 + 2863 + // ----------------------------------------------------------------------- 2864 + // Error page tests 2865 + // ----------------------------------------------------------------------- 2866 + 2867 + #[test] 2868 + fn error_page_dns_failure() { 2869 + let url = Url::parse("https://example.invalid/page").unwrap(); 2870 + let err = LoadError::Network(we_net::client::ClientError::Tcp( 2871 + we_net::tcp::NetError::DnsResolutionFailed("example.invalid".to_string()), 2872 + )); 2873 + let loaded = navigation_error_page(&url, &err); 2874 + assert!(loaded.text.contains("DNS address could not be found")); 2875 + assert!(loaded.text.contains("example.invalid")); 2876 + assert!(loaded.text.contains("DNS_RESOLUTION_FAILED")); 2877 + assert!(loaded.text.contains("Reload")); 2878 + assert_eq!(loaded.base_url.serialize(), "https://example.invalid/page"); 2879 + } 2880 + 2881 + #[test] 2882 + fn error_page_connection_refused() { 2883 + let url = Url::parse("https://example.com/").unwrap(); 2884 + let err = LoadError::Network(we_net::client::ClientError::Tcp( 2885 + we_net::tcp::NetError::ConnectionRefused, 2886 + )); 2887 + let loaded = navigation_error_page(&url, &err); 2888 + assert!(loaded.text.contains("refused to connect")); 2889 + assert!(loaded.text.contains("example.com")); 2890 + assert!(loaded.text.contains("CONNECTION_REFUSED")); 2891 + assert!(loaded.text.contains("Reload")); 2892 + } 2893 + 2894 + #[test] 2895 + fn error_page_connection_timeout() { 2896 + let url = Url::parse("https://slow.example.com/").unwrap(); 2897 + let err = LoadError::Network(we_net::client::ClientError::Tcp( 2898 + we_net::tcp::NetError::Timeout, 2899 + )); 2900 + let loaded = navigation_error_page(&url, &err); 2901 + assert!(loaded.text.contains("took too long")); 2902 + assert!(loaded.text.contains("slow.example.com")); 2903 + assert!(loaded.text.contains("CONNECTION_TIMED_OUT")); 2904 + } 2905 + 2906 + #[test] 2907 + fn error_page_tls_certificate_error() { 2908 + let url = Url::parse("https://expired.example.com/").unwrap(); 2909 + let err = LoadError::Network(we_net::client::ClientError::Tls( 2910 + we_net::tls::handshake::HandshakeError::CertificateError( 2911 + "certificate has expired".to_string(), 2912 + ), 2913 + )); 2914 + let loaded = navigation_error_page(&url, &err); 2915 + assert!(loaded.text.contains("not private")); 2916 + assert!(loaded.text.contains("expired.example.com")); 2917 + assert!(loaded.text.contains("certificate has expired")); 2918 + assert!(loaded.text.contains("TLS_ERROR")); 2919 + } 2920 + 2921 + #[test] 2922 + fn error_page_http_404() { 2923 + let url = Url::parse("https://example.com/missing").unwrap(); 2924 + let err = LoadError::HttpStatus { 2925 + status: 404, 2926 + reason: "Not Found".to_string(), 2927 + }; 2928 + let loaded = navigation_error_page(&url, &err); 2929 + assert!(loaded.text.contains("Page not found")); 2930 + assert!(loaded.text.contains("404")); 2931 + assert!(loaded.text.contains("Not Found")); 2932 + assert!(loaded.text.contains("Reload")); 2933 + } 2934 + 2935 + #[test] 2936 + fn error_page_http_500() { 2937 + let url = Url::parse("https://example.com/api").unwrap(); 2938 + let err = LoadError::HttpStatus { 2939 + status: 500, 2940 + reason: "Internal Server Error".to_string(), 2941 + }; 2942 + let loaded = navigation_error_page(&url, &err); 2943 + assert!(loaded.text.contains("Internal server error")); 2944 + assert!(loaded.text.contains("500")); 2945 + } 2946 + 2947 + #[test] 2948 + fn error_page_too_many_redirects() { 2949 + let url = Url::parse("https://loop.example.com/").unwrap(); 2950 + let err = LoadError::Network(we_net::client::ClientError::TooManyRedirects); 2951 + let loaded = navigation_error_page(&url, &err); 2952 + assert!(loaded.text.contains("redirected you too many times")); 2953 + assert!(loaded.text.contains("ERR_TOO_MANY_REDIRECTS")); 2954 + } 2955 + 2956 + #[test] 2957 + fn error_page_redirect_loop() { 2958 + let url = Url::parse("https://loop.example.com/").unwrap(); 2959 + let err = LoadError::Network(we_net::client::ClientError::RedirectLoop); 2960 + let loaded = navigation_error_page(&url, &err); 2961 + assert!(loaded.text.contains("redirected you too many times")); 2962 + assert!(loaded.text.contains("ERR_REDIRECT_LOOP")); 2963 + } 2964 + 2965 + #[test] 2966 + fn error_page_invalid_url() { 2967 + let url = Url::parse("about:blank").unwrap(); 2968 + let err = LoadError::InvalidUrl("not-a-url".to_string()); 2969 + let loaded = navigation_error_page(&url, &err); 2970 + assert!(loaded.text.contains("Invalid URL")); 2971 + assert!(loaded.text.contains("not-a-url")); 2972 + } 2973 + 2974 + #[test] 2975 + fn error_page_escapes_html_in_url() { 2976 + let url = Url::parse("https://example.com/page").unwrap(); 2977 + let err = LoadError::InvalidUrl("<script>alert(1)</script>".to_string()); 2978 + let loaded = navigation_error_page(&url, &err); 2979 + assert!(loaded.text.contains("&lt;script&gt;")); 2980 + assert!(!loaded.text.contains("<script>alert")); 2981 + } 2982 + 2983 + #[test] 2984 + fn error_page_base_url_matches_failed_url() { 2985 + let url = Url::parse("https://example.com/target").unwrap(); 2986 + let err = LoadError::Network(we_net::client::ClientError::ConnectionClosed); 2987 + let loaded = navigation_error_page(&url, &err); 2988 + assert_eq!(loaded.base_url.serialize(), "https://example.com/target"); 2989 + } 2990 + 2991 + #[test] 2992 + fn error_page_reload_link_points_to_failed_url() { 2993 + let url = Url::parse("https://example.com/page").unwrap(); 2994 + let err = LoadError::Network(we_net::client::ClientError::ConnectionClosed); 2995 + let loaded = navigation_error_page(&url, &err); 2996 + assert!(loaded.text.contains("href=\"https://example.com/page\"")); 2997 + } 2998 + 2999 + #[test] 3000 + fn generic_error_page_still_works() { 3001 + let loaded = error_page("Something went wrong"); 3002 + assert!(loaded.text.contains("Something went wrong")); 3003 + assert_eq!(loaded.base_url.serialize(), "about:blank"); 2656 3004 } 2657 3005 }