网络与常用互联网协议
本节目标
查询 Python socket、TCP/UDP、TLS、URL、HTTP、邮件协议和离线测试边界。
网络交互不是“发出请求便得到完整结果”的单次函数调用,而是由地址解析、传输、加密和应用协议共同组成的有状态会话。等待、输入大小、所有权和失败都要逐层定义边界。本章以 socket、selectors、ssl、asyncio streams、urllib.parse、urllib.request、http.client、email、smtplib、imaplib和poplib的 Python 3.14 合同为准。工具选择地图见标准库地图,任务与取消语义见线程、进程、任务与 asyncio。
网络分层、地址与名称解析
先把目标拆成四件事:协议决定通信语义,地址族决定地址的形状,地址标识网络端点,名称解析把外部名称映射成一个或多个候选地址。AF_INET、AF_INET6 与其他地址族并非可互换的字符串标签;同一名称也可能得到多个 IPv4/IPv6 结果。需要兼容多个地址族时,用 getaddrinfo() 获取候选项并按受控策略逐个尝试,不要先调用只返回单个 IPv4 地址的旧式便利函数。
主机名会触发系统名称解析,结果依赖 DNS、hosts 配置、搜索域和缓存;解析可能阻塞、失败或随时间变化。它必须与连接一样有独立的失败模型,不能把“语法像域名”当作“已授权的目标”。安全敏感客户端应先规范化 scheme 与名称,再解析、检查所有候选地址是否落在允许范围;连接和重定向时还要防止检查结果与实际目标分离。
端口只选择一台主机上的传输层服务,不证明该服务的身份,也不等同于应用协议。地址解析成功同样不证明端点可达、证书可信或响应符合业务合同。最常见的错误是把这些阶段压成一个布尔值,随后既无法分辨解析、连接、TLS 与协议失败,也无法为每一阶段配置不同上限。
socket 生命周期与消息 framing
socket 对象拥有底层系统 socket 资源:在 Unix 上通常由文件描述符标识,在 Windows 上则由 socket handle 支撑。fileno() 返回值与描述符或 handle API 的互操作能力取决于平台;Windows 上该小整数通常不能当作普通文件描述符使用(例如不能传给 os.fdopen()),Unix 没有这个限制。创建成功后立刻进入 with 或 try/finally;一旦把 socket 交给其他对象或任务,必须写明所有权是否转移,避免两个调用者都假定对方会关闭。流式 socket 没有应用消息边界:一次 send() 不对应一次 recv(),必须用固定长度、长度前缀、分隔符或“关闭写端即消息结束”等协议恢复边界。
下面的唯一代表脚本采用网络字节序的四字节无符号长度前缀。receive_exact() 循环处理短读,并把帧未完整时遇到 EOF 当作协议错误;发送方发送后半关闭写方向,接收方读完帧再确认 EOF。socketpair() 只证明本地流式 socket 的 framing、半关闭与清理,不证明所有平台上的 TCP 行为;它不绑定应用端口、不解析名称,也不访问公网或外部网络。
import socket
import struct
def receive_exact(stream: socket.socket, size: int) -> bytes:
chunks: list[bytes] = []
remaining = size
while remaining:
chunk = stream.recv(remaining)
if not chunk:
raise EOFError("stream ended before the frame was complete")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
sender, receiver = socket.socketpair()
try:
payload = b"hello"
sender.sendall(struct.pack("!I", len(payload)) + payload)
sender.shutdown(socket.SHUT_WR)
header = receive_exact(receiver, 4)
frame_length = struct.unpack("!I", header)[0]
received = receive_exact(receiver, frame_length)
peer_eof = receiver.recv(1) == b""
finally:
sender.close()
receiver.close()
closed = sender.fileno() == -1 and receiver.fileno() == -1
print(f"sent={len(payload)}")
print(f"received={received.decode('ascii')}")
print(f"frame-length={frame_length}")
print(f"peer-eof={peer_eof}")
print(f"closed={closed}")
sent=5
received=hello
frame-length=5
peer-eof=True
closed=True
真实协议还必须在分配 payload 前拒绝超过上限的长度,并区分“对端正常 EOF”“帧中途 EOF”和本地取消。只验证快乐路径、先按攻击者给出的长度分配内存,或依赖垃圾回收释放描述符,都会让 framing 缺陷转化为资源耗尽或泄漏。
TCP 流、连接与半关闭
TCP 提供有序字节流,不保留应用消息边界。send() 和 recv() 都可能只处理部分字节:发送端必须循环或使用 sendall(),接收端必须按 framing 循环补齐短读。即使 sendall() 返回,也只说明本地发送操作未报告错误,不证明对端应用已经处理消息;若它抛出异常,也不能从该异常推断此前究竟发送了多少字节,因此自动重试需要协议级幂等标识。
对非零长度的 recv(),返回 b"" 表示对端发送方向已到达 EOF。shutdown(SHUT_WR) 只关闭本端发送方向,本端仍可继续接收对端剩余数据;这适合表达“请求体已发送完,仍等待响应”。close() 则释放本地资源。协议要明确谁先半关闭、谁读到 EOF、何时关闭整个连接,避免双方都等待对方继续发送。
连接建立不等于会话永远健康。每个阶段都可能超时、复位或只完成部分工作;固定次数盲目重试会重复非幂等操作。应用应使用总 deadline 分配解析、连接、TLS、写入和读取预算,并在任何失败路径关闭 socket、保留阶段化诊断,同时避免把凭据或完整不可信响应写入日志。
UDP 数据报
UDP 保留数据报边界,但不保证到达、顺序或唯一性。一次接收得到一条数据报的至多缓冲区容量;缓冲区过小可能截断数据,不能像 TCP 一样用下一次读取补上同一数据报。发送成功只表示数据报已交给本地网络栈,不是远端收取或处理确认。
需要可靠性的协议必须自己定义消息 ID、去重、确认、重传、过期和乱序处理,并为最大数据报大小与重组成本设置上限。重试间隔与次数也要有界,否则丢包时会放大流量。广播、组播、路径 MTU 与 ICMP 行为具有网络和平台差异,不能从本机测试推出公网保证。
数据报来源地址同样是不可信输入,能够伪造或发生变化;不能仅凭来源元组授权敏感操作。常见错误是把 UDP 描述为“更快的 TCP”,或在没有协议级确认的情况下宣称业务已成功。
超时、非阻塞与 selectors
阻塞、timeout 和非阻塞是同一 socket 的不同等待策略。单次 settimeout() 可以限制阻塞操作,但多步协议若每步都重新获得完整 timeout,总耗时仍可能无界;更稳妥的是以单调时钟计算总 deadline,每次只等待剩余预算。非阻塞操作则可能抛出 BlockingIOError,调用者要保存尚未发送的数据和解析到一半的帧状态。
selectors.DefaultSelector 为平台选择高层 I/O 多路复用实现。注册感兴趣的读写事件后,select(timeout) 返回当前 ready 的文件对象;readiness 只表示此刻尝试 I/O 可能不会阻塞,不等于 I/O 已成功。通知与实际操作之间状态可能改变,操作仍可能短读、短写、遇到 EOF、错误或再次 BlockingIOError,所以事件处理器必须按实际返回值推进状态机。
selector 也需要 close(),注销登记并不自动关闭底层 socket。不要永久注册写事件——普通 socket 经常一直可写,会形成忙循环;只有存在待发送缓冲时才关注写就绪。每轮事件处理还应限制单连接工作量,避免一个活跃对端饿死其他连接。
asyncio 流与服务端生命周期
open_connection() 和 start_server() 用 StreamReader/StreamWriter 封装异步字节流,但 framing、大小限制与 EOF 合同不会因此消失。读取固定帧可用 readexactly() 并处理 IncompleteReadError;写入后用 await writer.drain() 接受传输层背压。阻塞的 DNS、文件或同步客户端不能直接塞进事件循环线程。
start_server() 返回的 Server 只拥有监听设施,不自动替应用追踪每一个长期客户端会话。关闭 server 后还要 await server.wait_closed();每个 StreamWriter 都要 close() 并 await wait_closed()。应用还应保存客户端处理 Task,停止接收后取消或等待它们、观察异常,再关闭 loop 管理的其他资源。
取消可能在任意 await 处到达,所以 writer 的关闭应位于 finally,清理完成后通常继续传播 CancelledError。asyncio.wait_for() 或 asyncio.timeout() 只提供有界等待框架;底层操作是否因取消而留下半完成协议状态仍需调用者判断,不能未经重新同步就把连接放回池中。
SSLContext、TLS 与证书验证
TLS 为传输提供机密性与完整性,但客户端只有同时验证受信任证书链和预期主机名,才建立了目标身份。常规客户端从 ssl.create_default_context() 或面向客户端的 PROTOCOL_TLS_CLIENT 开始,加载合适的信任根,并把原始目标名称作为 server_hostname 参与 SNI 与主机名检查。不能为了让握手成功而关闭证书验证或主机名检查。
SSLContext 的协议、最低版本、信任根和客户端证书应在建立连接前配置;共享 context 时不要在活跃连接之间并发改变验证策略。证书验证失败、主机名不匹配、握手超时和对端关闭都应作为真实失败处理,不能自动回退到明文或 CERT_NONE。
TLS 不验证应用内容是否安全,也不防止客户端主动访问了被业务禁止的内网目标。连接、握手、读写与关闭分别需要 deadline;读取的密文、解密后正文和应用解压后的数据都需要大小上限。日志不得记录私钥、认证令牌或完整会话内容。
URL 解析与 HTTP 客户端
urlsplit()/urlparse() 返回 scheme、authority、path、query 与 fragment,URL 解析只拆分文本结构,不验证目标是否可信。解析后必须按应用策略检查允许的 scheme、凭据、规范化主机、端口和路径;访问网络前再解析地址并拒绝回环、链路本地、私网或其他受限范围。urljoin() 遇到攻击者提供的绝对 URL 时可以替换原来的主机,不能直接用它拼接不可信跳转目标。
urllib.request.urlopen() 是方便的高层客户端,http.client 提供更低层的请求/响应状态机。无论使用哪一层,都要明确方法、状态码、重定向次数、代理、TLS context、字符编码和连接关闭;响应对象或连接应放进上下文管理或 finally。timeout 不能替代总 deadline,也不能限制响应正文大小。
HTTP 与邮件客户端都必须设置时间、响应大小和资源生命周期上限。读取正文时累计原始字节数,拒绝超限的 Content-Length,也要防止未声明或分块正文无限增长;压缩响应的解压后大小也必须受限。重定向后的每一跳都要重新执行目标信任与 SSRF 检查,不能因为第一跳通过便信任后来解析出的 scheme、凭据、主机或地址。
电子邮件消息与协议客户端
email 包处理 RFC 风格的消息数据模型:解析、创建头字段、MIME 正文和附件;它本身不发送或收取邮件。EmailMessage 适合构造结构化消息,解析不可信字节时仍要限制总消息、头字段、MIME 层数、附件数量和解码后大小,并把文件名当普通不可信文本处理。
smtplib 实现 SMTP 提交/发送,imaplib 与 poplib 用于读取邮箱,三者的命令、状态和服务器能力不同。加密连接应传入安全 SSLContext;认证凭据不能硬编码或写入调试日志。连接 timeout 只约束文档声明覆盖的阻塞阶段,不能假定整个多命令会话已有端到端 deadline。
所有客户端都要检查协议返回值与异常,并按库合同执行 quit()、logout()、close() 或上下文退出;清理本身也可能失败,不能掩盖原始异常。批量列取、搜索、下载与附件解析必须分页或计量,不能把整个邮箱或未知大小消息一次装入内存。
网络输入安全与离线测试
网络输入安全从“先限定,再解析”开始:限制帧长、头数量、正文与附件大小、嵌套深度、重定向次数、解压后总量和并发连接数。对出站目标实行 scheme/主机/解析地址 allowlist,重定向和重连都重新校验,以降低 SSRF、DNS 结果变化和跨协议跳转风险。错误消息和指标保留阶段、类别与计数即可,避免泄露 token、cookie、邮件内容或个人地址。
离线测试不访问公网、真实 DNS 或监听端口。像本章脚本一样用 socketpair() 测试短读循环、长度前缀、半关闭、EOF 和 finally 清理;再注入分段输入、过大长度、帧中途 EOF 与取消,验证每条失败路径释放双方资源。解析器、URL 策略和重定向策略优先以纯函数输入测试,客户端则用本地内存替身或显式注入的传输接口验证 timeout、大小与清理合同。
所列测试通过只能证明对应场景,不能据此推断生产网络可靠。上线前还要在目标平台验证 IPv4/IPv6、证书存储、代理、取消与关闭差异,并以受控故障测试确认 deadline、重试幂等性和资源计数最终归零。