API-Docker
view release on metacpan or search on metacpan
t/role_http.t view on Meta::CPAN
# than taking the 1xx status and reading the real response as its body.
subtest '_read_response: a 1xx informational response is skipped' => sub {
my $raw = "HTTP/1.1 100 Continue\r\n\r\n"
. "HTTP/1.1 200 OK\r\n"
. "Content-Length: 2\r\n\r\n"
. "{}";
my $resp = $client->_read_response(string_handle($raw));
is $resp->[0], 200, 'the real status is returned, not the 100';
is $resp->[1], 'OK', 'and its reason';
is $resp->[3], '{}', 'and the real body, not the second response as bytes';
};
subtest '_read_response: several stacked 1xx heads are all skipped' => sub {
my $raw = "HTTP/1.1 100 Continue\r\n\r\n"
. "HTTP/1.1 103 Early Hints\r\nLink: </x>; rel=preload\r\n\r\n"
. "HTTP/1.1 204 No Content\r\n\r\n";
my $resp = $client->_read_response(string_handle($raw));
is $resp->[0], 204, 'the first non-1xx status wins';
};
subtest 'the streaming reader skips a 1xx before the stream too' => sub {
my @got;
my $handler = $client->_stream_handler('GET /v1.41/events', 'on_event',
sub { push @got, $_[0] }, 0);
my $raw = "HTTP/1.1 100 Continue\r\n\r\n"
. "HTTP/1.1 200 OK\r\n"
. "Transfer-Encoding: chunked\r\n\r\n"
. qq(11\r\n{"status":"one"}\n\r\n)
. "0\r\n\r\n";
my $res = $client->_read_streaming_response(
string_handle($raw), 'GET', $handler, {});
is $res->[0], 200, 'the stream reader also passes the 100 by';
is_deeply [ map { $_->{status} } @got ], ['one'],
'and the real event reaches the callback';
};
# ---------------------------------------------------------------------------
subtest '_read_chunked: hex sizes, upper and lower case' => sub {
# 'a' and 'A' are both 10 -- hex() is case-insensitive, and so must this be.
my $raw = "a\r\n0123456789\r\nA\r\nABCDEFGHIJ\r\n0\r\n\r\n";
my $fh = string_handle($raw);
is $client->_read_chunked($fh), '0123456789ABCDEFGHIJ',
'lowercase and uppercase hex chunk sizes both read correctly';
};
subtest '_read_chunked: a single zero-size chunk terminates immediately' => sub {
my $fh = string_handle("0\r\n\r\n");
is $client->_read_chunked($fh), '', 'empty body, no chunks';
};
subtest '_read_chunked: a chunk arriving in several reads' => sub {
my $data = "b\r\nhello world\r\n0\r\n\r\n"; # 'b' hex = 11 = length("hello world")
tie *FH, 'Test::RoleHTTP::PartialReader', $data, 3; # 3 bytes per read() call
my $body = $client->_read_chunked(\*FH);
is $body, 'hello world',
'chunk payload reassembled correctly across multiple short reads';
untie *FH;
};
# ---------------------------------------------------------------------------
subtest '_uri_encode: what it escapes and what it leaves alone' => sub {
# Called as a bare function everywhere in the module (see _request's
# query-string assembly) -- not as a method. Calling it as $client->
# _uri_encode(...) would silently shift $client into the $str slot, since
# the sub only unpacks a single positional argument.
my $encode = \&API::Docker::Role::HTTP::_uri_encode;
is $encode->('alpine:latest'), 'alpine:latest',
'colon is left raw -- image references keep their tag separator';
is $encode->('myrepo/app:v1'), 'myrepo/app:v1',
'slash is left raw too -- image references keep their path shape';
is $encode->('abcXYZ019-_.~'), 'abcXYZ019-_.~',
'unreserved characters (alnum - _ . ~) are never escaped';
is $encode->('a b'), 'a%20b', 'space is percent-encoded';
is $encode->('foo?bar=baz'), 'foo%3Fbar%3Dbaz',
'? and = are percent-encoded';
is $encode->('100%'), '100%25', 'a literal percent sign is escaped itself';
is $encode->("a\nb"), 'a%0Ab', 'control characters are escaped, not passed through';
# A character string -- what a name/tag/author/comment/search term arrives as
# under `use utf8` or through a :utf8 layer -- is escaped by its UTF-8 bytes,
# not by its codepoint. The old code took ord() of the character, so 'ü'
# became %FC (not even valid UTF-8) and 'ä¸' became %4E2D.
is $encode->("\x{4E2D}"), '%E4%B8%AD',
'a wide character is escaped by its UTF-8 bytes, not its codepoint';
{
my $u = "\x{00FC}";
utf8::upgrade($u); # what a decoded 'ü' is: codepoint 252, the utf8 flag on
is $encode->($u), '%C3%BC',
'a Latin-1 character with the utf8 flag is UTF-8 encoded before escaping';
}
# The other half, and the reason the encoding is not unconditional: a byte
# string is already octets and must be escaped as-is. encode_json hands a
# HASH param (filters among them) its UTF-8 bytes, and re-encoding those would
# turn %C3%BC into %C3%83%C2%BC -- trading this bug for a broader one.
is $encode->("\xC3\xBC"), '%C3%BC',
'a byte string of UTF-8 octets is escaped as-is, never double-encoded';
};
# ---------------------------------------------------------------------------
subtest '_request: assembles the request line, headers and body' => sub {
my $t = Test::API::Docker::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
subtest 'plain GET, no body' => sub {
$t->_request('GET', '/containers/json');
my $req = $t->written;
like $req, qr{\AGET /v1\.41/containers/json HTTP/1\.1\r\n},
'method, versioned path, and protocol on the request line';
like $req, qr{Host: localhost\r\n}, 'Host header sent';
like $req, qr{Connection: close\r\n}, 'Connection: close sent';
like $req, qr{User-Agent: API-Docker\r\n}, 'User-Agent sent';
unlike $req, qr{Content-Type}, 'no Content-Type without a body';
unlike $req, qr{Content-Length}, 'no Content-Length without a body';
like $req, qr{\r\n\r\n\z}, 'request ends on the blank line, empty body';
};
subtest 'POST with a JSON body' => sub {
$t->_request('POST', '/containers/create', body => { Image => 'alpine:3' });
my $req = $t->written;
my $encoded = encode_json({ Image => 'alpine:3' });
like $req, qr{\APOST /v1\.41/containers/create HTTP/1\.1\r\n},
'request line for the POST';
like $req, qr{Content-Type: application/json\r\n}, 'JSON content type';
like $req, qr{Content-Length: @{[ length $encoded ]}\r\n},
'content-length matches the encoded body';
like $req, qr{\r\n\r\n\Q$encoded\E\z}, 'body follows the blank line verbatim';
};
subtest 'raw_body + content_type (tarball upload)' => sub {
my $tar = "fake tar bytes\0\0\0";
$t->_request('POST', '/build', raw_body => $tar, content_type => 'application/x-tar');
my $req = $t->written;
like $req, qr{Content-Type: application/x-tar\r\n},
'content type overridden for a raw body, not left as application/json';
like $req, qr{Content-Length: @{[ length $tar ]}\r\n},
'content-length matches the raw body, not a JSON encoding of it';
like $req, qr{\r\n\r\n\Q$tar\E\z}, 'raw bytes appended verbatim';
};
subtest 'params: sorted, hashref values JSON-encoded, then URI-encoded' => sub {
$t->_request('GET', '/images/json',
params => { all => 1, filters => { dangling => ['true'] } });
my $req = $t->written;
my ($request_line) = $req =~ /\A(GET [^\r\n]+)\r\n/;
my $expected_filters = API::Docker::Role::HTTP::_uri_encode(
encode_json({ dangling => ['true'] }));
is $request_line,
"GET /v1.41/images/json?all=1&filters=$expected_filters HTTP/1.1",
'params sorted alphabetically by key; a hashref value is JSON-encoded '
. 'then URI-encoded, not encoded twice by hand';
};
subtest 'extra headers: sanitised and appended' => sub {
$t->_request('POST', '/images/x/push',
headers => { 'X-Registry-Auth' => 'e30=' });
my $req = $t->written;
like $req, qr{X-Registry-Auth: e30=\r\n}, 'extra header present';
};
};
# ---------------------------------------------------------------------------
subtest '_request: a CR/LF in a header value cannot inject a second header' => sub {
my $t = Test::API::Docker::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
$t->_request('POST', '/images/x/push',
headers => { 'X-Registry-Auth' => "e30=\r\nX-Injected: evil" });
my $req = $t->written;
unlike $req, qr{\r\nX-Injected:},
'no second header line -- "X-Injected" never starts its own line';
like $req, qr{X-Registry-Auth: e30=X-Injected: evil\r\n},
'the CRLF is stripped, not left as a line break -- the payload is '
. 'flattened onto the one header line it belongs to';
};
# ---------------------------------------------------------------------------
# karr k11: the value above is sanitised, but the *name* used to go on the
# wire untouched, so a caller-supplied key could open a header line of its
# own. Names are rejected rather than stripped -- see the reasoning in
# API::Docker::Role::HTTP under "Header names are rejected, header values are
# stripped".
subtest '_request: an invalid header name is refused, not rewritten' => sub {
my $t = Test::API::Docker::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
subtest 'CRLF in the name croaks and sends nothing' => sub {
eval {
$t->_request('POST', '/images/x/push',
headers => { "X-Registry-Auth\r\nX-Injected" => 'evil' });
};
like $@, qr/invalid header name/, 'croaked';
like $@, qr/\QX-Registry-Auth\x0D\x0AX-Injected\E/,
'the offending name is shown with its control bytes escaped, so the '
. 'message stays on one line and names what was actually passed';
my $message = "$@";
unlike $message, qr/\r/, 'the croak itself carries no raw CR';
$message =~ s/\n\z//;
unlike $message, qr/\n/,
'and no LF beyond the one Carp ends on -- the escaped name cannot open '
. 'a line of its own in whatever logs the failure';
is $t->_sink, undef,
'no socket was even opened -- the name is checked while the request is '
. 'assembled, so nothing reached the daemon';
};
subtest 'the separators that would corrupt the line, injection or not' => sub {
my %bad = (
'an embedded space' => 'X Registry Auth',
'a trailing colon' => 'X-Registry-Auth:',
'a bare LF' => "tail\n",
'a bare CR' => "tail\r",
'the empty string' => '',
'a non-ASCII byte' => "X-Caf\xE9",
);
for my $why (sort keys %bad) {
eval { $t->_request('GET', '/x', headers => { $bad{$why} => 'v' }) };
like $@, qr/invalid header name/, "$why is rejected";
}
};
subtest 'a name is refused even when its value would skip the header' => sub {
# An undef value means the header is not sent, but the name is still a
# caller bug and is still reported.
eval { $t->_request('GET', '/x', headers => { "bad\r\nname" => undef }) };
like $@, qr/invalid header name/,
'validated before the defined-check on the value';
};
subtest 'every character an RFC 9110 token allows still passes' => sub {
my $token = "Abc123-!#\$%&'*+.^_`|~";
$t->_request('GET', '/x', headers => { $token => 'ok' });
like $t->written, qr/\Q$token\E: ok\r\n/,
'the full token charset is accepted, not just the alphanumerics';
$t->_request('POST', '/images/x/push',
headers => { 'X-Registry-Auth' => 'e30=' });
like $t->written, qr/X-Registry-Auth: e30=\r\n/,
'and the one name this distribution actually sends is unaffected';
};
};
# ---------------------------------------------------------------------------
# karr k102: $path is caller data (a container name, an image reference)
# spliced straight into the request line, and unlike a header value it was
# never checked. Measured through the real _request against a fake socket:
# containers->inspect("x HTTP/1.1\r\nX-Evil: 1\r\n\r\nGET /y") put
# 'GET /v1.41/containers/x HTTP/1.1\r\nX-Evil: 1\r\n\r\n...' on the wire -- the
# name ended the request line and opened a header of its own. A path outside
# the request-target character set is now refused, not written; see
# API::Docker::Role::HTTP under "A request path is rejected, not sanitised".
subtest '_request: a path outside the request-target charset is refused' => sub {
my $t = Test::API::Docker::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
subtest 'the measured injection: CRLF in the path croaks and sends nothing' => sub {
my $evil = "x HTTP/1.1\r\nX-Evil: 1\r\n\r\nGET /y";
eval { $t->_request('GET', "/containers/$evil/json") };
like $@, qr/invalid request path/, 'croaked';
like $@, qr/\Qx HTTP\E.*\Q\x0D\x0AX-Evil\E/,
'the offending path is shown with its control bytes escaped, so the '
. 'message stays on one line and names what was actually passed';
my $message = "$@";
unlike $message, qr/\r/, 'the croak itself carries no raw CR';
$message =~ s/\n\z//;
unlike $message, qr/\n/,
'and no LF beyond the one Carp ends on -- the escaped path cannot open a '
. 'line of its own in whatever logs the failure';
is $t->_sink, undef,
'no socket was even opened -- the path is checked while the request is '
. 'assembled, so nothing reached the daemon';
};
subtest 'each separator that would rewrite the request target' => sub {
my %bad = (
'a space (opens the HTTP-version field)' => 'na me',
'a bare CR' => "tail\r",
'a bare LF' => "tail\n",
'a ? (opens the query string)' => 'na?me',
'a # (opens the fragment)' => 'na#me',
'a non-ASCII byte' => "caf\xE9",
'a NUL byte' => "na\x00me",
);
for my $why (sort keys %bad) {
my $t2 = Test::API::Docker::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
eval { $t2->_request('GET', "/containers/$bad{$why}/json") };
like $@, qr/invalid request path/, "$why is rejected";
is $t2->_sink, undef, "$why: nothing reached the wire";
}
};
subtest 'a legitimate image reference still assembles verbatim' => sub {
# ':' tag, '/' path and '@sha256:...' digest all survive -- the charset
# that closes the injection is the one image references live in, so the
# check must not be a false positive on any of them.
$t->_request('POST', '/images/library/nginx:1.25/push');
like $t->written,
qr{\APOST /v1\.41/images/library/nginx:1\.25/push HTTP/1\.1\r\n},
'a tagged, namespaced reference is written, not rejected';
my $digest = 'nginx@sha256:' . ('a' x 64);
$t->_request('GET', "/images/$digest/json");
like $t->written,
qr{\AGET /v1\.41/images/nginx\@sha256:@{[ 'a' x 64 ]}/json HTTP/1\.1\r\n},
'a digest reference (@ and :) is written verbatim too';
};
subtest 'measured end to end through the resource method' => sub {
# Exactly the ticket's measurement: the malicious name arrives through
# containers->inspect, which builds /containers/$id/json. The injection is
# closed at the transport, whatever resource method assembled the path.
my $t3 = Test::API::Docker::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
eval { $t3->containers->inspect("x HTTP/1.1\r\nX-Evil: 1\r\n\r\nGET /y") };
like $@, qr/invalid request path/, 'refused before it reaches the wire';
is $t3->_sink, undef, 'and no socket was opened';
};
};
# ---------------------------------------------------------------------------
subtest '_request: >= 400 croaks' => sub {
my $t = Test::API::Docker::FakeTransport->new(
( run in 0.934 second using v1.01-cache-2.11-cpan-54e63673c56 )