Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Changes
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Revision history for WWW::Mechanize

{{$NEXT}}
[FIXED]
- Credential headers set via add_header() (Authorization,
Proxy-Authorization and Cookie) are no longer re-applied to cross-origin
redirect targets, so they are not disclosed to a different origin. This
matches LWP::UserAgent's own redirect handling; set
allow_credentialed_redirects to a true value to opt out. (GH#433) (Olaf
Alders)

2.22 2026-06-18 20:59:34Z
[ENHANCEMENTS]
Expand Down
60 changes: 57 additions & 3 deletions lib/WWW/Mechanize.pm
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ use Tie::RefHash ();
use HTTP::Request 1.30 ();
use HTML::Form 1.00 ();
use HTML::TokeParser ();
use Scalar::Util qw( blessed );

use parent 'LWP::UserAgent';

Expand Down Expand Up @@ -291,6 +292,12 @@ sub new {
strict_forms => 0, # pass-through to HTML::Form
verbose_forms => 0, # pass-through to HTML::Form
marked_sections => 1,

# Owned by Mech so the cross-origin credential strip honours it on
# every LWP version. We store it in the same hash slot LWP::UserAgent
# uses, so LWP's own strip (6.83+) reads it too, and we keep it out of
# the parent constructor, which would carp on it under older LWP.
allow_credentialed_redirects => 0,
);

