Content-Length нельзя доверять при чтении ответа от SslStream? - PullRequest
0 голосов
/ 28 апреля 2019

Игра с TcpClient и NetworkStream в .NET Core 2.2.
Попытка получить контент от https://www.google.com/

Прежде чем продолжить, я хотел бы прояснить, что я НЕ хочу использовать классы WebClient, HttpWebRequest или HttpClient. Есть много вопросов, где люди сталкивались с некоторыми проблемами при использовании TcpClient и где респонденты или комментаторы предлагали использовать что-то еще для этой задачи, поэтому, пожалуйста, не надо.

Допустим, у нас есть экземпляр SslStream, полученный из TcpClient's NetworkStream и должным образом аутентифицированный.

Допустим, у нас также есть один StreamWriter, который мы используем для записи HTTP-сообщений в этот поток, и один StreamReader, который мы используем для чтения заголовков HTTP-сообщений из ответа:

var tcpClient = new TcpClient("google.com", 443);
var stream = tcpClient.GetStream();
var sslStream = new SslStream(stream, false);
sslStream.AuthenticateAsClient("google.com");
var streamWriter = new StreamWriter(sslStream);
var streamReader = new StreamReader(sslStream);

Скажем, мы отправляем запрос так же, как отправил бы браузер Firefox:

GET / HTTP/1.1
Host: www.google.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: sr,sr-RS;q=0.8,sr-CS;q=0.6,en-US;q=0.4,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0

Что приводит к отправке следующего ответа:

HTTP/1.1 200 OK
Date: Sun, 28 Apr 2019 17:28:27 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=UTF-8
Strict-Transport-Security: max-age=31536000
P3P: CP="This is not a P3P policy! See g.co/p3phelp for more info."
Content-Encoding: br
Server: gws
Content-Length: 55786
... etc

Теперь, после чтения всех заголовков ответов с использованием streamReader.ReadLine() и анализа длины содержимого, найденного в заголовке ответа, давайте прочитаем содержимое ответа в буфер:

var totalBytesRead = 0;
int bytesRead;
var buffer = new byte[contentLength];
do
{
    bytesRead = sslStream.Read(buffer,
        totalBytesRead,
        contentLength - totalBytesRead);
    totalBytesRead += bytesRead;
} while (totalBytesRead < contentLength && bytesRead > 0);

Однако этот цикл do..while завершится только после того, как удаленный сервер завершит соединение, что означает, что последний вызов Read будет зависать. Это означает, что мы уже прочитали весь контент ответа, а сервер уже ожидает другое HTTP-сообщение в этом потоке. contentLength неверно? Не слишком ли много читается streamReader при вызове ReadLine, и поэтому он портит позицию SslStream, что приводит к чтению неверных данных?

Что дает? У кого-нибудь был опыт с этим?

P.S. Вот пример кода консольного приложения со всеми пропущенными проверками безопасности, который демонстрирует это:

