Python网络:HTTP
#网络开发人员 #python #http

到目前为止,我们已经看到了服务器和网络背后发生的事情。现在,现代网络当然不仅仅是回声服务器的网络。许多网站由称为HTTP(超文本传输​​协议)的东西提供动力。本文将使用各种Python代码和模块讨论HTTP的一些内部工作。对于那些寻求更多资源的人,我强烈建议您在任何与Web有关的内容上推荐Mozilla Developer Network documentation

安全说明

此处介绍的代码是出于学习目的。鉴于现代网络服务的复杂性,我高度劝阻试图在孤立的网络中学习自己的Web服务器。相反,您应该评估满足您需求的安全且维护良好的Web服务器。这里的流量也没有加密,这意味着任何人都可以窥探数据。因此总结:

  • 不要在生产中使用此代码
  • 始终确保您的网络通信是加密的,即加密方法没有过时/不安

HTTP版本

HTTP协议在一年中进行了许多修订。 1.0版于1996年发行为RFC 1945。随后在1999年由HTTP/1.1添加了许多在现代网络中广泛使用的功能。

目前HTTP/2被认为是现代标准。这里的许多功能都通过现代Web应用程序的工作方式来解决性能问题。 HTTP/3是基于UDP封装协议的当前过程中标准。特别是,它希望减少协商安全连接时的连接重复。

考虑到支持,本文将涵盖HTTP/1.1设置的标准。

一个URL

URL代表统一的资源定位器,是URI或统一资源标识符的子集。 URL的细节在RFC 1738中定义。尽管看起来URL似乎不一定是接触HTTP服务器,但它肯定是最受欢迎的用例之一。该计划部分允许其与其他服务(例如FTP和Gopher)一起使用。当时的URL支持方案可以在the RFC中找到。 The IANA保持更新和广泛的清单。 Python提供可用于与URL一起使用的urllib模块:

from urllib.parse import urlparse

URL = 'https://datatracker.ietf.org/doc/html/rfc1738#section-3'
print(urlparse(URL))

这给出了输出:

ParseResult(scheme='https', netloc='datatracker.ietf.org', path='/doc/html/rfc1738', params='', query='', fragment='section-3')

有一个更复杂的示例:

from urllib.parse import urlparse

URL = 'https://user:password@domain.com:7777/
parsed_url = urlparse(URL)
print(parsed_url.hostname)
print(parsed_url.username)
print(parsed_url.password)
print(parsed_url.port)

# Output:
# domain.com
# user
# password
# 7777

在某些情况下,URL路径可能具有非字母字符,例如空间特征。为了处理这种情况,可以编码该值。这是通过取ASCII hex value of the character(包括扩展的ASCII表)并在其前面添加%来完成。 urllib.parse.quote能够处理这样的编码:

from urllib.parse import quote

print(quote('/path with spaces'))

# Output:
# /path%20with%20spaces

基本请求

要查看请求过程,我将在端口80和requests library上使用基本套接字服务器,该服务器可以使用pip install requests
安装

import socket, os, pwd, grp
import socketserver

# https://stackoverflow.com/a/2699996
def drop_privileges(uid_name='nobody', gid_name='nogroup'):
    if os.getuid() != 0:
        # We're not root so, like, whatever dude
        return

    # Get the uid/gid from the name
    running_uid = pwd.getpwnam(uid_name).pw_uid
    running_gid = grp.getgrnam(gid_name).gr_gid

    # Remove group privileges
    os.setgroups([])

    # Try setting the new uid/gid
    os.setgid(running_gid)
    os.setuid(running_uid)

    # owner/group r+w+x
    old_umask = os.umask(0x007)

class MyTCPHandler(socketserver.StreamRequestHandler):
    """
    The request handler class for our server.

    It is instantiated once per connection to the server, and must
    override the handle() method to implement communication to the
    client.
    """

    def handle(self):
        self.data = self.rfile.readline()
        print(self.data)

if __name__ == "__main__":
    HOST, PORT = "localhost", 80

    # Create the server, binding to localhost on port 80
    with socketserver.TCPServer((HOST, PORT), MyTCPHandler) as server:
        # Activate the server; this will keep running until you
        # interrupt the program with Ctrl-C
        print(f'Server bound to port {PORT}')
        drop_privileges()
        server.serve_forever()

和客户:

import requests

requests.get('http://localhost/')

现在,目前这是一个目的不完整的请求,因此我可以按行显示事物。这意味着simple_client.py会出现对PEER的连接重置的错误,因为它期望HTTP响应。在服务器端,我们看到:

Server bound to port 80
b'GET / HTTP/1.1\r\n'

第一行由HTTP RFC表示为Request Line。第一个是方法,其次是对主机的请求,要使用的HTTP版本,最后是CRLF(Carriagar Rection'\ r'line feed'\ n')。因此,GET方法正在Path /上使用,并请求版本的http/1.1。

现在,您在此处注意到,我们不必声明我们正在使用端口80。这是因为它由IANA定义为服务端口,因此实现了use port 80 by default(或HTTPS的443)。<<<<<<<<<<<<<<<<<<< /p>

回复

接下来,我将在其余行中阅读HTTP请求:

    def handle(self):
        self.data = self.rfile.readlines()
        print(self.data)
Server bound to port 80
[b'GET / HTTP/1.1\r\n', b'Host: localhost\r\n', b'User-Agent: python-requests/2.22.0\r\n', b'Accept-Encoding: gzip, deflate\r\n', b'Accept: */*\r\n', b'Connection: keep-alive\r\n', b'\r\n']

现在要清理输出:

GET / HTTP/1.1

Host: localhost
User-Agent: python-requests/2.22.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

在请求行之后,我们看到许多密钥值对由:分开。这是我们不久将要进行的,但是现在我将传递数据以完成连接。当我在这里时,我也会重新组织处理程序课程,以使其更容易遵循:

