Skip to content

Commit f43727d

Browse files
committed
Verify the SAMLResponse based on the raw query string
BREAKING CHANGE: The verify function now requires the *raw* query string from the server in order to verify the signature. Because `URI` parses and re-encodes URI-escapes in uppercase (`%3f` becomes `%3F`, for instance), which leads to signature verification failures if the other party uses lower case (or mixed case). This seems to have been an issue with the current code base as well due to the various flags that need to be supplied to the constructor to work around it. The problem lies with the RFC 3986[^1] and the SAML specs[^2] that both offer different implementations of the same thing. RFC 3986 states that say the URI must be normalized so uppercasing %2f to %2F is correct behavior. The SAML specs states that we must operate on the original URL-encoded values. Thus no uppercasing is allowed. It is the author's opinion that Net::SAML2 should follow the SAML spec and and that implementations that normalize the URI should return the original URI to the application. This code is mostly ported from Zaaksysteem's code base, written by MSTREEK. Users of lighttpd need to be aware that they need to configure their instance with the following http-parseopts: server.http-parseopts = ( "url-normalize" => "disable", "url-normalize-unreserved" => "disable", "url-normalize-required" => "disable" ) You cannot change it on a URI basis because lighttpd needs a parsed URI before it can process conditions as was mentioned on #lighttpd: > The request must be parsed (using server.http-parseopts settings) *before* you > can apply lighttpd.conf conditions to the parsed results. [^1]: https://www.rfc-editor.org/rfc/rfc3986#section-6.2.2.1 [^2]: https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf (line 620) Signed-off-by: Wesley Schwengle <waterkip@cpan.org>
1 parent 5fd7f45 commit f43727d

File tree

4 files changed

+45
-74
lines changed

4 files changed

+45
-74
lines changed