private static void Main(string[] args)
{
    using (var tcpClient = new TcpClient("google.com", 443))
    {
        var stream = tcpClient.GetStream();
        using (var sslStream = new SslStream(stream, false))
        {
            sslStream.AuthenticateAsClient("google.com");
            using (var streamReader = new StreamReader(sslStream))
            using (var streamWriter = new StreamWriter(sslStream))
            {
                streamWriter.WriteLine("GET / HTTP/1.1");
                streamWriter.WriteLine("Host: www.google.com");
                streamWriter.WriteLine("User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0");
                streamWriter.WriteLine("Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
                streamWriter.WriteLine("Accept-Language: sr,sr-RS;q=0.8,sr-CS;q=0.6,en-US;q=0.4,en;q=0.2");
                streamWriter.WriteLine("Accept-Encoding: gzip, deflate, br");
                streamWriter.WriteLine("Connection: keep-alive");
                streamWriter.WriteLine("Upgrade-Insecure-Requests: 1");
                streamWriter.WriteLine("Cache-Control: max-age=0");
                streamWriter.WriteLine();
                streamWriter.Flush();

                var lines = new List<string>();
                var line = streamReader.ReadLine();
                var contentLength = 0;
                while (!string.IsNullOrWhiteSpace(line))
                {
                    var split = line.Split(": ");
                    if (split.First() == "Content-Length")
                    {
                        contentLength = int.Parse(split[1]);
                    }

                    lines.Add(line);
                    line = streamReader.ReadLine();
                }

                var totalBytesRead = 0;
                int bytesRead;
                var buffer = new byte[contentLength];
                do
                {
                    bytesRead = sslStream.Read(buffer,
                        totalBytesRead,
                        contentLength - totalBytesRead);
                    totalBytesRead += bytesRead;
                    Console.WriteLine(
                        $"Bytes read: {totalBytesRead} of {contentLength} (last chunk: {bytesRead} bytes)");
                } while (totalBytesRead < contentLength && bytesRead > 0);

                Console.WriteLine(
                    "--------------------");
            }
        }
    }

    Console.ReadLine();
}

EDIT

Это всегда происходит после того, как я задаю вопрос. Я почесал голову в течение нескольких дней, не имея возможности найти причину проблемы, но как только я отправил ее, я понял, что это как-то связано с StreamReader, когда он пытается что-то испортить линия.

Так что, если я перестану использовать StreamReader и заменю вызовы на ReadLine чем-то, что читает побайтово, все будет в порядке. Код замены можно записать следующим образом:

private static IEnumerable<string> ReadHeader(Stream sslStream)
{
    // One-byte buffer for reading bytes from the stream
    var buffer = new byte[1];

    // Initialize a four-character string to keep the last four bytes of the message
    var check = new StringBuilder("....");
    int bytes;
    var responseBuilder = new StringBuilder();
    do
    {
        // Read the next byte from the stream and write in into the buffer
        bytes = sslStream.Read(buffer, 0, 1);
        if (bytes == 0)
        {
            // If nothing was read, break the loop
            break;
        }

        // Add the received byte to the response builder.
        // We expect the header to be ASCII encoded so it's OK to just cast to char and append
        responseBuilder.Append((char) buffer[0]);

        // Always remove the first char from the string and append the latest received one
        check.Remove(0, 1);
        check.Append((char) buffer[0]);

        // \r\n\r\n marks the end of the message header, so break here
        if (check.ToString() == "\r\n\r\n")
        {
            break;
        }
    } while (bytes > 0);

    var headerText = responseBuilder.ToString();
    return headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
}

... что тогда сделало бы наше примерное консольное приложение похожим на это:

private static void Main(string[] args)
{
    using (var tcpClient = new TcpClient("google.com", 443))
    {
        var stream = tcpClient.GetStream();
        using (var sslStream = new SslStream(stream, false))
        {
            sslStream.AuthenticateAsClient("google.com");
            using (var streamWriter = new StreamWriter(sslStream))
            {
                streamWriter.WriteLine("GET / HTTP/1.1");
                streamWriter.WriteLine("Host: www.google.com");
                streamWriter.WriteLine("User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0");
                streamWriter.WriteLine("Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
                streamWriter.WriteLine("Accept-Language: sr,sr-RS;q=0.8,sr-CS;q=0.6,en-US;q=0.4,en;q=0.2");
                streamWriter.WriteLine("Accept-Encoding: gzip, deflate, br");
                streamWriter.WriteLine("Connection: keep-alive");
                streamWriter.WriteLine("Upgrade-Insecure-Requests: 1");
                streamWriter.WriteLine("Cache-Control: max-age=0");
                streamWriter.WriteLine();
                streamWriter.Flush();

                var lines = ReadHeader(sslStream);
                var contentLengthLine = lines.First(x => x.StartsWith("Content-Length"));
                var split = contentLengthLine.Split(": ");
                var contentLength = int.Parse(split[1]);

                var totalBytesRead = 0;
                int bytesRead;
                var buffer = new byte[contentLength];
                do
                {
                    bytesRead = sslStream.Read(buffer,
                        totalBytesRead,
                        contentLength - totalBytesRead);
                    totalBytesRead += bytesRead;
                    Console.WriteLine(
                        $"Bytes read: {totalBytesRead} of {contentLength} (last chunk: {bytesRead} bytes)");
                } while (totalBytesRead < contentLength && bytesRead > 0);

                Console.WriteLine(
                    "--------------------");
            }
        }
    }

    Console.ReadLine();
}

private static IEnumerable<string> ReadHeader(Stream sslStream)
{
    // One-byte buffer for reading bytes from the stream
    var buffer = new byte[1];

    // Initialize a four-character string to keep the last four bytes of the message
    var check = new StringBuilder("....");
    int bytes;
    var responseBuilder = new StringBuilder();
    do
    {
        // Read the next byte from the stream and write in into the buffer
        bytes = sslStream.Read(buffer, 0, 1);
        if (bytes == 0)
        {
            // If nothing was read, break the loop
            break;
        }

        // Add the received byte to the response builder.
        // We expect the header to be ASCII encoded so it's OK to just cast to char and append
        responseBuilder.Append((char)buffer[0]);

        // Always remove the first char from the string and append the latest received one
        check.Remove(0, 1);
        check.Append((char)buffer[0]);

        // \r\n\r\n marks the end of the message header, so break here
        if (check.ToString() == "\r\n\r\n")
        {
            break;
        }
    } while (bytes > 0);

    var headerText = responseBuilder.ToString();
    return headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
}

1 Ответ

0 голосов
/ 28 апреля 2019

Ответ на вопрос в заголовке: ДА .
Ему можно доверять, если вы правильно прочитали заголовок сообщения, то есть не используйте StreamReader.ReadLine.

Вот служебный метод, который выполняет свою работу:

private static string ReadStreamUntil(Stream stream, string boundary)
{
    // One-byte buffer for reading bytes from the stream
    var buffer = new byte[1];

    // Initialize a string builder with some placeholder chars of the length as the boundary
    var boundaryPlaceholder = string.Join(string.Empty, boundary.Select(x => "."));
    var check = new StringBuilder(boundaryPlaceholder);
    var responseBuilder = new StringBuilder();
    do
    {
        // Read the next byte from the stream and write in into the buffer
        var byteCount = stream.Read(buffer, 0, 1);
        if (byteCount == 0)
        {
            // If nothing was read, break the loop
            break;
        }

        // Add the received byte to the response builder.
        responseBuilder.Append((char)buffer[0]);

        // Always remove the first char from the string and append the latest received one
        check.Remove(0, 1);
        check.Append((char)buffer[0]);

        // boundary marks the end of the message, so break here
    } while (check.ToString() != boundary);

    return responseBuilder.ToString();
}

Затем, чтобы прочитать заголовок, мы можем просто позвонить ReadStreamUntil(sslStream, "\r\n\r\n").

Ключевым моментом здесь является чтение потока побайтно до тех пор, пока не встретится известная последовательность байтов (в данном случае \ r \ n \ r \ n).

После того, как он прочитан с использованием этого метода, поток будет в правильной позиции для содержимого ответа, которое будет прочитано правильно.

Если это хорошо, этот метод можно легко преобразовать в асинхронный вариант, вызвав await ReadAsync вместо Read.

Стоит отметить, что описанный выше метод работает нормально только в том случае, если текст в кодировке ASCII.

...