概念定义
请求走私是一类前后端失步攻击:当代理与源站对 Content-Length 与 Transfer-Encoding 等成帧信号解析不一致时,一个请求的多余字节会被当作下一个请求的开头。被走私的前缀可劫持会话、毒化缓存或绕过内部访问控制。
工作机制
经典变体滥用冲突的长度头、重复头部或畸形块长度。HTTP/2 到 HTTP/1.1 的翻译层引入新向量:内容长度本由帧隐含,翻译时重建反而制造歧义。走私前缀可携带完整请求,后端会把它归属到受害者的连接上。
使用场景
任何多于一个 HTTP 跳点的部署都应做成帧规范化:拒绝冲突或重复的长度头、强制单一 Transfer-Encoding、丢弃驱动失步的头部名。CDN、API 网关、企业出口代理与 WAF 都在此列,且多个代理组合存在已公开的失步案例。
常见误区
走私者往往只是普通用户,失步存在于你自己的设备之间,而不是攻击者发出畸形请求这么简单。症状常很微弱:诡异的 400、幻影 405、或陌生会话行为异常。经走私响应造成的缓存污染可在攻击结束后持续很久。修复应落在规范化中间层,而非逐个补丁。
代码示例
export function parseContentDisposition(header) {
if (typeof header !== "string" || header.trim() === "") {
throw new TypeError("header must be a non-empty string");
}
const segments = splitParameters(header);
const type = segments.shift().trim().toLowerCase();
if (type !== "inline" && type !== "attachment") {
throw new Error(`unsupported disposition type "${type}"`);
}
const parameters = new Map();
for (const segment of segments) {
const equals = segment.indexOf("=");
if (equals === -1) continue;
const name = segment.slice(0, equals).trim().toLowerCase();
if (name === "") continue;
parameters.set(name, unquote(segment.slice(equals + 1).trim()));
}
return { type, filename: resolveFilename(parameters), parameters };
}
function splitParameters(header) {
const segments = [];
let current = "";
let quoted = false;
for (const character of header) {
if (character === '"') quoted = !quoted;
if (character === ";" && !quoted) {
segments.push(current);
current = "";
continue;
}
current += character;
}
segments.push(current);
return segments;
}
function unquote(value) {
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replace(/\\(.)/g, "$1");
}
return value;
}
function resolveFilename(parameters) {
const extended = parameters.get("filename*");
if (extended !== undefined) {
const match = /^([\w-]+)'([\w-]*)'(.+)$/.exec(extended);
if (match !== null) {
try {
return sanitize(decodeURIComponent(match[3]));
} catch {
return sanitize(extended);
}
}
}
const plain = parameters.get("filename");
return plain === undefined ? null : sanitize(plain);
}
function sanitize(filename) {
return filename.split(/[\\/]/).pop() || null;
}
console.log(parseContentDisposition('attachment; filename="report.pdf"'));
console.log(parseContentDisposition("attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87.pdf"));
console.log(parseContentDisposition('inline; filename="../../etc/passwd"'));
export class LatestRequestGate {
private active: AbortController | null = null;
private sequence = 0;
private begin(): () => boolean {
this.sequence += 1;
const ticket = this.sequence;
return () => ticket === this.sequence;
}
async run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T | undefined> {
if (typeof task !== "function") throw new TypeError("task must be a function");
const controller = new AbortController();
const isCurrent = this.begin();
if (this.active) this.active.abort();
this.active = controller;
try {
const result = await task(controller.signal);
return isCurrent() ? result : undefined;
} catch (error) {
if (controller.signal.aborted) return undefined;
throw error;
} finally {
if (isCurrent()) this.active = null;
}
}
abort(): void {
if (this.active) {
this.active.abort();
this.active = null;
}
}
}