class MyTCPHandler(socketserver.BaseRequestHandler):
    """
    The request handler class for our server.

    It is instantiated once per connection to the server, and must
    override the handle() method to implement communication to the
    client.
    """

    def read_http_request(self):
        print("reading request")
        self.data = self.request.recv(8192)
        print(self.data)

    def write_http_response(self):
        print("writing response")
        response_lines = [
            b'HTTP/1.1 200\r\n',
            b'Content-Type: text/plain\r\n',
            b'Content-Length: 12\r\n',
            b'Location: http://localhost/\r\n',
            b'\r\n',
            b'Hello World\n'
        ]
        for response_line in response_lines:
            self.request.send(response_line)
        print("response sent")

    def handle(self):
        self.read_http_request()
        self.write_http_response()
        self.request.close()
        print("connection closed")

,客户也经过了稍微修改:

import requests

r = requests.get('http://localhost/')
print(r.headers)
print(r.text)

因此,处理程序类已更改回socketserver.BaseRequestHandler,因为我不再需要单行读取。我现在还写了一个静态响应。最后,handle()方法给出了不同步骤的不错概述。现在为例:

服务器:

Server bound to port 80
reading request
b'GET / HTTP/1.1\r\nHost: localhost\r\nUser-Agent: python-requests/2.22.0\r\nAccept-Encoding: gzip, deflate\r\nAccept: */*\r\nConnection: keep-alive\r\n\r\n'
writing response
response sent
connection closed

客户端:

{'Content-Type': 'text/plain', 'Content-Length': '12', 'Location': 'http://localhost/'}
Hello World

与请求一样,响应也有自己的响应行,在这种情况下是:

HTTP/1.1 200\r\n

首先是HTTP版本,作为服务器可以在该版本中通信的确认。下一个是状态代码,以指示响应的性质。稍后,我将在文章中介绍一些状态代码。在这种情况下,200是确认请求有效,一切顺利。有了最初的响应,该是时候更深入地看待事物了。

更好的服务器

现在,我们已经看到了HTTP请求的原始元素,现在该抽取一些抽象了。 Python实际上具有一个http server module,可用于扩展socketserver模块。它具有各种组件,可以促进服务HTTP流量。因此,现在我们的服务器看起来像这样:

import os, pwd, grp
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler

# https://stackoverflow.com/a/2699996
def drop_privileges(uid_name='nobody', gid_name='nogroup'):
    if os.getuid() != 0:
        return
    running_uid = pwd.getpwnam(uid_name).pw_uid
    running_gid = grp.getgrnam(gid_name).gr_gid
    os.setgroups([])
    os.setgid(running_gid)
    os.setuid(running_uid)
    old_umask = os.umask(0x007)

class MyHTTPHandler(BaseHTTPRequestHandler):

    def read_http_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))

    def write_http_response(self):
        self.log_message(f"Writing response to {self.client_address}")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'Hello World\n')

    def do_GET(self):
        self.read_http_request()
        self.write_http_response()
        self.request.close()

if __name__ == "__main__":
    HOST, PORT = "localhost", 80

    with ThreadingHTTPServer((HOST, PORT), MyHTTPHandler) as server:
        print(f'Server bound to port {PORT}')
        drop_privileges()
        server.serve_forever()

现在,服务器正在做一些繁重的LISE。它具有有关请求行的信息,并为响应发送明智的默认标头。说到哪个让我们看头

标题

所以在进行了另一个新服务器之后:

{'Host': 'localhost', 'User-Agent': 'python-requests/2.22.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive'}

我们看到请求在请求中发送给我们。现在,标题的目的是,除了主机(由HTTP/1.1标准所要求的)外,其他服务器不是服务器必须真正关注的东西。这是因为单个IP可以托管多个域(实际上,这在CDN中变得越来越普遍)。因此,如果我添加了这样的/etc/hosts条目:

127.0.0.1 webserver

然后我可以进行以下更改:

    def write_http_response(self):
        self.log_message(f"Writing response to {self.client_address}")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(bytes(f'Hello {self.headers["Host"]}\n', 'utf-8'))

,例如:

{'Server': 'BaseHTTP/0.6 Python/3.10.6', 'Date': 'Tue, 25 Jul 2023 02:57:29 GMT'}
Hello localhost

{'Server': 'BaseHTTP/0.6 Python/3.10.6', 'Date': 'Tue, 25 Jul 2023 02:57:29 GMT'}
Hello webserver

尽管通过相同的IP地址进行连接,但我还是会收回不同的内容。现在,至于其余的标题,这是pretty long list。考虑到这一点,我将介绍一些基本的(以后的部分中,其他一些更具体的内容)。

类型标题

这些标题与要交付的内容类型有关。请求版本将要求某些类型的内容,并且响应版本将为内容提供元数据。接受是最重要的一种,与MIME (Multipurpose Internet Mail Extensions)指示的内容类型有关。这是指示文件类型的一种方法,最初是作为一种以通常的电子邮件形式提供有关非文本内容的信息的一种方式。这有助于区分解析HTML并显示图像。毫不奇怪,IANA管理着Mime类型的official list。 Python有一个mimetypes module,该mimetypes module将文件扩展名映射到系统的MIME类型数据库:

import mimetypes

mimetypes.init()
print(mimetypes.guess_all_extensions('text/plain'))
print(mimetypes.types_map['.html'])

# Output
# ['.txt', '.bat', '.c', '.h', '.ksh', '.pl', '.csh', '.jsx', '.sln', '.slnf', '.srf', '.ts']
# text/html

现在,这当然是假设具有一定扩展名的文件实际上是该类型的文件。实际上,尽管恶意演员可以简单地将其恶意软件重命名为.jpg或类似,因此,如果您不能完全相信用户的意图,那不是一个很好的真实来源。相反,我们可以使用python-magic。因此,在执行pip install python-magic之后(Windows用户将需要安装python-magic-bin,其中包括所需的DLL):

