123 lines · 5.5 KB
/** Pure USERCONTENT_ORIGIN validation and grant encoding (no Worker bindings), so it unit-tests under bun. */

import { bytesToHex, hexToBytes, hmacSha256, utf8 } from "./sync-hash";

export const USERCONTENT_GRANT_PARAM = "uc";
/** Long enough for a page full of images to finish loading, short enough that a leaked link goes stale fast. */
export const USERCONTENT_GRANT_TTL_MS = 15 * 60 * 1000;
const MAX_GRANT_PATH = 4096;
const MAX_GRANT_LENGTH = 256;

/** Read-only paths whose bodies are user-supplied bytes; uploads stay on the forge origin with its cookie and CSRF checks. */
const SERVE_PATHS = [
  /^\/[^/]+\/[^/]+\/raw\/.+$/,
  /^\/[^/]+\/[^/]+\/archive\/.+\.(?:tar\.gz|tgz)$/,
  /^\/[^/]+\/[^/]+\/releases\/download\/[^/]+\/[^/]+$/,
  /^\/attachments\/[a-z0-9]+\/[^/]+$/,
  /^\/avatars\/(?:u\/)?[^/]+$/,
  /^\/camo\/[^/]+\/[^/]+$/,
];

export function isUserContentPath(pathname: string): boolean {
  return SERVE_PATHS.some((pattern) => pattern.test(pathname));
}

function within(host: string, domain: string | undefined): boolean {
  const base = (domain ?? "")
    .trim()
    .toLowerCase()
    .replace(/^\.+|\.+$/g, "");
  return base !== "" && (host === base || host.endsWith(`.${base}`));
}

/**
 * Why a USERCONTENT_ORIGIN is unsafe, or null. User-supplied bytes (raw files, attachments, avatars, proxied
 * images, release assets) must never share an origin with the forge: it must not be the forge host, must not
 * sit under SITES_DOMAIN or PAGES_DOMAIN (other user content), and must not be the editor origin.
 */
export function userContentOriginProblem(
  raw: string,
  forgeHost: string,