请求大小限制是Web服务最基础的一道防护屏障,用来防止恶意客户端提交超大报文导致服务器内存被吃光。但很多开发者发现,明明在应用层配置了限制,线上还是会出现内存暴涨甚至服务崩溃的情况。问题往往不在于限制本身,而在于限制的校验方式存在可以被绕过的盲区。本文以Ruby技术栈为背景,先分析常见的绕过手法,再给出可落地的防御实现。

一、请求大小限制为什么会被绕过
先看一个典型的错误实现。不少Ruby项目会在Rack中间件或者Controller里读取整个请求体,然后再判断大小:
class BodySizeLimit
def initialize(app, max = 1.megabyte)
@app = app
@max = max
end
def call(env)
body = env['rack.input'].read
if body.bytesize > @max
return [413, {'Content-Type' => 'text/plain'}, ['Payload Too Large']]
end
env['rack.input'].rewind
@app.call(env)
end
end这段代码的问题非常明显:read不带参数会把整个请求体一次性读进内存,判断发生在读取之后。如果攻击者发送一个2GB的请求体,服务器会先把2GB数据完整读入,再抛出413。限制形同虚设,内存已经在判断之前被消耗掉了。
第二类绕过手法针对的是只检查Content-Length头的实现。HTTP头部是客户端可以随意伪造的,攻击者可以把一个超大报文的Content-Length声明成很小的值,或者干脆使用Transfer-Encoding: chunked分块传输,让报文根本没有Content-Length头。只依赖头部做校验的代码在这两种情况下都会失效。
第三类是压缩载荷攻击,俗称gzip炸弹。攻击者把一个几百GB的重复字符串压缩成几十KB的gzip包,请求在网络传输层面完全符合大小限制,但服务端解压后内存瞬间被撑爆。这类攻击发生在解压阶段,纯大小校验根本管不住。理解了这些绕过原理,防御方案的设计思路就清晰了:校验必须发生在读取过程中,而不是读取之后,并且要覆盖各种传输编码形式。
二、Ruby应用层的流式校验实现
正确的做法是边读边检查,一旦累计字节数超过阈值就立即中断连接。Rack提供了each方式逐块读取请求体,我们可以包装rack.input实现一个受限的输入流:
class LimitedInput
def initialize(io, max)
@io = io
@max = max
@read_bytes = 0
end
def read(length = nil, buffer = nil)
data = @io.read(length, buffer)
return nil if data.nil?
@read_bytes += data.bytesize
raise PayloadTooLarge if @read_bytes > @max
data
end
def each(&block)
while (chunk = @io.read(8192))
@read_bytes += chunk.bytesize
raise PayloadTooLarge if @read_bytes > @max
yield chunk
end
end
def rewind
@io.rewind
@read_bytes = 0
end
end
class BodySizeLimit
class PayloadTooLarge < StandardError; end
def initialize(app, max = 1.megabyte)
@app = app
@max = max
end
def call(env)
env['rack.input'] = LimitedInput.new(env['rack.input'], @max)
begin
@app.call(env)
rescue PayloadTooLarge
[413, {'Content-Type' => 'text/plain'}, ['Payload Too Large']]
end
end
end这个实现的关键点在于each方法每次只读8KB,累计计数超过上限立刻抛出异常,剩余的数据根本不会被读取。配合异常捕获返回413,整个过程中内存占用始终维持在KB级别,攻击者发多大的报文都无济于事。
如果使用Rails,还可以借助ActionDispatch::Request的流式接口配合rack.input实现同样的效果,另外要注意在Nginx或Puma层面开启reject_large_requests相关的超时保护,避免客户端慢速发送小数据块长期占用连接。慢速攻击虽然单次不超限,但可以通过连接数堆积拖垮服务,这是大小限制之外需要单独处理的维度。
三、反向代理层与应用层的多层配合
只靠Ruby应用层防御是不够的,请求在到达Rack之前要先经过反向代理,在最外层拦截效率最高。Nginx提供了client_max_body_size指令,超出限制的请求会在代理层直接被切断,不会转发到Ruby进程:
http {
# 全局限制请求体为2MB
client_max_body_size 2m;
# 文件上传接口单独放宽到20MB
location /api/uploads {
client_max_body_size 20m;
proxy_pass http://puma_backend;
}
# 其他接口收紧到256KB
location /api/ {
client_max_body_size 256k;
client_body_buffer_size 128k;
client_body_timeout 10s;
proxy_pass http://puma_backend;
}
}这里有个容易忽略的细节:client_max_body_size默认值为1MB,但它的校验依据主要是Content-Length头和分块传输过程中的累计大小,对chunked请求同样有效,Nginx会边收边检查,超出即断开。而client_body_timeout控制的是两次读操作之间的间隔,把它调小可以有效缓解慢速攻击。
针对gzip炸弹,需要在解压环节加保险。Ruby标准库的Zlib::GzipReader支持流式读取,解压时同样要限制累计解压出的字节数:
require 'zlib'
def safe_gunzip(io, max_decompressed = 10.megabytes)
reader = Zlib::GzipReader.new(io)
result = +''
while (chunk = reader.read(64 * 1024))
result << chunk
raise '解压后数据超限' if result.bytesize > max_decompressed
end
reader.close
result
end同理,解析JSON时不要用JSON.parse(raw_body)一次性反序列化超大字符串,可以采用分批解析或在中间件层先截断。对于必须接收大文件的场景,建议直接走对象存储的预签名URL方案,让客户端把文件直传到S3或OSS,API只接收一个文件引用,从根本上避免大流量经过Ruby进程。
总结一下防御要点:第一,大小校验必须在流式读取过程中进行,而不是读完再判断;第二,不能信任Content-Length头,要覆盖chunked编码;第三,解压操作要有独立的解压后大小上限;第四,代理层的限制和应用层的限制要同时存在,形成纵深防御。把这四点落实到位,请求大小限制才能真正发挥保护作用,而不是一个看起来安全的摆设。