AWS-Signature-V4
view release on metacpan or search on metacpan
t/canonical.t view on Meta::CPAN
use v5.24;
use utf8;
use experimental 'signatures';
use Test2::V0;
use FindBin '$Bin';
use lib "$Bin/../lib";
use AWS::Signature::V4;
use Digest::SHA qw< sha256_hex >;
my %cred = (access_key_id => 'AKID', secret_access_key => 'SECRET');
my $std = AWS::Signature::V4->new(service => 'service', region => 'us-east-1', credentials => {%cred});
my $s3 = AWS::Signature::V4->new(service => 's3', region => 'us-east-1', credentials => {%cred});
sub canon ($signer, $url, %rest) {
my $r = $signer->sign(method => 'GET', url => $url, time => 1440938160, %rest);
my @lines = split /\n/, $r->{canonical_request}, -1;
return ($lines[1], $lines[2], $r); # path, query, whole result
}
sub path_of ($signer, $url) { (canon($signer, $url))[0] }
sub query_of ($signer, $url) { (canon($signer, $url))[1] }
sub req (@args) { (canon(@args))[2] }
# errors caused by the caller: Ouch exceptions with code 400
sub bad ($re = qr/./) {
object { prop blessed => 'Ouch'; call code => 400; call message => match $re };
}
subtest 'path, non-S3 (normalized, double-encoded)' => sub {
my %case = ( # from the AWS SigV4 test suite
'https://h' => '/',
'https://h/' => '/',
'https://h/example/..' => '/',
'https://h/./' => '/',
'https://h/./example' => '/example',
'https://h//example//' => '/example/',
'https://h/example/./a/../b' => '/example/b',
'https://h/../a' => '/a',
'https://h/example/' => '/example/',
'https://h/example space/' => '/example%2520space/',
'https://h/example%20space/' => '/example%2520space/',
'https://h/%E1%88%B4' => '/%25E1%2588%25B4',
'https://h/a-._~b' => '/a-._~b',
'https://h/a$b' => '/a%2524b',
);
is path_of($std, $_), $case{$_}, $_ for sort keys %case;
};
subtest 'path, S3 (verbatim, single-encoded)' => sub {
my %case = (
'https://h/' => '/',
'https://h/example space/' => '/example%20space/',
'https://h/example%20space/' => '/example%20space/',
'https://h/a/../b' => '/a/../b',
'https://h//a//b' => '//a//b',
'https://h/%E1%88%B4' => '/%E1%88%B4',
'https://h/a$b' => '/a%24b',
);
is path_of($s3, $_), $case{$_}, $_ for sort keys %case;
};
subtest 'query' => sub {
my %case = (
'https://h/' => '',
'https://h/?' => '',
'https://h/?Param1=value1' => 'Param1=value1',
'https://h/?Param2=v2&Param1=v1' => 'Param1=v1&Param2=v2',
'https://h/?a=2&a=1&a=3' => 'a=1&a=2&a=3',
'https://h/?B=1&a=1&b=1&A=1' => 'A=1&B=1&a=1&b=1',
'https://h/?a&b=' => 'a=&b=',
'https://h/?a=&a=x' => 'a=&a=x',
'https://h/?k=a b' => 'k=a%20b',
'https://h/?k=a%20b' => 'k=a%20b',
t/canonical.t view on Meta::CPAN
is req($std, $url)->{headers}{host}, $case{$url}, $url;
}
like req($std, 'https://STS.Amazonaws.com/')->{canonical_request},
qr/\nhost:sts\.amazonaws\.com\n/, 'the lowercase host is signed';
is req($std, 'https://a/', headers => {Host => 'b'})->{headers}{host}, 'b',
'explicit Host wins';
is dies { canon($std, 'https://user:pw@example.com/') }, bad(qr/user/),
'user information is refused';
for my $url ('https://evil.example\\.b.s3.amazonaws.com/x', 'https://b s3/',
'https://h:abc/', 'https://h:443:443/', 'https://h:123456/') {
is dies { canon($std, $url) }, bad(qr/invalid/), "invalid url: $url";
}
};
subtest 'headers' => sub {
my $r = req($std, 'https://h/', headers => {
'X-Amz-Meta-B' => ' a b ',
'x-amz-meta-a' => "tab\there",
'X-Multi' => ['1', '2'],
'User-Agent' => 'ignored',
'Authorization' => 'ignored',
});
like $r->{canonical_request},
qr{\nhost:h\nx-amz-date:20150830T123600Z\nx-amz-meta-a:tab here\nx-amz-meta-b:a b\nx-multi:1,2\n\nhost;x-amz-date;x-amz-meta-a;x-amz-meta-b;x-multi\n},
'lowercased, sorted, trimmed, collapsed, multi-valued joined';
unlike $r->{signed_headers}, qr/user-agent|authorization/, 'unsigned headers left out';
is $r->{headers}{'user-agent'}, 'ignored', 'but still returned';
# arrayref pairs, repeated names are merged
$r = req($std, 'https://h/', headers => [X => 'a', x => 'b']);
like $r->{canonical_request}, qr/\nx:a,b\n/, 'repeated header names merged';
$r = req($std, 'https://h/',
headers => {A => 1, B => 2}, signed_headers => ['B', 'Host', 'x-amz-date']);
is $r->{signed_headers}, 'b;host;x-amz-date', 'explicit signed_headers';
is dies { canon($std, 'https://h/', signed_headers => ['nope']) },
bad(qr/missing header/), 'signing a missing header croaks';
# only ASCII whitespace is trimmed: 0xA0 and 0x85 are UTF-8 bytes here
$r = req($std, 'https://h/', headers => {
'x-amz-meta-title' => "citt\xC3\xA0", 'x-amz-meta-ni' => " \xE4\xBD\xA0\x85 "});
like $r->{canonical_request}, qr/\nx-amz-meta-title:citt\xC3\xA0\n/, 'trailing 0xA0 kept';
like $r->{canonical_request}, qr/\nx-amz-meta-ni:\xE4\xBD\xA0\x85\n/,
'inner 0xA0 and 0x85 kept, spaces trimmed';
# undefined values are like missing headers
is warns {
$r = req($std, 'https://h/', headers => {Host => undef, 'X-A' => undef, 'X-B' => ['1', undef]});
}, 0, 'undefined header values: no warnings';
is $r->{headers}{host}, 'h', 'undefined Host: taken from the url';
ok !exists $r->{headers}{'x-a'}, 'undefined value: no header';
is $r->{headers}{'x-b'}, '1', 'undefined items of a list are skipped';
# names that differ only in case are joined in a repeatable order
is req($std, 'https://h/', headers => {'X-Foo' => 'a', 'x-foo' => 'b', 'X-FOO' => 'c'})
->{headers}{'x-foo'}, 'c,a,b', 'sorted by name as given';
};
subtest 'x-amz-* headers are always signed' => sub {
my $t = AWS::Signature::V4->new(service => 's3', region => 'r',
credentials => {%cred, session_token => 'TOK'});
my $r = $t->sign(method => 'PUT', url => 'https://b.s3.amazonaws.com/k', time => 0,
headers => {'Content-Type' => 'text/plain', 'X-Amz-Acl' => 'private'},
signed_headers => ['content-type'], body => 'abc');
is $r->{signed_headers},
'content-type;host;x-amz-acl;x-amz-content-sha256;x-amz-date;x-amz-security-token',
'those given and those added by sign';
is req($std, 'https://h/', signed_headers => [])->{signed_headers}, 'host;x-amz-date',
'even with an empty list';
};
subtest 'payload' => sub {
my $empty = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
my $r = req($std, 'https://h/');
like $r->{canonical_request}, qr/\n$empty\z/, 'empty body';
ok !exists $r->{headers}{'x-amz-content-sha256'}, 'no payload header outside S3';
$r = req($s3, 'https://h/');
is $r->{headers}{'x-amz-content-sha256'}, $empty, 'S3 payload header';
like $r->{signed_headers}, qr/x-amz-content-sha256/, 'and it is signed';
$r = req($std, 'https://h/', unsigned_payload => 1);
like $r->{canonical_request}, qr/\nUNSIGNED-PAYLOAD\z/, 'unsigned payload';
$r = req($std, 'https://h/', payload_hash => 'STREAMING-X');
like $r->{canonical_request}, qr/\nSTREAMING-X\z/, 'explicit payload hash';
$r = req($std, 'https://h/', body => "\xc3\xa9");
like $r->{canonical_request}, qr/\n\Q@{[ sha256_hex("\xc3\xa9") ]}\E\z/,
'byte strings are hashed as they are';
is dies { canon($std, 'https://h/', body => "\x{263a}") },
bad(qr/byte string/), 'wide characters in the body are rejected';
$r = req($std, 'https://h/', body => 'abc', payload_hash => undef);
like $r->{canonical_request}, qr/\n\Q@{[ sha256_hex('abc') ]}\E\z/,
'undefined payload_hash: the body is hashed';
$r = req($std, 'https://h/', payload_hash => 'BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD');
like $r->{canonical_request}, qr/\nba7816bf[0-9a-f]{56}\z/, 'hex payload_hash is lowercased';
for my $hash ("x\r\nX-Injected: 1", 'HASH', 'a' x 63, {}) {
is dies { canon($s3, 'https://h/', payload_hash => $hash) }, bad(qr/payload_hash/),
'invalid payload_hash ' . (ref $hash || $hash =~ s/[\r\n]/?/gr);
}
};
subtest 'payload header' => sub {
my $lambda = AWS::Signature::V4->new(service => 'lambda', region => 'r', credentials => {%cred});
my $r = req($lambda, 'https://h/', body => '{}', unsigned_payload => 1);
is $r->{headers}{'x-amz-content-sha256'}, 'UNSIGNED-PAYLOAD', 'unsigned payload: header added';
like $r->{signed_headers}, qr/x-amz-content-sha256/, 'and signed';
$r = req($lambda, 'https://h/', payload_hash => 'STREAMING-X');
is $r->{headers}{'x-amz-content-sha256'}, 'STREAMING-X', 'same for any marker';
my $abc = sha256_hex('abc');
$r = req($s3, 'https://h/', headers => {'X-Amz-Content-Sha256' => $abc});
is $r->{headers}{'x-amz-content-sha256'}, $abc, 'S3: the header given is kept';
like $r->{canonical_request}, qr/\n$abc\z/, 'and used as the payload hash';
$r = req($lambda, 'https://h/', headers => {'X-Amz-Content-Sha256' => 'UNSIGNED-PAYLOAD'});
like $r->{canonical_request}, qr/\nUNSIGNED-PAYLOAD\z/, 'other services too';
is $r->{headers}{'x-amz-content-sha256'}, 'UNSIGNED-PAYLOAD', 'and it is sent';
$r = req($s3, 'https://h/', headers => {'X-Amz-Content-Sha256' => $abc}, body => 'abc');
like $r->{canonical_request}, qr/\n$abc\z/, 'it can agree with the body';
is dies { canon($lambda, 'https://h/', body => 'abc',
headers => {'X-Amz-Content-Sha256' => 'UNSIGNED-PAYLOAD'}) },
bad(qr/does not match/), 'a header that disagrees with the body is refused';
is dies { canon($s3, 'https://h/', headers => {'X-Amz-Content-Sha256' => 'nope'}) },
bad(qr/x-amz-content-sha256/), 'an invalid header is refused';
};
subtest 'S3 under other names' => sub {
for my $name (qw< s3-object-lambda s3-outposts s3express >) {
my $t = AWS::Signature::V4->new(service => $name, region => 'r', credentials => {%cred});
my ($path, undef, $r) = canon($t, 'https://h/a%20b//c/../d');
is $path, '/a%20b//c/../d', "$name: path verbatim";
is $r->{headers}{'x-amz-content-sha256'}, sha256_hex(''), "$name: payload header";
like $t->presign(url => 'https://h/', time => 0)->{canonical_request},
qr/\nUNSIGNED-PAYLOAD\z/, "$name: presign with UNSIGNED-PAYLOAD";
}
my $t = AWS::Signature::V4->new(service => 's3x', region => 'r', credentials => {%cred});
is path_of($t, 'https://h/a/../b'), '/b', 'but not any name starting with s3';
};
subtest 'body as scalar reference' => sub {
my $body = 'some payload';
my $plain = req($std, 'https://h/', body => $body);
my $byref = req($std, 'https://h/', body => \$body);
is $byref->{signature}, $plain->{signature}, 'same signature by reference';
is $body, 'some payload', 'referenced body untouched';
my $big = 'x' x 10_000_000;
my $b1 = req($std, 'https://h/', body => \$big);
like $b1->{canonical_request}, qr/\n[0-9a-f]{64}\z/, 'large body by reference';
my $chars = "\x{263a}";
is dies { canon($std, 'https://h/', body => \$chars) }, bad(qr/byte string/),
'wide characters rejected through a reference too';
my $latin = "\xe9"; utf8::upgrade($latin);
my $l = req($std, 'https://h/', body => \$latin);
my $l2 = req($std, 'https://h/', body => "\xe9");
is $l->{signature}, $l2->{signature}, 'upgraded latin-1 string hashes as bytes';
ok utf8::is_utf8($latin), 'and the input string was not downgraded';
my $undef;
my $e1 = req($std, 'https://h/', body => \$undef);
my $e2 = req($std, 'https://h/');
is $e1->{signature}, $e2->{signature}, 'reference to undef is an empty body';
is dies { canon($std, 'https://h/', body => []) }, bad(qr/body/), 'array reference rejected';
is dies { canon($std, 'https://h/', body => sub {}) }, bad(qr/body/), 'code reference rejected';
};
subtest 'method, scope, token' => sub {
my $r = $std->sign(method => 'post', url => 'https://h/', time => 1440938160);
like $r->{canonical_request}, qr/\APOST\n/, 'method uppercased';
is $r->{scope}, '20150830/us-east-1/service/aws4_request', 'scope';
like $r->{authorization},
qr{^AWS4-HMAC-SHA256 Credential=AKID/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=[0-9a-f]{64}$},
'authorization header';
my $t = AWS::Signature::V4->new(service => 'service', region => 'r',
credentials => {%cred, session_token => 'TOK'});
$r = $t->sign(method => 'GET', url => 'https://h/', time => 0);
is $r->{headers}{'x-amz-security-token'}, 'TOK', 'session token header';
like $r->{signed_headers}, qr/x-amz-security-token/, 'is signed';
like $r->{headers}{'x-amz-date'}, qr/^19700101T000000Z$/, 'epoch 0';
};
subtest 'constructor errors' => sub {
is dies { AWS::Signature::V4->new(region => 'r', credentials => {%cred}) },
bad(qr/service/), 'no service';
is dies { AWS::Signature::V4->new(service => 's', credentials => {%cred}) },
bad(qr/region/), 'no region';
is dies { AWS::Signature::V4->new(service => 's', region => 'r') },
bad(qr/credentials/), 'no credentials';
is dies { AWS::Signature::V4->new(service => 's', region => 'r', credentials => {access_key_id => 'x'}) },
bad(qr/secret_access_key/), 'missing secret';
is dies { AWS::Signature::V4->new(service => 's', region => 'r', credentials => {%cred, token => 'T'}) },
bad(qr/unknown option "token" in credentials/), 'unknown credentials option';
is dies { AWS::Signature::V4->new(service => 's', region => "us-east-1\r\nX-Evil: 1",
credentials => {%cred}) }, bad(qr/region/), 'invalid region';
is dies { AWS::Signature::V4->new(service => 'a/b', region => 'r', credentials => {%cred}) },
bad(qr/service/), 'invalid service';
};
subtest 'arguments' => sub {
is $std->sign({method => 'GET', url => 'https://h/', time => 0})->{signature},
$std->sign(method => 'GET', url => 'https://h/', time => 0)->{signature},
'a hash reference is the same as a list of pairs';
is dies { $std->sign(method => 'GET', url => 'https://h/', 'time') },
bad(qr/pairs/), 'odd list';
is dies { canon($std, 'https://h/', content => 'x') }, bad(qr/unsupported for sign: "content"/),
'unknown parameter';
is dies { canon($std, 'https://h/', expires => 60) }, bad(qr/unsupported for sign: "expires"/),
'presign-only parameter';
for my $name (qw< body payload_hash unsigned_payload >) {
is dies { canon($s3, 'https://h/', streaming => 1, decoded_content_length => 1, $name => 'x') },
bad(qr/"$name" does not apply to streaming/), "$name with streaming";
}
is dies { canon($std, 'https://h/', decoded_content_length => 1) },
bad(qr/needs streaming/), 'streaming option without streaming';
my %bad = (
'headers as a string' => [headers => 'Host: h'],
'odd headers array' => [headers => ['X-A']],
'header value a hash' => [headers => {'X-A' => {}}],
'wide header value' => [headers => {'X-A' => "\x{263a}"}],
'signed_headers a string' => [signed_headers => 'host'],
'signed_headers empty str' => [signed_headers => ''],
'method not a token' => [method => "GET\n/other"],
'method with a byte > 127' => [method => "\xff"],
'empty method' => [method => ''],
'method a reference' => [method => {}],
'time in nanoseconds' => [time => 1440938160 * 1e9],
'time as a date' => [time => '2015-08-30T12:36:00Z'],
'negative time' => [time => -1],
'url a reference' => [url => []],
);
for my $name (sort keys %bad) {
is dies { $std->sign(method => 'GET', url => 'https://h/', $bad{$name}->@*) }, bad(), $name;
}
is req($std, 'https://h/', time => 1440938160.75)->{headers}{'x-amz-date'},
'20150830T123600Z', 'fractional time is fine';
};
subtest 'url path and query' => sub {
for my $url ('foo/bar', 'folder/file.txt', 'x') {
is dies { canon($std, $url, headers => {Host => 'h'}) }, bad(qr{start with "/"}),
"relative path $url refused";
}
is path_of($std, 'https://h?a=1'), '/', 'empty path is fine';
is req($std, '/a/b', headers => {Host => 'h'})->{headers}{host}, 'h', 'absolute path with Host';
is dies { canon($std, 'https://h/?prefix=a+b') }, bad(qr/\+/), 'plus sign in the query refused';
is query_of($std, 'https://h/?prefix=a%2Bb%20c'), 'prefix=a%2Bb%20c', 'encoded is fine';
is path_of($std, 'https://h/a+b'), '/a%252Bb', 'plus sign in the path is a plus sign';
for my $url ('https://h/public/%2e%2e/admin', 'https://h/a/%2E/b', 'https://h/a/.%2e/b') {
is dies { canon($std, $url) }, bad(qr/dot segment/), "encoded dot segment: $url";
}
is path_of($s3, 'https://h/public/%2e%2e/admin'), '/public/../admin',
'S3 does not normalize: fine there';
};
subtest 'values that the module puts in headers or in the query' => sub {
my $signer = sub (%creds) {
AWS::Signature::V4->new(service => 'service', region => 'r',
credentials => {%cred, %creds});
};
my $t = $signer->(session_token => "TOKEN\n");
is dies { $t->sign(method => 'GET', url => 'https://h/') },
bad(qr/x-amz-security-token/), 'session token with a newline';
my $k = $signer->(access_key_id => "AKID\r\nX-Evil: 1");
is dies { $k->sign(method => 'GET', url => 'https://h/') },
bad(qr/authorization/), 'access key id with CR/LF';
# wide characters would reach sha256_hex, or _uri_encode in presign, and
# die there instead of being reported as bad input
my $w = $signer->(session_token => "TOK\x{263a}EN");
is dies { $w->sign(method => 'GET', url => 'https://h/') },
bad(qr/x-amz-security-token.*byte string/), 'session token with a wide character';
is dies { $w->presign(url => 'https://h/') },
bad(qr/X-Amz-Security-Token.*byte string/), '... in a presigned url too';
my $wk = $signer->(access_key_id => "AKID\x{263a}");
is dies { $wk->sign(method => 'GET', url => 'https://h/') },
bad(qr/authorization.*byte string/), 'access key id with a wide character';
is dies { $wk->presign(url => 'https://h/') },
bad(qr/X-Amz-Credential.*byte string/), '... in a presigned url too';
};
done_testing;
( run in 0.818 second using v1.01-cache-2.11-cpan-85d3896f969 )