import magic

f = magic.Magic(mime=True)
print(f.from_file('/bin/bash'))

# Output application/x-sharedlib

您可能会处理的一些内容类型:

  • text/plain:纯文本
  • kauda13:helsl < / limhat + / li>
  • application/json:json

Mozilla还具有更多的extensive list。现在查看我们看到的请求标题:

Accept: */*
Accept-Encoding: gzip, deflate

对于基本交易,如果客户端不确定服务器会响应什么,则Accept: */*是相当标准的。从本质上讲,这对MIME类型服务器将返回。一个更复杂的例子是:

Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8

这将接受大多数HTML格式。这里还有一个;q=[number],指示MIME类型的偏好。如果没有偏好,则所有内容都相同,并且将选择最特定的类型。它的服务器版本是Content-Type,它指示服务器将发送的内容的类型。现在,如果您决定将类型弄糊涂到不是:

    def write_http_response(self):
        self.log_message(f"Writing response to {self.client_address}")
        self.send_response(200)
        self.send_header('Content-Type', 'image/jpeg')
        self.end_headers()
        self.wfile.write(bytes(f'Hello {self.headers["Host"]}\n', 'utf-8'))

基于请求的客户端(或者实际上是诸如curl and wget之类的仅下载)不在乎,因为它不会呈现图像。另一方面,实际浏览器会丢失错误或显示占位符破碎的图像。

Accept-Encoding表示客户端支持返回的压缩数据。建议在可能的情况下通过服务器规格进行压缩,以减少传输的数据量。由于使用此指标来定价并不少见,因此也可以帮助降低成本。服务器可以发送回Content-Encoding以指示其发送压缩数据:

    def write_http_response(self):
        self.log_message(f"Writing response to {self.client_address}")
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.send_header('Content-Encoding', 'gzip')
        self.end_headers()
        return_data = gzip.compress(bytes(f'Hello {self.headers["Host"]}\n', encoding='utf-8'))
        self.wfile.write(return_data)

请求能够从包装盒中处理压缩数据,因此不需要更改,并且运行表明压缩有效:

{'Server': 'BaseHTTP/0.6 Python/3.10.6', 'Date': 'Wed, 26 Jul 2023 23:27:53 GMT', 'Content-Type': 'text/plain', 'Content-Encoding': 'gzip'}
Hello localhost

增强服务器更多

现在要查看其他一些标头选项,我将更新HTTP服务器:

import datetime
import grp
import gzip
import hashlib
import os
import pwd
from email.utils import parsedate_to_datetime
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler

# https://stackoverflow.com/a/2699996
def drop_privileges(uid_name='nobody', gid_name='nogroup'):
    if os.getuid() != 0:
        return
    running_uid = pwd.getpwnam(uid_name).pw_uid
    running_gid = grp.getgrnam(gid_name).gr_gid
    os.setgroups([])
    os.setgid(running_gid)
    os.setuid(running_uid)
    old_umask = os.umask(0x007)

class MyHTTPHandler(BaseHTTPRequestHandler):

    ROUTES = {
        '/': 'serve_front_page',
        '/index.html': 'serve_html', 
        '/python-logo/': 'serve_python_logo',
        '/js/myjs.js': 'serve_js',
        '/favicon.ico': 'serve_favicon'
    }
    HTTP_DT_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'

    def read_http_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))

    def serve_front_page(self):
        self.log_message(f"Writing response to {self.client_address}")
        self.send_response(307)
        self.send_header('Location', '/index.html')
        return b''

    def serve_python_logo(self):
        return self.serve_file_with_caching('python-logo-only.png', 'image/png')

    def serve_favicon(self):
        return self.serve_file_with_caching('favicon.ico', 'image/x-icon')

    def serve_html(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/html')
        return b'<html><head><title>Old Website</title><script type="text/javascript" src="/js/myjs.js"></script></head><body><img src="/python-logo/" /></body></html>'

    def serve_js(self):
        js_code = b'const a = Math.random();'
        etag = hashlib.md5(js_code).hexdigest()
        if 'If-None-Match' in self.headers and self.headers['If-None-Match'] == etag:
            self.send_response(304)
            return b''
        else:
            self.send_response(200)
            self.send_header('Etag', etag)
            self.send_header('Content-Type', 'text/javascript')
            self.send_header('Cache-Control', 'public, max-age=10')
            return js_code

    def write_data(self, bytes_data):
        self.send_header('Content-Encoding', 'gzip')
        return_data = gzip.compress(bytes_data)
        self.send_header('Content-Length', len(return_data))
        self.end_headers()
        self.wfile.write(return_data)

    def check_cache(self, filename):
        if 'If-Modified-Since' in self.headers:
            cache_date = parsedate_to_datetime(self.headers['If-Modified-Since'])
            filename_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename), tz=datetime.timezone.utc).replace(microsecond=0)
            return filename_date <= cache_date
        return False

    def serve_file_with_caching(self, filename, file_type):
        self.log_message(f"Writing response to {self.client_address}")
        if self.check_cache(filename):
            self.send_response(304)
            return b''
        else:
            self.send_response(200)
            self.send_header('Content-Type', file_type)
            file_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename), tz=datetime.timezone.utc).replace(microsecond=0)
            self.send_header('Last-Modified', file_date.strftime(self.HTTP_DT_FORMAT))
            self.send_header('Cache-Control', 'public, max-age=10')
            self.send_header('Expires', (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(0, 10)).strftime(self.HTTP_DT_FORMAT) )
            with open(filename, 'rb') as file_fp:
                file_data = file_fp.read()
            return file_data

    def do_GET(self):
        self.read_http_request()
        bytes_data = self.__getattribute__(self.ROUTES[self.path])()
        self.write_data(bytes_data)
        self.request.close()

