Skip to content

hardened responses/redirect variant #194

@terrablue

Description

@terrablue

redirect currently accepts a string as target. Hardened alternative:

type RedirectionStatus =
  | 300 // MULTIPLE_CHOICES
  | 301 // MOVED_PERMANENTLY
  | 302 // FOUND
  | 303 // SEE_OTHER
  | 304 // NOT_MODIFIED
  // 305 USE_PROXY (deprecated)
  // 306 SWITCH_PROXY (reserved)
  | 307 // TEMPORARY_REDIRECT
  | 308 // PERMANENT_REDIRECT
  ;

type RedirectObject = {
  // must starts with "/"
  pathname: string;
  query?: Record<string, boolean | null | number | string | undefined>;
};

type RedirectTarget = RedirectObject | string;

type RedirectOptions = {
  // Allow absolute external redirects, default: `false` (same-origin only)
  allowExternal?: boolean;
  // Base URL for normalising relative paths and to compare origins, default `request.url`
  base?: string | URL;
  // Max allowed `Location` header size (bytes), default `2048`
  maxLocationBytes?: number;
};

type RedirectInit = {
  headers?: HeadersInit;
  status?: RedirectionStatus; // default FOUND (302)
} & Omit<ResponseInit, "headers" | "status"> & RedirectOptions;

export default function redirect(target: RedirectTarget, init: RedirectInit = {}): ResponseFunction {
  // impl
}

hardening todos (see helpers):

  • block CR/LF
  • block protocol-relative
  • strip fragments
  • enforce same-origin unless disabled
  • cap Location length

helpers:

function hasCRLF(s: string): boolean {
  return /[\r\n]/.test(s);
}

function isAbsoluteUrl(s: string): boolean {
  try {
    new URL(s);
    return true;
  } catch {
    return false;
  }
}

function encodePathname(s: string): string {
  // Expect strict origin-form path ("/..."), disallow protocol-relative and
  // backslashes
  if (!s.startsWith("/") || s.startsWith("//") || s.includes("\\")) {
    throw new Error("invalid pathname");
  }
  // Encode per segment to avoid double-encoding percent-escapes
  return s.split("/")
    .map((each, i) => i === 0 ? "" : encodeURIComponent(each)).join("/");
}

function toSearch(query?: RedirectObject["query"]) {
  if (query === undefined) {
    return "";
  }

  const search = Object.entries(query)
    .filter(([, v]) => v !== null && v !== undefined)
    .reduce((params, [k, v]) => {
      params.append(k, String(v));
      return params;
    }, new URLSearchParams())
    .toString()
    ;

  return search ? "?" + search : "";
}

function toNormalized(relative: string, base?: string | URL): string {
  // base -> URL resolution
  if (base !== undefined) {
    const url = new URL(relative, new URL(String(base)));
    return url.pathname + url.search;
  }
  // no base -> enforce strict single-slash path and strip fragment
  if (!relative.startsWith("/") || relative.startsWith("//")) {
    throw new Error("bad relative");
  }

  const i = relative.indexOf("#");
  return i === -1 ? relative : relative.slice(0, i);
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions