← 中文资源

简体中文版本

Referrer Policy

Referrer Policy 控制浏览器在外发导航与子资源请求的 Referer 头中泄漏多少 URL 细节,从完整 URL 到仅源再到完全不带。

阅读英文原文

概念定义

每个外发请求都可能携带引发它的页面 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));
}