if __name__ == "__main__":
    HOST, PORT = "localhost", 80

    with ThreadingHTTPServer((HOST, PORT), MyHTTPHandler) as server:
        print(f'Server bound to port {PORT}')
        drop_privileges()
        server.serve_forever()

这将需要与服务器同一目录中的两个文件:

ROUTES允许处理程序充当处理程序类中方法的简单路由器映射路径。还将数据写入方法抽象为GZIP每次都会压缩数据。另一种方法涉及缓存逻辑。我将介绍有关标头逻辑的零件。

重定向

可以使用一些状态代码使用“位置”标头,以指示需要重定向到的文件的位置。在这里看:

    def serve_front_page(self):
        self.log_message(f"Writing response to {self.client_address}")
        self.send_response(307)
        self.send_header('Location', '/index.html')
        return b''

这将将用户重定向到/index.html页面。请注意,一些基于CLI的HTTP客户端将需要一个其他选择来实际处理重定向。另一方面,Web浏览器将无缝处理。

缓存

第一个有用的缓存标头是Cache-Control。主要用途是指出文件可以在客户端的本地缓存中停留多长时间。然后将其补充为Last-Modified和/或Etag。所以这里:

            self.send_header('Cache-Control', 'public, max-age=10')

我们告诉客户可以在本地缓存,而无需重新验证10秒钟。对于Last-Modified,我将其设置为UTC中文件的修改时间:

            file_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename), tz=datetime.timezone.utc).replace(microsecond=0)
            self.send_header('Last-Modified', file_date.strftime(self.HTTP_DT_FORMAT))

替换微秒部分是由于微秒的粒度引起的,引起了比较问题。自时代以来,getmtime将获得文件的修改时间。将tz=设置为UTC,使ITZONE将其视为UTC日期/时间。现在,对于标准的Web浏览器,当客户端将文件当地缓存超过10秒时,它将使用If-Modified-Since查询服务器:

    def check_cache(self, filename):
        if 'If-Modified-Since' in self.headers:
            cache_date = parsedate_to_datetime(self.headers['If-Modified-Since'])
            filename_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename), tz=datetime.timezone.utc).replace(microsecond=0)
            return filename_date <= cache_date
        return False

现在,服务器将检查提供的值并将其与文件的修改时间进行比较。如果它大于If-Modified-Since,那么服务器将像往常一样返回文件,并带有新的Last-Modified值:

 if self.check_cache(filename):
            self.send_response(304)
            return b''
        else:
            self.send_response(200)

否则服务器发送304以表示文件没有更改。 Cache-Controlmax-age计时器将重置为0,并且周期继续。现在的问题在于,内容是动态生成的。在这种情况下,可以使用Etag。此值没有确切的生成方法。作为MDN web docs states:“通常,ETAG值是内容的哈希,最后一个修改时间戳的哈希或仅修订编号”:

        js_code = b'const a = Math.random();'
        etag = hashlib.md5(js_code).hexdigest()

在这种情况下,我使用MD5哈希。当客户要求时,这将发送给客户。然后,客户端将将此ETAG值附加到缓存条目。当max-age起来时,使用If-Modified-Since而不是发送标题If-None-Match

        if 'If-None-Match' in self.headers and self.headers['If-None-Match'] == etag:
            self.send_response(304)
            return b''
        else:

请注意,使用Firefox,他们实施了一个称为Race Cache With Network (RCWN)的功能。 Firefox将计算网络是否比从磁盘中提取该网络更快。如果网络更快,则无论如何设置如何,它都会拉出内容。如果您正在执行Localhost - > Local -Host连接或在非常高速的网络上,这很可能很可能是PROC。当前没有服务器端的方法可以禁用此操作,必须通过浏览器端进行。

用户代理

这是一个相当有趣的标题,是假定,以指示客户当前要求的内容。例如,铬可能显示:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36

问题是非常容易欺骗,因为归根结底,这只是一个普通的标题。例如:

import requests

