diff --git a/Changes b/Changes index 254eb7ff..81481927 100644 --- a/Changes +++ b/Changes @@ -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] diff --git a/lib/WWW/Mechanize.pm b/lib/WWW/Mechanize.pm index 9dfd7a47..1a40e945 100644 --- a/lib/WWW/Mechanize.pm +++ b/lib/WWW/Mechanize.pm @@ -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'; @@ -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 = @_; @@ -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(); @@ -3288,9 +3299,13 @@ L 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 @@ -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 ); @@ -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 to intercept the diff --git a/t/cross-origin-redirect.t b/t/cross-origin-redirect.t new file mode 100644 index 00000000..501b2cff --- /dev/null +++ b/t/cross-origin-redirect.t @@ -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;