129 lines · 5.7 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\/[^/]+\/[^/]+$/,
];

/** User-content paths that serve the same public bytes to everyone, so their redirects need no viewer grant. */
const PUBLIC_PATHS = [/^\/avatars\/(?:u\/)?[^/]+$/];

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

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}`));
}

/**