my %passed_params = @_;
Expand Down Expand Up @@ -3068,7 +3075,11 @@ sub request {
$self->die('->request was called without a request parameter')
unless $request;

$request = $self->_modify_request($request);
# On a redirect or auth retry, LWP::UserAgent re-enters request() and
# threads the triggering response through as the fourth positional
# argument ( $request, $arg, $size, $response ). _modify_request() uses
# it to detect a cross-origin hop. @_ now holds ( $arg, $size, $response ).
$request = $self->_modify_request( $request, $_[2] );

if ( $request->method eq 'GET' || $request->method eq 'POST' ) {
$self->_push_page_stack();
Expand Down Expand Up @@ -3288,9 +3299,13 @@ L<Compress::Zlib> is installed.

=cut

my %CREDENTIAL_HEADER
= map { $_ => 1 } qw( authorization proxy-authorization cookie );

sub _modify_request {
my $self = shift;
my $req = shift;
my $self = shift;
my $req = shift;
my $previous = shift;

# add correct Accept-Encoding header to restore compliance with
# http://www.freesoft.org/CIE/RFC/2068/158.htm
Expand All @@ -3306,6 +3321,18 @@ sub _modify_request {
$last = $last->as_string if ref($last);
$req->header( Referer => $last );
}

# Detect a cross-origin redirect hop. LWP::UserAgent threads the triggering
# response in as $previous when it re-enters request() to follow a redirect;
# $previous->request->uri is the origin we are coming from.
my $strip_credentials
= $previous
&& blessed($previous)
&& $previous->can('request')
&& $previous->request
&& !$self->{allow_credentialed_redirects}
&& $self->_is_cross_origin( $previous->request->uri, $req->uri );

while ( my ( $key, $value ) = each %{ $self->{headers} } ) {
if ( defined $value ) {
$req->header( $key => $value );
Expand All @@ -3315,9 +3342,36 @@ sub _modify_request {
}
}

# On a cross-origin redirect, credential headers must not be disclosed to
# the new origin (the CVE-2018-1000007 class of leak). Remove them here so
# neither our own persistent add_header() values (re-applied just above)
# nor headers carried forward by LWP survive the hop. Recent LWP strips
# these from the redirected request itself, but older releases clone them
# forward, so we cannot rely on that. allow_credentialed_redirects opts out.
if ($strip_credentials) {
$req->remove_header( keys %CREDENTIAL_HEADER );
}

return $req;
}

# Mirror the scheme/host/port comparison LWP::UserAgent uses when deciding
# whether a redirect crosses origins (see LWP::UserAgent::request).
sub _is_cross_origin {
my ( $self, $from, $to ) = @_;

# Both URIs are absolute request URIs (LWP rejects relative URLs before
# they get here), so scheme is always defined. canonical() lower-cases the
# scheme and host, so a case-only difference does not count as cross-origin,
# and host_port supplies the scheme's default port when none is given, so
# http://h/ and http://h:80/ compare equal.
$from = $from->canonical;
$to = $to->canonical;

return $from->scheme ne $to->scheme
|| $from->host_port ne $to->host_port;
}

=head2 $mech->_make_request()

Convenience method to make it easier for subclasses like L<WWW::Mechanize::Cached> to intercept the
Expand Down
183 changes: 183 additions & 0 deletions t/cross-origin-redirect.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
use strict;
use warnings;

use Test::More;
use HTTP::Response ();

# A persistent header set via add_header() must not be re-applied to a
# cross-origin redirect target. LWP strips Authorization/Proxy-Authorization
# on cross-origin redirects (the CVE-2018-1000007 mitigation); Mech must not
# put them back via its _modify_request() header loop.
#
# Transport is mocked so LWP's *real* redirect loop runs; only the socket
# layer is replaced. Each request the agent emits is recorded so we can
# inspect the header that reached the redirect target.

{
package TestMech;
use parent 'WWW::Mechanize';

sub simple_request {
my ( $self, $req ) = @_;
push @{ $self->{_seen} }, $req;

my $location = $self->{_route}{ $req->uri->as_string };
if ($location) {
my $r = HTTP::Response->new( $self->{_status} || 302 );
$r->header( Location => $location );
$r->request($req);
return $r;
}
my $r = HTTP::Response->new(
200, 'OK',
[ 'Content-Type', 'text/html' ], 'ok'
);
$r->request($req);
return $r;
}
}

my @cases = (
{
name => 'cross-origin redirect strips persistent Authorization',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/',
location => 'http://hostb.example/',
opts => {},
expect => undef,
},
{
name => 'cross-origin redirect strips persistent Proxy-Authorization',
header => 'Proxy-Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/',
location => 'http://hostb.example/',
opts => {},
expect => undef,
},
{
name => 'same-origin redirect preserves persistent Authorization',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/one',
location => 'http://hosta.example/two',
opts => {},
expect => 'Basic SECRET',
},
{
name =>
'cross-origin to different port strips persistent Authorization',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/',
location => 'http://hosta.example:8080/',
opts => {},
expect => undef,
},
{
name =>
'case-only host difference is same-origin and preserves header',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/',
location => 'http://HOSTA.example/',
opts => {},
expect => 'Basic SECRET',
},
{
name => 'cross-origin redirect strips persistent Cookie',
header => 'Cookie',
value => 'session=SECRET',
start => 'http://hosta.example/',
location => 'http://hostb.example/',
opts => {},
expect => undef,
},
{
name => 'same-origin redirect preserves persistent Cookie',
header => 'Cookie',
value => 'session=SECRET',
start => 'http://hosta.example/one',
location => 'http://hosta.example/two',
opts => {},
expect => 'session=SECRET',
},
{
name => 'allow_credentialed_redirects opts back in to cross-origin',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/',
location => 'http://hostb.example/',
opts => { allow_credentialed_redirects => 1 },
expect => 'Basic SECRET',
},
{
name => 'scheme change (http to https) is cross-origin and strips',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/',
location => 'https://hosta.example/',
opts => {},
expect => undef,
},
{
name => '307 redirect strips persistent Authorization cross-origin',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/',
location => 'http://hostb.example/',
status => 307,
opts => {},
expect => undef,
},
{
# Same-origin hop preserves the header, then a cross-origin hop strips
# it: the verdict is made per hop, so a later same-origin target does
# not get the credential back once an intervening hop has dropped it.
name => 'multi-hop redirect strips at the cross-origin hop',
header => 'Authorization',
value => 'Basic SECRET',
start => 'http://hosta.example/one',
route => {
'http://hosta.example/one' => 'http://hosta.example/two',
'http://hosta.example/two' => 'http://hostb.example/',
},
final => 'http://hostb.example/',
opts => {},
expect => undef,
},
);

for my $case (@cases) {
subtest $case->{name} => sub {
my $mech = TestMech->new( autocheck => 0, %{ $case->{opts} } );
$mech->{_route}
= $case->{route} || { $case->{start} => $case->{location} };
$mech->{_status} = $case->{status};
$mech->add_header( $case->{header} => $case->{value} );

$mech->get( $case->{start} );

my @seen = @{ $mech->{_seen} };
is(
$seen[0]->header( $case->{header} ),
$case->{value},
'first request carries the persistent header',
);

my $final = $seen[-1];
is(
$final->uri->as_string,
$case->{final} || $case->{location},
'final request reached the redirect target',
);
is(
scalar $final->header( $case->{header} ),
$case->{expect},
'header on the redirect target is as expected',
);
};
}

done_testing;