r = requests.get('http://localhost/js/myjs.js', headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'})
print(r.headers)
print(r.text)

查看服务器端请求日志:

127.0.0.1 - - [27/Jul/2023] Reading request from ('127.0.0.1', 44110)
{'Host': 'localhost', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive'}

因此,即使我实际使用的请求,我也可以使用早期的用户代理字符串来欺骗自己。如果您做了很多JS开发,这就是为什么浏览器检测的最佳实践是通过功能而不是用户字符串的原因。

饼干

安全说明:由于这是用于学习目的加密的HTTP服务器。在现实世界中,应始终通过强大的加密发送cookie,以帮助防止会议劫持。

虽然cookie是技术上的另一个标题,但我发现它们的功能足以保证专用部分。 cookie本质上是一种能够在HTTP调用之间具有状态的方法。在一般操作中,每个HTTP调用与另一个呼叫不同。如果没有Cookie来弥合此差距,就很难确保诸如用户被认证为服务的状态。 cookies从服务器发送一个或多个Set-Cookie标头开始。所以我在这里添加另一条路线:

from http.cookies import SimpleCookie
# <snip>
    ROUTES = {
        '/': 'serve_front_page',
        '/index.html': 'serve_html', 
        '/python-logo/': 'serve_python_logo',
        '/js/myjs.js': 'serve_js',
        '/favicon.ico': 'serve_favicon',
        '/cookie-test/': 'serve_cookies'
    }
# <snip>
    def serve_cookies(self):
        self.send_response(200)
        cookies_list = SimpleCookie()
        cookies_list['var1'] = 'test'
        cookies_list['var2'] = 'test2'
        cookies_list['var2']['path'] = '/'
        for morsel in cookies_list.values():
            self.send_header("Set-Cookie", morsel.OutputString())
        return self.serve_html()

将使用SimpleCookie类来设置我们的标题。请求将这些cookie放入自己的专用财产中:

import gzip
import requests

r = requests.get('http://localhost/cookie-test/')
print(r.headers)
print(dict(r.cookies))
print(gzip.decompress(r.content))

# Output:
# {'Server': 'BaseHTTP/0.6 Python/3.10.6', 'Date': 'Thu, 27 Jul 2023 23:30:07 GMT', 'Set-Cookie': 'var1=test, var2=test2; Path=/'}
# {'var2': 'test2', 'var1': 'test'}
# b'<html><head><title>Old Website</title><script type="text/javascript" src="/js/myjs.js"></script></head><body><img src="/python-logo/" /></body></html>'

现在添加更多路线并调整cookie逻辑:

    ROUTES = {
        '/': 'serve_front_page',
        '/index.html': 'serve_html', 
        '/python-logo/': 'serve_python_logo',
        '/js/myjs.js': 'serve_js',
        '/favicon.ico': 'serve_favicon',
        '/cookie-test/': 'serve_cookies',
        '/cookie-test2/': 'serve_cookies'
    }
# <snip>
    def serve_cookies(self):
        self.send_response(200)
        cookies_list = SimpleCookie()
        cookies_list['var1'] = 'test'
        cookies_list['path_specific'] = 'test2'
        cookies_list['path_specific']['path'] = '/cookie-test/'
        cookies_list['shady_cookie'] = 'test3'
        cookies_list['shady_cookie']['domain'] = 'shadysite.com'
        for morsel in cookies_list.values():
            self.send_header("Set-Cookie", morsel.OutputString())
        return self.serve_html()

只要我在/cookie-test/,就会在浏览器中访问一次,并且它的子路径将出现path_specific cookie。但是,如果我浏览/cookie-test2/,则不会因为路径不匹配。如果我们还看一下shady_cookie

Chrome marking shady cookie as suspicious

Chrome拒绝注册Cookie,因为它与主机不同。这通常被称为第三方饼干。尽管有一些方法可以处理适当的用法,但通常可以期望大多数浏览器会拒绝第三方饼干。这主要是因为第三方饼干通常包含广告/跟踪相关的内容。现在,在发送cookie的浏览器时,我们将使用逻辑来弄清楚哪些cookie对请求有效,并将其发送回Cookie标头。然后,服务器可以将其用于保持某种状态:

    def parse_cookies(self):
        if 'Cookie' in self.headers:
            raw_cookies = self.headers['Cookie']
            self.cookies = SimpleCookie()
            self.cookies.load(raw_cookies)
        else:
            self.cookies = None

    def get_cookie(self, key, default=None):
        if not self.cookies:
            return default
        elif key not in self.cookies:
            return default
        else:
            return self.cookies[key].value

    def serve_html(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/html')
        title_cookie = self.get_cookie('path_specific', 'Old Website')
        return bytes(f'<html><head><title>{title_cookie}</title><script type="text/javascript" src="/js/myjs.js"></script></head><body><img src="/python-logo/" /></body></html>', encoding='utf-8')

因此,在这里,我们修改了serve_html以使用cookie值作为标题。如果不存在,我们将使用“旧网站”值。 SimpleCookie是解析饼干的一种方式,让我们重用解析饼干。

安全说明:将cookie值直接插入HTML是一个可怕的想法。这仅是出于简单的说明目的。

现在在客户端:

import gzip
import requests

r = requests.get('http://localhost/cookie-test/')
print(r.headers)
print(dict(r.cookies))
print(gzip.decompress(r.content))

r2 = requests.get('http://localhost/cookie-test/', cookies=r.cookies)
print(r2.headers)
print(dict(r2.cookies))
print(gzip.decompress(r2.content))

将输出:

{'Server': 'BaseHTTP/0.6 Python/3.10.6', 'Date': 'Fri, 28 Jul 2023 02:46:15 GMT', 'Set-Cookie': 'var1=test, path_specific=test2; Path=/cookie-test/, shady_cookie=test3; Domain=shadysite.com'}
{'var1': 'test', 'path_specific': 'test2'}
b'<html><head><title>Old Website</title><script type="text/javascript" src="/js/myjs.js"></script></head><body><img src="/python-logo/" /></body></html>'
{'Server': 'BaseHTTP/0.6 Python/3.10.6', 'Date': 'Fri, 28 Jul 2023 02:46:15 GMT', 'Set-Cookie': 'var1=test, path_specific=test2; Path=/cookie-test/, shady_cookie=test3; Domain=shadysite.com'}
{'var1': 'test', 'path_specific': 'test2'}
b'<html><head><title>test2</title><script type="text/javascript" src="/js/myjs.js"></script></head><body><img src="/python-logo/" /></body></html>'

我还要注意,即使请求删除了第三方Shady网站cookie:

127.0.0.1 - - [27/Jul/2023] Reading request from ('127.0.0.1', 50316)
{'Host': 'localhost', 'User-Agent': 'python-requests/2.22.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Cookie': 'path_specific=test2; var1=test'}

cookies可以通过过去到期,或在将来设置有效期并在此之前播放到有效期。这是将要删除的cookie的示例:

import time
HTTP_DT_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'
INSTANT_EXPIRE = time.strftime(HTTP_DT_FORMAT, time.gmtime(0))
cookies_list['var1'] = 'test'
cookies_list['var1']['expires'] = self.INSTANT_EXPIRE

这将设置到Epoch或Thu, 01 Jan 1970 00:00:00 GMT的开始。要注意的一件重要的事情是,当实施处理要规格的事物时,情况就是这种情况。不管怎样,流氓客户可以选择发送过期的cookie。

请求类型

由于我们不使用标题,我会再次将服务器代码带回简化的表单:

import datetime
import grp
import gzip
import hashlib
import os
import pwd
from email.utils import parsedate_to_datetime
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler

# https://stackoverflow.com/a/2699996
def drop_privileges(uid_name='nobody', gid_name='nogroup'):
    if os.getuid() != 0:
        return
    running_uid = pwd.getpwnam(uid_name).pw_uid
    running_gid = grp.getgrnam(gid_name).gr_gid
    os.setgroups([])
    os.setgid(running_gid)
    os.setuid(running_uid)
    old_umask = os.umask(0x007)

class MyHTTPHandler(BaseHTTPRequestHandler):

    ROUTES = {
        '/': 'serve_front_page',
        '/index.html': 'serve_html', 
        '/python-logo/': 'serve_python_logo',
        '/js/myjs.js': 'serve_js',
        '/favicon.ico': 'serve_favicon',
    }
    HTTP_DT_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'

    def read_http_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))

    def serve_front_page(self):
        self.log_message(f"Writing response to {self.client_address}")
        self.send_response(307)
        self.send_header('Location', '/index.html')
        return b''

    def serve_python_logo(self):
        return self.serve_file_with_caching('python-logo-only.png', 'image/png')

    def serve_favicon(self):
        return self.serve_file_with_caching('favicon.ico', 'image/x-icon')

    def serve_html(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/html')
        return bytes(f'<html><head><title>Old Website</title><script type="text/javascript" src="/js/myjs.js"></script></head><body><img src="/python-logo/" /></body></html>', encoding='utf-8')

    def serve_js(self):
        js_code = b'const a = Math.random();'
        etag = hashlib.md5(js_code).hexdigest()
        if 'If-None-Match' in self.headers and self.headers['If-None-Match'] == etag:
            self.send_response(304)
            return b''
        else:
            self.send_response(200)
            self.send_header('Etag', etag)
            self.send_header('Content-Type', 'text/javascript')
            self.send_header('Cache-Control', 'public, max-age=10')
            return js_code

    def write_data(self, bytes_data):
        self.send_header('Content-Encoding', 'gzip')
        return_data = gzip.compress(bytes_data)
        self.send_header('Content-Length', len(return_data))
        self.end_headers()
        self.wfile.write(return_data)

    def check_cache(self, filename):
        if 'If-Modified-Since' in self.headers:
            cache_date = parsedate_to_datetime(self.headers['If-Modified-Since'])
            filename_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename), tz=datetime.timezone.utc).replace(microsecond=0)
            return filename_date <= cache_date
        return False

    def serve_file_with_caching(self, filename, file_type):
        self.log_message(f"Writing response to {self.client_address}")
        if self.check_cache(filename):
            self.send_response(304)
            return b''
        else:
            self.send_response(200)
            self.send_header('Content-Type', file_type)
            file_date = datetime.datetime.fromtimestamp(os.path.getmtime(filename), tz=datetime.timezone.utc).replace(microsecond=0)
            self.send_header('Last-Modified', file_date.strftime(self.HTTP_DT_FORMAT))
            self.send_header('Cache-Control', 'public, max-age=10')
            self.send_header('Expires', (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(0, 10)).strftime(self.HTTP_DT_FORMAT) )
            with open(filename, 'rb') as file_fp:
                file_data = file_fp.read()
            return file_data

    def do_GET(self):
        self.read_http_request()
        bytes_data = self.__getattribute__(self.ROUTES[self.path])()
        self.write_data(bytes_data)
        self.request.close()

