Compare commits

...

2 Commits

Author SHA1 Message Date
Gregory Schier
8e4fc67284 Fix sensitive headers leaking on cross-origin redirects
Strip Authorization, Cookie, cookie2, Proxy-Authorization, and
WWW-Authenticate headers when following a redirect to a different
host. This matches reqwest's remove_sensitive_headers() behavior
and fixes the issue where APIs redirecting to S3 would fail because
the Authorization header was forwarded, conflicting with S3's own
auth mechanism.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 14:11:38 -08:00
Rahul Mishra
c4ce458f79 fix: pass down onClose properly (#376) 2026-01-31 07:34:40 -08:00
4 changed files with 196 additions and 15 deletions

View File

@@ -168,6 +168,7 @@ impl<S: HttpSender> HttpTransaction<S> {
response.drain().await?;
// Update the request URL
let previous_url = current_url.clone();
current_url = if location.starts_with("http://") || location.starts_with("https://") {
// Absolute URL
location
@@ -181,6 +182,8 @@ impl<S: HttpSender> HttpTransaction<S> {
format!("{}/{}", base_path, location)
};
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Determine redirect behavior based on status code and method
let behavior = if status == 303 {
// 303 See Other always changes to GET
@@ -220,6 +223,33 @@ impl<S: HttpSender> HttpTransaction<S> {
}
}
/// Remove sensitive headers when redirecting to a different host.
/// This matches reqwest's `remove_sensitive_headers()` behavior and prevents
/// credentials from being forwarded to third-party servers (e.g., an
/// Authorization header sent from an API redirect to an S3 bucket).
fn remove_sensitive_headers(
headers: &mut Vec<(String, String)>,
previous_url: &str,
next_url: &str,
) {
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
});
let next_host = Url::parse(next_url).ok().and_then(|u| {
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
});
if previous_host != next_host {
headers.retain(|h| {
let name_lower = h.0.to_lowercase();
name_lower != "authorization"
&& name_lower != "cookie"
&& name_lower != "cookie2"
&& name_lower != "proxy-authorization"
&& name_lower != "www-authenticate"
});
}
}
/// Check if a status code indicates a redirect
fn is_redirect(status: u16) -> bool {
matches!(status, 301 | 302 | 303 | 307 | 308)
@@ -269,9 +299,20 @@ mod tests {
use tokio::io::AsyncRead;
use tokio::sync::Mutex;
/// Captured request metadata for test assertions
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct CapturedRequest {
url: String,
method: String,
headers: Vec<(String, String)>,
}
/// Mock sender for testing
struct MockSender {
responses: Arc<Mutex<Vec<MockResponse>>>,
/// Captured requests for assertions
captured_requests: Arc<Mutex<Vec<CapturedRequest>>>,
}
struct MockResponse {
@@ -282,7 +323,10 @@ mod tests {
impl MockSender {
fn new(responses: Vec<MockResponse>) -> Self {
Self { responses: Arc::new(Mutex::new(responses)) }
Self {
responses: Arc::new(Mutex::new(responses)),
captured_requests: Arc::new(Mutex::new(Vec::new())),
}
}
}
@@ -290,9 +334,16 @@ mod tests {
impl HttpSender for MockSender {
async fn send(
&self,
_request: SendableHttpRequest,
request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
// Capture the request metadata for later assertions
self.captured_requests.lock().await.push(CapturedRequest {
url: request.url.clone(),
method: request.method.clone(),
headers: request.headers.clone(),
});
let mut responses = self.responses.lock().await;
if responses.is_empty() {
Err(crate::error::Error::RequestError("No more mock responses".to_string()))
@@ -726,4 +777,116 @@ mod tests {
assert!(result.is_ok());
assert_eq!(request_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn test_cross_origin_redirect_strips_auth_headers() {
// Redirect from api.example.com -> s3.amazonaws.com should strip Authorization
let responses = vec![
MockResponse {
status: 302,
headers: vec![(
"Location".to_string(),
"https://s3.amazonaws.com/bucket/file.pdf".to_string(),
)],
body: vec![],
},
MockResponse { status: 200, headers: Vec::new(), body: b"PDF content".to_vec() },
];
let sender = MockSender::new(responses);
let captured = sender.captured_requests.clone();
let transaction = HttpTransaction::new(sender);
let request = SendableHttpRequest {
url: "https://api.example.com/download".to_string(),
method: "GET".to_string(),
headers: vec![
("Authorization".to_string(), "Basic dXNlcjpwYXNz".to_string()),
("Accept".to_string(), "application/pdf".to_string()),
],
options: crate::types::SendableHttpRequestOptions {
follow_redirects: true,
..Default::default()
},
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
assert_eq!(result.status, 200);
let requests = captured.lock().await;
assert_eq!(requests.len(), 2);
// First request should have the Authorization header
assert!(
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"First request should have Authorization header"
);
// Second request (to different host) should NOT have the Authorization header
assert!(
!requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"Redirected request to different host should NOT have Authorization header"
);
// Non-sensitive headers should still be present
assert!(
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")),
"Non-sensitive headers should be preserved across cross-origin redirects"
);
}
#[tokio::test]
async fn test_same_origin_redirect_preserves_auth_headers() {
// Redirect within the same host should keep Authorization
let responses = vec![
MockResponse {
status: 302,
headers: vec![(
"Location".to_string(),
"https://api.example.com/v2/download".to_string(),
)],
body: vec![],
},
MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() },
];
let sender = MockSender::new(responses);
let captured = sender.captured_requests.clone();
let transaction = HttpTransaction::new(sender);
let request = SendableHttpRequest {
url: "https://api.example.com/v1/download".to_string(),
method: "GET".to_string(),
headers: vec![
("Authorization".to_string(), "Bearer token123".to_string()),
("Accept".to_string(), "application/json".to_string()),
],
options: crate::types::SendableHttpRequestOptions {
follow_redirects: true,
..Default::default()
},
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
assert_eq!(result.status, 200);
let requests = captured.lock().await;
assert_eq!(requests.len(), 2);
// Both requests should have the Authorization header (same host)
assert!(
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"First request should have Authorization header"
);
assert!(
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"Redirected request to same host should preserve Authorization header"
);
}
}

View File

@@ -98,13 +98,14 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event }) => (
renderDetail={({ event, onClose }) => (
<GrpcEventDetail
event={event}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
@@ -147,19 +148,26 @@ function GrpcEventDetail({
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: GrpcEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} />
<EventDetailHeader
title={title}
timestamp={event.createdAt}
copyText={event.content}
onClose={onClose}
/>
{!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
@@ -197,7 +205,7 @@ function GrpcEventDetail({
// Error or connection_end - show metadata/trailers
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={event.content} timestamp={event.createdAt} />
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
{event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error}

View File

@@ -13,7 +13,7 @@ import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon';
@@ -75,7 +75,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, index }) => (
renderDetail={({ event, index, onClose }) => (
<WebsocketEventDetail
event={event}
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
@@ -84,6 +84,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
@@ -145,6 +146,7 @@ function WebsocketEventDetail({
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: WebsocketEvent;
hexDump: boolean;
@@ -153,6 +155,7 @@ function WebsocketEventDetail({
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const message = useMemo(() => {
if (hexDump) {
@@ -185,11 +188,12 @@ function WebsocketEventDetail({
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
copyText={formattedMessage || undefined}
/>
title={title}
timestamp={event.createdAt}
actions={actions}
copyText={formattedMessage || undefined}
onClose={onClose}
/>
{!showLarge && event.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden

View File

@@ -51,10 +51,9 @@ function ActualEventStreamViewer({ response }: Props) {
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
/>
)}
renderDetail={({ event, index }) => (
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
@@ -62,6 +61,7 @@ function ActualEventStreamViewer({ response }: Props) {
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
@@ -75,6 +75,7 @@ function EventDetail({
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: ServerSentEvent;
index: number;
@@ -82,6 +83,7 @@ function EventDetail({
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text';
@@ -90,7 +92,11 @@ function EventDetail({
return (
<div className="flex flex-col h-full">
<EventDetailHeader title="Message Received" prefix={<EventLabels event={event} index={index} />} />
<EventDetailHeader
title="Message Received"
prefix={<EventLabels event={event} index={index} />}
onClose={onClose}
/>
{!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden