概念定义
每个外发请求都可能携带引发它的页面 URL,而 URL 里常有用户名、令牌或能力链接。Referrer Policy 决定哪些请求带多少:完整 URL、仅源或不带,并对同源与跨源、安全到非安全跳转分别处理。现代默认是 strict-origin-when-cross-origin。
工作机制
策略可经响应头、meta 标签或元素的 referrerpolicy 属性下发。降级规则始终生效:HTTPS 页面向 HTTP 源绝不发送 Referer。默认策略只在目标同源时才发送完整 URL,跨源只发源。
使用场景
URL 嵌入状态的站点都应配置:密码重置链接、带筛选参数的管理视图、携带令牌的分享链接。严格策略还能收缩跨站跟踪面,同时不破坏只需要源的站内分析。
常见误区
完全禁用 Referer 并非唯一保护手段,strict-origin-when-cross-origin 已在跨源时剥掉路径。元素级属性会覆盖页面策略,单个嵌入就可能重新引入泄漏。能力 URL 无论头部多干净都应一次性使用,因为历史记录与日志同样留存它们。
代码示例
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 interface ByteRange {
readonly start: number;
readonly end: number;
}
export type RangeParseResult =
| { kind: "none" }
| { kind: "invalid"; reason: string }
| { kind: "unsatisfiable"; reason: string }
| { kind: "partial"; ranges: ByteRange[] };
function parseInteger(value: string): number | null {
if (!/^\d+$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}
export function parseRangeHeader(
header: string | null | undefined,
size: number,
): RangeParseResult {
if (!Number.isSafeInteger(size) || size < 0) {
throw new RangeError("size must be a non-negative safe integer");
}
if (header === null || header === undefined) {
return { kind: "none" };
}
if (typeof header !== "string") {
throw new TypeError("Range header must be a string");
}
const value = header.trim();
if (value.length === 0) {
return { kind: "invalid", reason: "empty Range header" };
}
const match = /^bytes=(.+)$/i.exec(value);
if (match === null) {
return {
kind: "invalid",
reason: "only bytes ranges are supported",
};
}
const specs = match[1].split(",").map((item) => item.trim());
if (specs.length === 0 || specs.length > 16) {
return {
kind: "invalid",
reason: "expected between 1 and 16 ranges",
};
}
const ranges: ByteRange[] = [];
let hadUnsatisfiable = false;
for (const spec of specs) {
if (spec.length === 0) {
return { kind: "invalid", reason: "empty range specification" };
}
if (spec.startsWith("-")) {
const suffix = parseInteger(spec.slice(1));
if (suffix === null || suffix === 0) {
return {
kind: "invalid",
reason: `invalid suffix range "${spec}"`,
};
}
if (size === 0) {
hadUnsatisfiable = true;
continue;
}
const length = Math.min(suffix, size);
ranges.push({ start: size - length, end: size - 1 });
continue;
}
const separator = spec.indexOf("-");
if (separator < 1) {
return { kind: "invalid", reason: `invalid range "${spec}"` };
}
const start = parseInteger(spec.slice(0, separator));
const endText = spec.slice(separator + 1);
if (start === null) {
return { kind: "invalid", reason: `invalid range "${spec}"` };
}
if (start >= size) {
hadUnsatisfiable = true;
continue;
}
if (endText.length === 0) {
ranges.push({ start, end: size - 1 });
continue;
}
const end = parseInteger(endText);
if (end === null || end < start) {
return { kind: "invalid", reason: `invalid range "${spec}"` };
}
ranges.push({ start, end: Math.min(end, size - 1) });
}
if (ranges.length === 0) {
return {
kind: "unsatisfiable",
reason: hadUnsatisfiable
? "no range overlaps the resource"
: "no ranges requested",
};
}
ranges.sort((left, right) => left.start - right.start || left.end - right.end);
const merged: ByteRange[] = [];
for (const range of ranges) {
const last = merged[merged.length - 1];
if (last !== undefined && range.start <= last.end + 1) {
merged[merged.length - 1] = {
start: last.start,
end: Math.max(last.end, range.end),
};
} else {
merged.push(range);
}
}
return { kind: "partial", ranges: merged };
}
const cases: Array<[string | null, number]> = [
["bytes=0-499", 1000],
["bytes=500-", 1000],
["bytes=-200", 1000],
["bytes=0-0,900-999", 1000],
["bytes=2000-3000", 1000],
["items=0-10", 1000],
];
for (const [header, size] of cases) {
console.log(header, parseRangeHeader(header, size));
}