if __name__ == "__main__":
    HOST, PORT = "localhost", 80

    with ThreadingHTTPServer((HOST, PORT), MyHTTPHandler) as server:
        print(f'Server bound to port {PORT}')
        drop_privileges()
        server.serve_forever()

在HTTP标准中,可以使用various request methods。我将要考虑三个核心。如果您正在开发REST API,则可能会使用更多的API。

得到

这是您将在大多数Web交互中看到的标准方法。它表示仅读取操作以获取某种形式的内容,并且不应更改服务器使用的内容。由于读取的性质,因此忽略了请求主体内容的性质。为了传递任何类型的参数,可以在路径之后使用查询字符串。当HTTP服务器作为路径的一部分中拉动查询字符串时,我们需要在使用路由词典之前对其进行解析:

from urllib.parse import urlparse, parse_qs
# <snip>
    ROUTES = {
        '/': 'serve_front_page',
        '/index.html': 'serve_html', 
        '/python-logo/': 'serve_python_logo',
        '/js/myjs.js': 'serve_js',
        '/favicon.ico': 'serve_favicon',
        '/query_test': 'serve_html'
    }
# <snip>
    def do_GET(self):
        self.read_http_request()

        segments = urlparse(self.path)
        self.query = parse_qs(segments.query)
        self.log_message(self.query)
        bytes_data = self.__getattri

        self.write_data(bytes_data)
        self.request.close()

urlparse允许我们分解路径和查询字符串组件。然后,parse_qs将解析查询字符串,以给我们一个字典的值。请注意,这两个示例都是有效的:

# Handled by the code
http://website/query-test?test1=test2&test3=test4
# Valid, but not handled by our code
http://website/query-test/?test1=test2&test3=test4

,但我只是故意处理第一种情况以使事情变得简单(功能丰富的Web服务器可以解决此问题)。我们将更新我们的客户端以传递某些参数并查看结果:

import requests

r = requests.get('http://localhost/query_test?test1=foo&test2=bar&test3=hello%20world')
print(r.headers)
print(r.content)

将提供服务器的以下输出:

127.0.0.1 - - [29/Jul/2023] {'test1': ['foo'], 'test2': ['bar'], 'test3': ['hello world']}

现在,值为列表的原因是因为通过在查询字符串中使用相同的键,您可以允许多个值:

r = requests.get('http://localhost/query_test?test1=foo&test2=bar&test3=hello%20world&test2=baz&test2=nothing')
# 127.0.0.1 - - [29/Jul/2023] {'test1': ['foo'], 'test2': ['bar', 'baz', 'nothing'], 'test3': ['hello world']}

