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 HTTP redirect following during navigation (Phase 17)

Add proper HTTP redirect handling per spec:
- Support 301, 302, 303, 307, 308 status codes
- 301/302/303 change method to GET and drop body
- 307/308 preserve original method and body
- Detect redirect loops and produce clear error
- Increase max redirect limit from 10 to 20
- Strip Authorization header on cross-origin redirects
- Track redirect metadata (redirected flag, final URL)
- Expose response.redirected and final URL in fetch API
- Use final URL after redirects as base URL in loader

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

+407 -16
+22 -8
crates/browser/src/loader.rs
··· 272 272 }); 273 273 } 274 274 275 + // Use the final URL after redirects, falling back to the original URL 276 + let effective_url = response 277 + .final_url 278 + .as_ref() 279 + .and_then(|u| Url::parse(u).ok()) 280 + .unwrap_or_else(|| url.clone()); 281 + 275 282 let content_type = response.content_type(); 276 283 let mime = content_type 277 284 .as_ref() ··· 284 291 decode_text_resource(&response.body, content_type.as_ref(), true); 285 292 Ok(Resource::Html { 286 293 text, 287 - base_url: url.clone(), 294 + base_url: effective_url, 288 295 encoding, 289 296 }) 290 297 } ··· 293 300 decode_text_resource(&response.body, content_type.as_ref(), false); 294 301 Ok(Resource::Css { 295 302 text, 296 - url: url.clone(), 303 + url: effective_url, 297 304 }) 298 305 } 299 306 MimeClass::Script => { ··· 301 308 decode_text_resource(&response.body, content_type.as_ref(), false); 302 309 Ok(Resource::Script { 303 310 text, 304 - url: url.clone(), 311 + url: effective_url, 305 312 }) 306 313 } 307 314 MimeClass::Image => Ok(Resource::Image { 308 315 data: response.body, 309 316 mime_type: mime.to_string(), 310 - url: url.clone(), 317 + url: effective_url, 311 318 }), 312 319 MimeClass::Other => { 313 320 // Check if it's a text type we should decode ··· 317 324 Ok(Resource::Other { 318 325 data: text.into_bytes(), 319 326 mime_type: mime.to_string(), 320 - url: url.clone(), 327 + url: effective_url, 321 328 }) 322 329 } else { 323 330 Ok(Resource::Other { 324 331 data: response.body, 325 332 mime_type: mime.to_string(), 326 - url: url.clone(), 333 + url: effective_url, 327 334 }) 328 335 } 329 336 } ··· 353 360 }); 354 361 } 355 362 363 + // Use the final URL after redirects, falling back to the original URL 364 + let effective_url = response 365 + .final_url 366 + .as_ref() 367 + .and_then(|u| Url::parse(u).ok()) 368 + .unwrap_or_else(|| url.clone()); 369 + 356 370 let ct = response.content_type(); 357 371 let mime = ct 358 372 .as_ref() ··· 364 378 let (text, encoding) = decode_text_resource(&response.body, ct.as_ref(), true); 365 379 Ok(Resource::Html { 366 380 text, 367 - base_url: url.clone(), 381 + base_url: effective_url, 368 382 encoding, 369 383 }) 370 384 } ··· 372 386 let (text, encoding) = decode_text_resource(&response.body, ct.as_ref(), true); 373 387 Ok(Resource::Html { 374 388 text, 375 - base_url: url.clone(), 389 + base_url: effective_url, 376 390 encoding, 377 391 }) 378 392 }
+24 -2
crates/js/src/fetch.rs
··· 23 23 pub headers: Vec<(String, String)>, 24 24 pub body: Vec<u8>, 25 25 pub url: String, 26 + pub redirected: bool, 26 27 } 27 28 28 29 /// A fetch that is in-flight or just completed. ··· 311 312 .map(|(k, v)| (k.to_string(), v.to_string())) 312 313 .collect(); 313 314 315 + let final_url = response 316 + .final_url 317 + .clone() 318 + .unwrap_or_else(|| url.serialize()); 319 + let redirected = response.redirected; 320 + 314 321 Ok(FetchResult { 315 322 status: response.status_code, 316 323 status_text: response.reason, 317 324 headers: resp_headers, 318 325 body: response.body, 319 - url: url.serialize(), 326 + url: final_url, 327 + redirected, 320 328 }) 321 329 } else if is_cross_origin && cors_mode == "no-cors" { 322 330 // no-cors mode: make the request but return an opaque response. ··· 330 338 headers: Vec::new(), 331 339 body: Vec::new(), 332 340 url: url.serialize(), 341 + redirected: false, 333 342 }) 334 343 } else if is_cross_origin { 335 344 // Default: block cross-origin if not in cors mode. ··· 348 357 .map(|(k, v)| (k.to_string(), v.to_string())) 349 358 .collect(); 350 359 360 + let final_url = response 361 + .final_url 362 + .clone() 363 + .unwrap_or_else(|| url.serialize()); 364 + let redirected = response.redirected; 365 + 351 366 Ok(FetchResult { 352 367 status: response.status_code, 353 368 status_text: response.reason, 354 369 headers: resp_headers, 355 370 body: response.body, 356 - url: url.serialize(), 371 + url: final_url, 372 + redirected, 357 373 }) 358 374 } 359 375 } ··· 386 402 data.insert_property( 387 403 "url".to_string(), 388 404 Property::data(Value::String(result.url.clone())), 405 + shapes, 406 + ); 407 + data.insert_property( 408 + "redirected".to_string(), 409 + Property::data(Value::Boolean(result.redirected)), 389 410 shapes, 390 411 ); 391 412 ··· 830 851 ], 831 852 body: b"hello world".to_vec(), 832 853 url: "http://example.com/".to_string(), 854 + redirected: false, 833 855 }; 834 856 835 857 let resp_ref = create_response_object(&mut gc, &mut shapes, &result);
+290 -6
crates/net/src/client.rs
··· 23 23 // Constants 24 24 // --------------------------------------------------------------------------- 25 25 26 - const DEFAULT_MAX_REDIRECTS: u32 = 10; 26 + const DEFAULT_MAX_REDIRECTS: u32 = 20; 27 27 const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); 28 28 const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30); 29 29 const DEFAULT_MAX_IDLE_TIME: Duration = Duration::from_secs(60); ··· 49 49 Http(http::HttpError), 50 50 /// Too many redirects. 51 51 TooManyRedirects, 52 + /// Redirect loop detected. 53 + RedirectLoop, 52 54 /// HTTP/2 protocol error. 53 55 Http2(Http2Error), 54 56 /// Connection was closed unexpectedly. ··· 66 68 Self::Tls(e) => write!(f, "TLS error: {e}"), 67 69 Self::Http(e) => write!(f, "HTTP error: {e}"), 68 70 Self::TooManyRedirects => write!(f, "too many redirects"), 71 + Self::RedirectLoop => write!(f, "redirect loop detected"), 69 72 Self::Http2(e) => write!(f, "HTTP/2 error: {e}"), 70 73 Self::ConnectionClosed => write!(f, "connection closed"), 71 74 Self::Io(e) => write!(f, "I/O error: {e}"), ··· 309 312 310 313 /// Perform an HTTP request with full control over method, headers, and body. 311 314 /// 312 - /// Follows redirects (301, 302, 307, 308) up to `max_redirects`. 315 + /// Follows redirects (301, 302, 303, 307, 308) up to `max_redirects`. 316 + /// 317 + /// - 301/302/303: method changes to GET, body is dropped 318 + /// - 307/308: original method and body are preserved 319 + /// - Cross-origin redirects strip the `Authorization` header 320 + /// - Redirect loops are detected and produce an error 313 321 pub fn request( 314 322 &mut self, 315 323 method: Method, ··· 318 326 body: Option<&[u8]>, 319 327 ) -> Result<HttpResponse> { 320 328 let mut current_url = url.clone(); 321 - let mut redirects = 0; 329 + let mut current_method = method; 330 + let mut current_body = body.map(|b| b.to_vec()); 331 + let mut current_headers = headers.clone(); 332 + let mut redirects = 0u32; 333 + let original_origin = url.origin(); 334 + let mut visited_urls = vec![current_url.serialize()]; 322 335 323 336 loop { 324 - let resp = self.execute_request(method, &current_url, headers, body)?; 337 + let resp = self.execute_request( 338 + current_method, 339 + &current_url, 340 + &current_headers, 341 + current_body.as_deref(), 342 + )?; 325 343 326 344 // Check for redirects 327 - if matches!(resp.status_code, 301 | 302 | 307 | 308) { 345 + if is_redirect_status(resp.status_code) { 328 346 redirects += 1; 329 347 if redirects > self.max_redirects { 330 348 return Err(ClientError::TooManyRedirects); ··· 332 350 333 351 if let Some(location) = resp.headers.get("Location") { 334 352 // Resolve relative URLs against current URL 335 - current_url = Url::parse_with_base(location, &current_url) 353 + let next_url = Url::parse_with_base(location, &current_url) 336 354 .or_else(|_| Url::parse(location)) 337 355 .map_err(|_| { 338 356 ClientError::InvalidUrl(format!( 339 357 "invalid redirect location: {location}" 340 358 )) 341 359 })?; 360 + 361 + // Detect redirect loops 362 + let next_serialized = next_url.serialize(); 363 + if visited_urls.contains(&next_serialized) { 364 + return Err(ClientError::RedirectLoop); 365 + } 366 + visited_urls.push(next_serialized); 367 + 368 + // Handle method changes per HTTP spec 369 + current_method = redirect_method(resp.status_code, current_method); 370 + if !redirect_preserves_body(resp.status_code) { 371 + current_body = None; 372 + } 373 + 374 + // Strip Authorization header on cross-origin redirects 375 + if !next_url.origin().same_origin(&original_origin) { 376 + current_headers.remove("Authorization"); 377 + } 378 + 379 + current_url = next_url; 342 380 continue; 343 381 } 344 382 } 345 383 384 + // Attach redirect metadata to the response 385 + let mut resp = resp; 386 + if redirects > 0 { 387 + resp.redirected = true; 388 + resp.final_url = Some(current_url.serialize()); 389 + } 390 + 346 391 return Ok(resp); 347 392 } 348 393 } ··· 562 607 reason: reason_phrase(status_code).to_string(), 563 608 headers: response_headers, 564 609 body: resp_body, 610 + redirected: false, 611 + final_url: None, 565 612 }) 566 613 } 567 614 } ··· 735 782 204 => "No Content", 736 783 301 => "Moved Permanently", 737 784 302 => "Found", 785 + 303 => "See Other", 738 786 304 => "Not Modified", 739 787 307 => "Temporary Redirect", 740 788 308 => "Permanent Redirect", ··· 750 798 } 751 799 } 752 800 801 + /// Determine whether a status code is a redirect that should be followed. 802 + fn is_redirect_status(status: u16) -> bool { 803 + matches!(status, 301 | 302 | 303 | 307 | 308) 804 + } 805 + 806 + /// Determine the method for the next request after a redirect. 807 + /// 808 + /// Per HTTP spec: 809 + /// - 301/302/303: change to GET (drop body) 810 + /// - 307/308: preserve original method 811 + fn redirect_method(status: u16, original_method: Method) -> Method { 812 + match status { 813 + 301..=303 => Method::Get, 814 + _ => original_method, 815 + } 816 + } 817 + 818 + /// Determine whether body should be preserved after a redirect. 819 + /// 820 + /// Body is dropped for 301/302/303, preserved for 307/308. 821 + fn redirect_preserves_body(status: u16) -> bool { 822 + matches!(status, 307 | 308) 823 + } 824 + 753 825 /// Check if chunked body data contains the terminating `0\r\n\r\n`. 754 826 fn has_chunked_terminator(data: &[u8]) -> bool { 755 827 // Look for \r\n0\r\n\r\n (the final chunk after some data) or 0\r\n\r\n at start ··· 1111 1183 let url = Url::parse("ftp://example.com/file").unwrap(); 1112 1184 let result = client.get(&url); 1113 1185 assert!(matches!(result, Err(ClientError::UnsupportedScheme(_)))); 1186 + } 1187 + 1188 + // -- Redirect-related tests -- 1189 + 1190 + #[test] 1191 + fn default_max_redirects_is_20() { 1192 + let client = HttpClient::new(); 1193 + assert_eq!(client.max_redirects, 20); 1194 + } 1195 + 1196 + #[test] 1197 + fn error_display_redirect_loop() { 1198 + let e = ClientError::RedirectLoop; 1199 + assert_eq!(e.to_string(), "redirect loop detected"); 1200 + } 1201 + 1202 + #[test] 1203 + fn reason_phrase_303() { 1204 + assert_eq!(reason_phrase(303), "See Other"); 1205 + } 1206 + 1207 + #[test] 1208 + fn reason_phrase_301() { 1209 + assert_eq!(reason_phrase(301), "Moved Permanently"); 1210 + } 1211 + 1212 + #[test] 1213 + fn reason_phrase_302() { 1214 + assert_eq!(reason_phrase(302), "Found"); 1215 + } 1216 + 1217 + #[test] 1218 + fn reason_phrase_307() { 1219 + assert_eq!(reason_phrase(307), "Temporary Redirect"); 1220 + } 1221 + 1222 + #[test] 1223 + fn reason_phrase_308() { 1224 + assert_eq!(reason_phrase(308), "Permanent Redirect"); 1225 + } 1226 + 1227 + // -- is_redirect_status tests -- 1228 + 1229 + #[test] 1230 + fn redirect_status_301() { 1231 + assert!(is_redirect_status(301)); 1232 + } 1233 + 1234 + #[test] 1235 + fn redirect_status_302() { 1236 + assert!(is_redirect_status(302)); 1237 + } 1238 + 1239 + #[test] 1240 + fn redirect_status_303() { 1241 + assert!(is_redirect_status(303)); 1242 + } 1243 + 1244 + #[test] 1245 + fn redirect_status_307() { 1246 + assert!(is_redirect_status(307)); 1247 + } 1248 + 1249 + #[test] 1250 + fn redirect_status_308() { 1251 + assert!(is_redirect_status(308)); 1252 + } 1253 + 1254 + #[test] 1255 + fn redirect_status_200_is_not_redirect() { 1256 + assert!(!is_redirect_status(200)); 1257 + } 1258 + 1259 + #[test] 1260 + fn redirect_status_404_is_not_redirect() { 1261 + assert!(!is_redirect_status(404)); 1262 + } 1263 + 1264 + #[test] 1265 + fn redirect_status_304_is_not_redirect() { 1266 + assert!(!is_redirect_status(304)); 1267 + } 1268 + 1269 + // -- redirect_method tests -- 1270 + 1271 + #[test] 1272 + fn redirect_301_changes_post_to_get() { 1273 + assert_eq!(redirect_method(301, Method::Post), Method::Get); 1274 + } 1275 + 1276 + #[test] 1277 + fn redirect_302_changes_post_to_get() { 1278 + assert_eq!(redirect_method(302, Method::Post), Method::Get); 1279 + } 1280 + 1281 + #[test] 1282 + fn redirect_303_changes_post_to_get() { 1283 + assert_eq!(redirect_method(303, Method::Post), Method::Get); 1284 + } 1285 + 1286 + #[test] 1287 + fn redirect_303_changes_put_to_get() { 1288 + assert_eq!(redirect_method(303, Method::Put), Method::Get); 1289 + } 1290 + 1291 + #[test] 1292 + fn redirect_303_changes_delete_to_get() { 1293 + assert_eq!(redirect_method(303, Method::Delete), Method::Get); 1294 + } 1295 + 1296 + #[test] 1297 + fn redirect_307_preserves_post() { 1298 + assert_eq!(redirect_method(307, Method::Post), Method::Post); 1299 + } 1300 + 1301 + #[test] 1302 + fn redirect_307_preserves_put() { 1303 + assert_eq!(redirect_method(307, Method::Put), Method::Put); 1304 + } 1305 + 1306 + #[test] 1307 + fn redirect_308_preserves_post() { 1308 + assert_eq!(redirect_method(308, Method::Post), Method::Post); 1309 + } 1310 + 1311 + #[test] 1312 + fn redirect_308_preserves_delete() { 1313 + assert_eq!(redirect_method(308, Method::Delete), Method::Delete); 1314 + } 1315 + 1316 + #[test] 1317 + fn redirect_301_get_stays_get() { 1318 + assert_eq!(redirect_method(301, Method::Get), Method::Get); 1319 + } 1320 + 1321 + // -- redirect_preserves_body tests -- 1322 + 1323 + #[test] 1324 + fn redirect_301_drops_body() { 1325 + assert!(!redirect_preserves_body(301)); 1326 + } 1327 + 1328 + #[test] 1329 + fn redirect_302_drops_body() { 1330 + assert!(!redirect_preserves_body(302)); 1331 + } 1332 + 1333 + #[test] 1334 + fn redirect_303_drops_body() { 1335 + assert!(!redirect_preserves_body(303)); 1336 + } 1337 + 1338 + #[test] 1339 + fn redirect_307_preserves_body() { 1340 + assert!(redirect_preserves_body(307)); 1341 + } 1342 + 1343 + #[test] 1344 + fn redirect_308_preserves_body() { 1345 + assert!(redirect_preserves_body(308)); 1346 + } 1347 + 1348 + // -- Cross-origin header stripping -- 1349 + 1350 + #[test] 1351 + fn cross_origin_strips_authorization() { 1352 + let url_a = Url::parse("https://example.com/page").unwrap(); 1353 + let url_b = Url::parse("https://other.com/page").unwrap(); 1354 + assert!(!url_b.origin().same_origin(&url_a.origin())); 1355 + 1356 + let mut headers = Headers::new(); 1357 + headers.add("Authorization", "Bearer token123"); 1358 + headers.add("Accept", "text/html"); 1359 + 1360 + // Simulate cross-origin redirect stripping 1361 + if !url_b.origin().same_origin(&url_a.origin()) { 1362 + headers.remove("Authorization"); 1363 + } 1364 + 1365 + assert!(headers.get("Authorization").is_none()); 1366 + assert_eq!(headers.get("Accept"), Some("text/html")); 1367 + } 1368 + 1369 + #[test] 1370 + fn same_origin_preserves_authorization() { 1371 + let url_a = Url::parse("https://example.com/page1").unwrap(); 1372 + let url_b = Url::parse("https://example.com/page2").unwrap(); 1373 + assert!(url_b.origin().same_origin(&url_a.origin())); 1374 + 1375 + let mut headers = Headers::new(); 1376 + headers.add("Authorization", "Bearer token123"); 1377 + 1378 + if !url_b.origin().same_origin(&url_a.origin()) { 1379 + headers.remove("Authorization"); 1380 + } 1381 + 1382 + assert_eq!(headers.get("Authorization"), Some("Bearer token123")); 1383 + } 1384 + 1385 + // -- Redirect loop detection -- 1386 + 1387 + #[test] 1388 + fn redirect_loop_detected_in_visited_urls() { 1389 + let mut visited = vec!["https://example.com/a".to_string()]; 1390 + let next = "https://example.com/a".to_string(); 1391 + assert!(visited.contains(&next)); 1392 + 1393 + // Unique URL is not a loop 1394 + let next2 = "https://example.com/b".to_string(); 1395 + assert!(!visited.contains(&next2)); 1396 + visited.push(next2); 1397 + assert_eq!(visited.len(), 2); 1114 1398 } 1115 1399 }
+71
crates/net/src/http.rs
··· 337 337 pub headers: Headers, 338 338 /// Response body bytes. 339 339 pub body: Vec<u8>, 340 + /// Whether this response was the result of following one or more redirects. 341 + pub redirected: bool, 342 + /// The final URL after following all redirects (serialized). 343 + /// `None` if no redirects were followed. 344 + pub final_url: Option<String>, 340 345 } 341 346 342 347 impl HttpResponse { ··· 404 409 reason: reason.to_string(), 405 410 headers, 406 411 body, 412 + redirected: false, 413 + final_url: None, 407 414 }) 408 415 } 409 416 ··· 1135 1142 reason: "Switching Protocols".to_string(), 1136 1143 headers: Headers::new(), 1137 1144 body: Vec::new(), 1145 + redirected: false, 1146 + final_url: None, 1138 1147 }; 1139 1148 assert!(resp.has_no_body()); 1140 1149 } ··· 1147 1156 reason: "OK".to_string(), 1148 1157 headers: Headers::new(), 1149 1158 body: Vec::new(), 1159 + redirected: false, 1160 + final_url: None, 1150 1161 }; 1151 1162 assert!(!resp.has_no_body()); 1163 + } 1164 + 1165 + // -- Redirect tracking fields -- 1166 + 1167 + #[test] 1168 + fn response_redirect_fields_default() { 1169 + let resp = parse_response(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n").unwrap(); 1170 + assert!(!resp.redirected); 1171 + assert!(resp.final_url.is_none()); 1172 + } 1173 + 1174 + #[test] 1175 + fn response_redirect_fields_set() { 1176 + let mut resp = parse_response(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n").unwrap(); 1177 + resp.redirected = true; 1178 + resp.final_url = Some("https://example.com/final".to_string()); 1179 + assert!(resp.redirected); 1180 + assert_eq!(resp.final_url.as_deref(), Some("https://example.com/final")); 1181 + } 1182 + 1183 + #[test] 1184 + fn parse_response_301() { 1185 + let data = b"HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n"; 1186 + let resp = parse_response(data).unwrap(); 1187 + assert_eq!(resp.status_code, 301); 1188 + assert_eq!(resp.headers.get("Location"), Some("/new")); 1189 + } 1190 + 1191 + #[test] 1192 + fn parse_response_302() { 1193 + let data = b"HTTP/1.1 302 Found\r\nLocation: /other\r\nContent-Length: 0\r\n\r\n"; 1194 + let resp = parse_response(data).unwrap(); 1195 + assert_eq!(resp.status_code, 302); 1196 + assert_eq!(resp.headers.get("Location"), Some("/other")); 1197 + } 1198 + 1199 + #[test] 1200 + fn parse_response_303() { 1201 + let data = b"HTTP/1.1 303 See Other\r\nLocation: /see\r\nContent-Length: 0\r\n\r\n"; 1202 + let resp = parse_response(data).unwrap(); 1203 + assert_eq!(resp.status_code, 303); 1204 + assert_eq!(resp.headers.get("Location"), Some("/see")); 1205 + } 1206 + 1207 + #[test] 1208 + fn parse_response_307() { 1209 + let data = 1210 + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: /temp\r\nContent-Length: 0\r\n\r\n"; 1211 + let resp = parse_response(data).unwrap(); 1212 + assert_eq!(resp.status_code, 307); 1213 + assert_eq!(resp.headers.get("Location"), Some("/temp")); 1214 + } 1215 + 1216 + #[test] 1217 + fn parse_response_308() { 1218 + let data = 1219 + b"HTTP/1.1 308 Permanent Redirect\r\nLocation: /perm\r\nContent-Length: 0\r\n\r\n"; 1220 + let resp = parse_response(data).unwrap(); 1221 + assert_eq!(resp.status_code, 308); 1222 + assert_eq!(resp.headers.get("Location"), Some("/perm")); 1152 1223 } 1153 1224 1154 1225 // -- Default trait --