Makefile.PL

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ my %WriteMakefileArgs = (
4545
"Types::Serialiser" => 0,
4646
"URI" => 0,
4747
"URI::Encode" => 0,
48+
"URI::Escape" => 0,
4849
"URI::QueryParam" => 0,
4950
"URN::OASIS::SAML2" => "0.002",
5051
"XML::Enc" => "0.05",
@@ -124,6 +125,7 @@ my %FallbackPrereqs = (
124125
"Types::Serialiser" => 0,
125126
"URI" => 0,
126127
"URI::Encode" => 0,
128+
"URI::Escape" => 0,
127129
"URI::QueryParam" => 0,
128130
"URI::URL" => 0,
129131
"URN::OASIS::SAML2" => "0.002",

cpanfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ requires "MooseX::Types::URI" => "0";
2929
requires "Types::Serialiser" => "0";
3030
requires "URI" => "0";
3131
requires "URI::Encode" => "0";
32+
requires "URI::Escape" => "0";
3233
requires "URI::QueryParam" => "0";
3334
requires "URN::OASIS::SAML2" => "0.002";
3435
requires "XML::Enc" => "0.05";

lib/Net/SAML2/Binding/Redirect.pm

Lines changed: 39 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1-
use strict;
2-
use warnings;
31
package Net::SAML2::Binding::Redirect;
2+
use Moose;
3+
44
# VERSION
55

6-
use Moose;
6+
use Carp qw(croak);
7+
use Crypt::OpenSSL::RSA;
8+
use Crypt::OpenSSL::X509;
9+
use File::Slurper qw/ read_text /;
10+
use IO::Compress::RawDeflate qw/ rawdeflate /;
11+
use IO::Uncompress::RawInflate qw/ rawinflate /;
12+
use MIME::Base64 qw/ encode_base64 decode_base64 /;
713
use MooseX::Types::URI qw/ Uri /;
814
use Net::SAML2::Types qw(signingAlgorithm SAMLRequestType);
9-
use Carp qw(croak);
15+
use URI::Encode qw/uri_decode/;
16+
use URI::Escape qw(uri_unescape);
17+
use URI::QueryParam;
18+
use URI;
1019

1120
# ABSTRACT: Net::SAML2::Binding::Redirect - HTTP Redirect binding for SAML
1221

13-
=head1 NAME
14-
15-
Net::SAML2::Binding::Redirect
16-
1722
=head1 SYNOPSIS
1823
1924
my $redirect = Net::SAML2::Binding::Redirect->new(
@@ -32,16 +37,6 @@ Net::SAML2::Binding::Redirect
3237
3338
=cut
3439

35-
use MIME::Base64 qw/ encode_base64 decode_base64 /;
36-
use IO::Compress::RawDeflate qw/ rawdeflate /;
37-
use IO::Uncompress::RawInflate qw/ rawinflate /;
38-
use URI;
39-
use URI::QueryParam;
40-
use Crypt::OpenSSL::RSA;
41-
use Crypt::OpenSSL::X509;
42-
use File::Slurper qw/ read_text /;
43-
use URI::Encode qw/uri_decode/;
44-
4540
=head2 new( ... )
4641
4742
Constructor. Creates an instance of the Redirect binding.
@@ -218,7 +213,7 @@ sub sign {
218213
return $u->as_string;
219214
}
220215

221-
sub _verified {
216+
sub _verify {
222217
my ($self, $sigalg, $signed, $sig) = @_;
223218

224219
foreach my $crt (@{$self->cert}) {
@@ -235,84 +230,60 @@ sub _verified {
235230
$rsa_pub->use_sha512_hash;
236231
} elsif ($sigalg eq 'http://www.w3.org/2000/09/xmldsig#rsa-sha1') {
237232
$rsa_pub->use_sha1_hash;
238-
} else {
239-
warn "Unsupported Signature Algorithim: $sigalg" if ($self->debug);
240233
}
241-
242-
if ($rsa_pub->verify($signed, $sig)) {
243-
return 1;
234+
else {
235+
warn "Unsupported Signature Algorithim: $sigalg, defaulting to sha256" if $self->debug;
244236
}
245237

246-
warn "Unable to verify with " . $cert->subject if ($self->debug);
238+
return 1 if $rsa_pub->verify($signed, $sig);
239+
240+
warn "Unable to verify with " . $cert->subject if $self->debug;
247241
}
248242

249-
die "bad sig";
243+
croak("Unable to verify the XML signature");
250244
}
251245

252-
=head2 verify( $url )
246+
=head2 verify( $query_string )
253247
254248
Decode a Redirect binding URL.
255249
256250
Verifies the signature on the response.
257251
252+
Requires the *raw* query string to be passed, because L<URI> parses and
253+
re-encodes URI-escapes in uppercase (C<%3f> becomes C<%3F>, for instance),
254+
which leads to signature verification failures if the other party uses lower
255+
case (or mixed case).
256+
258257
=cut
259258

260259
sub verify {
261260
my ($self, $url) = @_;
262-
my $u = URI->new($url);
263261

264-
# verify the response
265-
my $sigalg = $u->query_param('SigAlg');
262+
# This now becomes the query string
263+
$url =~ s#^https?://.+\?##;
266264

267-
my $signed;
268-
my $saml_request;
269-
my $sig = $u->query_param_delete('Signature');
265+
my %params = map { split(/=/, $_, 2) } split(/&/, $url);
270266

271-
# During the verify the only query parameters that should be in the query are
272-
# 'SAMLRequest', 'RelayState', 'Sig', 'SigAlg' the other parameter values are
273-
# deleted from the URI query that was created from the URL that was passed
274-
# to the verify function
275-
my @signed_params = ('SAMLRequest', 'SAMLResponse', 'RelayState', 'Sig', 'SigAlg');
276-
277-
for my $key ($u->query_param) {
278-
if (grep /$key/, @signed_params ) {
279-
next;
280-
}
281-
$u->query_param_delete($key);
282-
}
267+
my $sigalg = uri_unescape($params{SigAlg});
283268

284-
# Some IdPs (PingIdentity) seem to double encode the LogoutResponse URL
285-
if ($self->sls_double_encoded_response) {
286-
#if ($sigalg =~ m/%/) {
287-
$signed = uri_decode($u->query);
288-
$sig = uri_decode($sig);
289-
$sigalg = uri_decode($sigalg);
290-
$saml_request = uri_decode($u->query_param($self->param));
291-
} else {
292-
$signed = $u->query;
293-
$saml_request = $u->query_param($self->param);
294-
}
269+
my $encoded_sig = uri_unescape($params{Signature});
270+
my $sig = decode_base64($encoded_sig);
295271

296-
# What can we say about this one Microsoft Azure uses lower case in the
297-
# URL encoding %2f not %2F. As it is signed as %2f the resulting signed
298-
# needs to change it to lowercase if the application layer reencoded the URL.
299-
if ($self->sls_force_lcase_url_encoding) {
300-
# TODO: This is a hack.
301-
$signed =~ s/(%..)/lc($1)/ge;
272+
my @signed_parts;
273+
for my $p ($self->param, qw(RelayState SigAlg)) {
274+
push @signed_parts, join('=', $p, $params{$p}) if exists $params{$p};
302275
}
276+
my $signed = join('&', @signed_parts);
303277

304-
$sig = decode_base64($sig);
305-
306-
$self->_verified($sigalg, $signed, $sig);
278+
$self->_verify($sigalg, $signed, $sig);
307279

308280
# unpack the SAML request
309-
my $deflated = decode_base64($saml_request);
281+
my $deflated = decode_base64(uri_unescape($params{$self->param}));
310282
my $request = '';
311283
rawinflate \$deflated => \$request;
312284

313285
# unpack the relaystate
314-
my $relaystate = $u->query_param('RelayState');
315-
286+
my $relaystate = uri_unescape($params{'RelayState'});
316287
return ($request, $relaystate);
317288
}
318289

t/17-lowercase-url-escaping.t

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use Test::Lib;
22
use Test::Net::SAML2;
3-
use Test::More tests => 2;
43

54
use Net::SAML2::Binding::Redirect;
65
use File::Slurper qw/read_text/;
@@ -15,13 +14,11 @@ my $redirect = Net::SAML2::Binding::Redirect->new(
1514
param => 'SAMLRequest',
1615
cert => read_text('t/net-saml2-cert.pem'),
1716
sig_hash => 'sha256',
18-
sls_force_lcase_url_encoding => 1,
1917
);
2018

2119
my ($request, $relaystate) = $redirect->verify($url);
2220

23-
like($request, qr/NETSAML/, 'Good Signature if sls_force_lcase_url_encoding = 1');
21+
like($request, qr/NETSAML/,
22+
"Good Signature because we now don't alter the input with URI anymore");
2423

25-
$redirect->{sls_force_lcase_url_encoding} = 0;
26-
27-
throws_ok( sub { $redirect->verify($url) }, qr/bad sig/, "Bad Signature if sls_force_lcase_url_encoding = 0");
24+
done_testing();

0 commit comments

Comments
 (0)