如果您只想用唯一键支持单个值,则可以使用parse_qsl

        segments = urlparse(self.path)
        # Returns key value pair tuple
        self.query = dict(parse_qsl(segments.query))
        self.log_message(f'{self.query}')
        bytes_data = self.__getattribute__(self.ROUTES[segments.path])()
r = requests.get('http://localhost/query_test?test1=foo&test2=bar&test3=hello%20world')
# 127.0.0.1 - - [29/Jul/2023] {'test1': 'foo', 'test2': 'bar', 'test3': 'hello world'}
r = requests.get('http://localhost/query_test?test1=foo&test2=bar&test3=hello%20world&test2=baz&test2=nothing')
# 127.0.0.1 - - [29/Jul/2023] {'test1': 'foo', 'test2': 'nothing', 'test3': 'hello world'}

您可以看到多个值版本仍然有效,但仅列入最后一个定义的值。同样,使用功能丰富的Web服务器进行实际使用的另一个充分理由。

这与Get Quest基本相同,除了仅返回标题。这对于弄清楚文件是否存在而无需下载整个内容,这很有用。也就是说,即使响应主体为空白,标题仍然必须与下载文件完全相同。服务器端这对静态文件来说还不错。不得不动态生成大量数据以推回空体是不理想的。在您的方法逻辑中要考虑的东西。使用基本的HTTP服务器get_HEAD将需要使用逻辑添加,并且write_data方法将需要另一个版本才能正确处理标头(我将忽略查询字符串在此处进行简单性):

    def write_head_data(self, bytes_data):
        self.send_header('Content-Encoding', 'gzip')
        return_data = gzip.compress(bytes_data)
        self.send_header('Content-Length', len(return_data))
        self.end_headers()
        self.wfile.write(b'')

    def do_HEAD(self):
        self.read_http_request()
        bytes_data = self.__getattribute__(self.ROUTES[self.path])()
        self.write_head_data(bytes_data)
        self.request.close()

现在requests需要致电head()而不是get()

import requests

r = requests.head('http://localhost/index.html')
print(r.headers)
print(r.content)
# {'Server': 'BaseHTTP/0.6 Python/3.10.6', 'Date': 'Sat, 29 Jul 2023 18:32:14 GMT', 'Content-Type': 'text/html', 'Content-Encoding': 'gzip', 'Content-Length': '129'}
b''
# Server Log: 127.0.0.1 - - [29/Jul/2023] "HEAD /index.html HTTP/1.1" 200 -

因此,Content-Length正确地显示了将来自压缩HTML的字节数,但身体响应是空的。

邮政

帖子是针对在服务器端更改数据的情况。重要的是要注意,即使存在HTML表格,也不能保证结果是发布。搜索功能可能具有用于搜索参数的表格,结果是带有包含参数的查询字符串的get查询。由于帖子使您可以在URL中的身体查询字符串中声明数据几乎没有实际使用,因此应避免使用。第一种帖子是人体中编码为application/x-www-form-urlencoded的键/值帖子。首先,我们只需打印出标头和身体即可查看它的外观:

    def read_post_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))
        content_length = int(self.headers['Content-Length'])
        data = self.rfile.read(content_length)
        print(data)

    def serve_post_response(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/html')
        return bytes(f'<html><head><title>Old Website</title><script type="text/javascript" src="/js/myjs.js"></script></head><body><img src="/python-logo/" /></body></html>', encoding='utf-8')

    def do_POST(self):
        self.read_post_request()
        bytes_data = self.serve_post_response()
        self.write_data(bytes_data)
        self.request.close()

和客户:

import requests

r = requests.post('http://localhost/', data={'var1': 'test', 'var2': 'test2'})
print(r.headers)
print(r.content)

运行客户端后,我们在服务器端看到了这一点:

127.0.0.1 - - [29/Jul/2023] Reading request from ('127.0.0.1', 35888)
{'Host': 'localhost', 'User-Agent': 'python-requests/2.22.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Length': '20', 'Content-Type': 'application/x-www-form-urlencoded'}
b'var1=test&var2=test2'

由于客户端发送了信息,因此发送了Content-TypeContent-Length标头。现在可以使用parse_qsl在服务器端解析:

    def read_post_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))
        content_length = int(self.headers['Content-Length'])
        data = self.rfile.read(content_length)
        self.data = dict(parse_qsl(data.decode('utf-8')))
        print(self.data)
# Output: {'var1': 'test', 'var2': 'test2'}

当数据从连接中读取时,它随着字节而变为字节,可以使用decode()将其变成字符串。 Content-Length也是一个有趣的困境安全。如果您尝试使用read()在插座上进行read()时,则在客户端发送的范围内,服务器可以进入卡住阶段。这是由于预期可能会有更多的数据包到达,并且网络简直很慢。恶意攻击者可以简单地将Content-Length设置为比实际发送的字节更多,从而导致服务器端read()悬挂。在这种情况下,确保您的联系有时很重要。

现在,另一种选择是简单地发布诸如JSON之类的格式。这在REST API中非常受欢迎,该请求甚至可以选择:

import requests

r = requests.post('http://localhost/', json={'var1': 'test', 'var2': 'test2'})
print(r.headers)
print(r.content)

然后可以在服务器端将其解码为JSON:

    def read_post_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))
        content_length = int(self.headers['Content-Length'])
        data = self.rfile.read(content_length)
        self.data = json.loads(data)
        print(self.data)

在这种情况下,json.loads接受字节,因此我们不需要自己解码。明智的输出是相同的,但是内容类型已更改为JSON:

{'Host': 'localhost', 'User-Agent': 'python-requests/2.22.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Length': '33', 'Content-Type': 'application/json'}
{'var1': 'test', 'var2': 'test2'}

现在,另一种方法是一种称为Multipart Post的方法。这主要用于您可能正在处理二进制输入以及其他表单字段的情况(通常是以HTML表单为单位的文件选择输入)。因此,要了解这是什么样子,我将更新我们的客户:

import requests

multipart_data = {
    'image_data': ('python_logo.png', open('python-logo-only.png', 'rb'), 'image/png'),
    'field1': (None, 'value1'),
    'field2': (None, 'value2')
}

r = requests.post('http://localhost/', files=multipart_data)
print(r.headers)
print(r.content)

因此,每个prouptart_data条目是字段名称是什么和元组值的关键。实际文件将以文件名作为第一部分,将文件指针作为第二部分,是内容的可选MIME类型。常规字段仅将None作为文件名和值的字符串内容作为第二部分。这一切都以files=关键字参数的方式传递,在“请求”帖子中。现在检查服务器将收到的内容:

    def read_post_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))
        content_length = int(self.headers['Content-Length'])
        self.data = self.rfile.read(content_length)
        print(self.data)

很多数据回来了:

{'Host': 'localhost', 'User-Agent': 'python-requests/2.22.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Length': '21005', 'Content-Type': 'multipart/form-data; boundary=0cfc2d1479f926612dde676e228fc12c'}
b'--0cfc2d1479f926612dde676e228fc12c\r\nContent-Disposition: form-data; name="image_data"; filename="python_logo.png"\r\nContent-Type: image/png\r\n\r\n\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\r\x00\x00\x01F\x08\x06\x00\x00\x00p\x8d\xca\xa7\x00\x00\x00\tpHYs\x00\x00#\xbf\x00\x00#
<snip lots of binary here>
\r\n--0cfc2d1479f926612dde676e228fc12c\r\nContent-Disposition: form-data; name="field1"\r\n\r\nvalue1\r\n--0cfc2d1479f926612dde676e228fc12c\r\nContent-Disposition: form-data; name="field2"\r\n\r\nvalue2\r\n--0cfc2d1479f926612dde676e228fc12c--\r\n'

所以这里发生了什么,我们有一个叫做边界的东西。这有助于显示每个字段的分离。我清理了最后一部分的输出,最终看起来像这样:

--0cfc2d1479f926612dde676e228fc12c
Content-Disposition: form-data; name="field1"

value1
--0cfc2d1479f926612dde676e228fc12c
Content-Disposition: form-data; name="field2"

value2
--0cfc2d1479f926612dde676e228fc12c--

您可以看到boundary=作为内容类型标头的一部分具有--,然后在其自己的行上指示一个新字段。最后一个有一个--,最终显示了所有领域的完成。其中大部分来自used multiparts作为指示文件附件的一种电子邮件标准。现在所有这些看起来都很繁琐,但是值得庆幸的是,我们可以通过pip install multipart安装一个包,这使与Multipart合作变得更加容易:

from multipart import MultipartParser
<snip>
   def read_post_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))
        content_length = int(self.headers['Content-Length'])
        content_boundary = self.headers['Content-Type'].split('=')[1]
        self.data = MultipartParser(self.rfile, content_boundary, content_length)
        print(self.data.get('field1').value)
        print(self.data.get('field2').value)

现在启动服务器并再次运行客户端后:

{'Host': 'localhost', 'User-Agent': 'python-requests/2.22.0', 'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Length': '21005', 'Content-Type': 'multipart/form-data; boundary=708b331135e8d587fd9a1cced157cf79'}
value1
value2
127.0.0.1 - - [29/Jul/2023] "POST / HTTP/1.1" 200 -

正在显示数据。 multipart还提供了一个方便的save_as方法用于下载文件:

    def read_post_request(self):
        self.log_message(f"Reading request from {self.client_address}")
        print(dict(self.headers.items()))
        content_length = int(self.headers['Content-Length'])
        content_boundary = self.headers['Content-Type'].split('=')[1]
        self.data = MultipartParser(self.rfile, content_boundary, content_length)
        image_entry = self.data.get('image_data')
        image_entry.save_as(image_entry.filename)

这将将图像写入当前目录,并使用我们在请求数据中给它的python_logo.png名称。

状态代码

现在,我们查看一些HTTP状态代码。我没有经历每个人,而是简单地介绍不同类别的内容。

2xx

这些表示成功。在所有这些中,200是大多数情况。

3xx

这些通常涉及重定向。 304有点奇怪,表明尚未修改内容。这用于与缓存系统协调。 307可用于指示重定向到另一个位置。

4xx

这主要是在请求中显示出不好的东西。一些值得注意的代码:

  • 400-您的客户请求是完全错误的(缺失/畸形的标题)
  • 403-您无权查看页面
  • 404-很难找到以前从未击中过的人。用于指示页面不存在
  • 418-我是茶壶。根据四月的愚人标准有关Coffee Pot Protocol

5xx

这些代码都与破坏服务器有关。 500是通用的“此服务器损坏”。其他版本可以提供有关出了问题的确切性质的更多细节。

结论

这是使用Python来查看HTTP协议的结论。这也将是本系列的最后一部分。我认为,HTTP足够的水平足以阻止深度潜水,因为可以通过了解到目前为止的所有概念来更快地推理现代抽象(例如用户会话)。本指南的网络部分也对DevOps角色中的人员也可能有助于对更独特的情况进行故障排除。

如果我希望您摆脱困境,那就是尽管显示了所有代码,但它甚至不是完整的HTTP服务器实现,可以正常处理所有用例。安全明智的通信没有加密,没有超时处理,总体解析标题可以使用工作。因此,基本上尝试自己做,您必须牢记几个用例并与潜在的恶意演员打交道是不值得的。与您的安全需求,威胁模型和用户案例一起工作,以找到适合您需求的综合服务器。

感谢过去几周跟随我的所有新人们。期待未来的更多文章!