Minimale git-remote met publiek/prive-toggle

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.0 kB Ruw bestand
import crypto from "node:crypto";
import type { NextFunction, Request, Response } from "express";

import { config } from "./config.ts";
import { blockedFor, recordFailure, recordSuccess } from "./throttle.ts";

const COOKIE_NAME = "pub_repo_session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Express {
    interface Request {
      isOwner: boolean;
    }
  }
}

function timingSafeEqual(a: string, b: string): boolean {
  const bufA = Buffer.from(a, "utf8");
  const bufB = Buffer.from(b, "utf8");
  // Even lang maken zodat de vergelijking zelf niets over de lengte verklapt.
  const length = Math.max(bufA.length, bufB.length, 32);
  const padA = Buffer.alloc(length);
  const padB = Buffer.alloc(length);
  bufA.copy(padA);
  bufB.copy(padB);
  return crypto.timingSafeEqual(padA, padB) && bufA.length === bufB.length;
}

/**
 * Gebruikersnaam én wachtwoord worden gecontroleerd, en altijd beide, zodat de
 * duur van de vergelijking niet verklapt welke van de twee fout was.
 */
export function checkOwnerCredentials(user: string, password: string): boolean {
  const userOk = timingSafeEqual(user, config.ownerUser);
  const passwordOk = timingSafeEqual(password, config.ownerToken);
  return userOk && passwordOk;
}

function sign(payload: string): string {
  return crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url");
}

export function createSessionCookie(): string {
  const expires = Date.now() + SESSION_TTL_MS;
  const payload = String(expires);
  const value = `${payload}.${sign(payload)}`;
  const secure = config.publicUrl.startsWith("https://") ? "; Secure" : "";
  const maxAge = Math.floor(SESSION_TTL_MS / 1000);
  return `${COOKIE_NAME}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
}

export function clearSessionCookie(): string {
  return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}

function parseCookies(header: string | undefined): Map<string, string> {
  const jar = new Map<string, string>();
  if (!header) return jar;
  for (const part of header.split(";")) {
    const eq = part.indexOf("=");
    if (eq === -1) continue;
    jar.set(part.slice(0, eq).trim(), decodeURIComponent(part.slice(eq + 1).trim()));
  }
  return jar;
}

function hasValidSession(req: Request): boolean {
  const raw = parseCookies(req.headers.cookie).get(COOKIE_NAME);
  if (!raw) return false;
  const dot = raw.lastIndexOf(".");
  if (dot === -1) return false;
  const payload = raw.slice(0, dot);
  const signature = raw.slice(dot + 1);
  if (!timingSafeEqual(signature, sign(payload))) return false;
  const expires = Number.parseInt(payload, 10);
  return Number.isFinite(expires) && expires > Date.now();
}

function basicAuth(req: Request): { user: string; password: string } | null {
  const header = req.headers.authorization;
  if (!header?.startsWith("Basic ")) return null;
  const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
  const colon = decoded.indexOf(":");
  if (colon === -1) return null;
  return { user: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
}

/**
 * Zet `req.isOwner` voor elke request, op basis van cookie óf Basic auth, en
 * weigert afzenders die te vaak verkeerd geraden hebben.
 */
export function identify(req: Request, res: Response, next: NextFunction): void {
  const key = throttleKey(req);
  const credentials = basicAuth(req);

  if (credentials !== null) {
    // De rem geldt alleen voor wie inloggegevens meestuurt. Wie gewoon een publieke
    // repo bekijkt of cloont merkt hier niets van, ook niet als hij achter hetzelfde
    // IP zit als iemand die aan het raden was.
    if (tooManyAttempts(res, key)) return;

    if (checkOwnerCredentials(credentials.user, credentials.password)) {
      recordSuccess(key);
      req.isOwner = true;
      next();
      return;
    }
    recordFailure(key);
  }

  req.isOwner = hasValidSession(req);
  next();
}

export function throttleKey(req: Request): string {
  return req.ip ?? "onbekend";
}

/** Stuurt zelf een 429 en geeft `true` terug als er niets meer mag. */
export function tooManyAttempts(res: Response, key: string): boolean {
  const wait = blockedFor(key);
  if (wait === 0) return false;
  const seconds = Math.ceil(wait / 1000);
  res.setHeader("Retry-After", String(seconds));
  res
    .status(429)
    .type("text/plain")
    .send(`Te veel mislukte inlogpogingen. Probeer het over ${seconds}s opnieuw.\n`);
  return true;
}

export function requireOwnerWeb(req: Request, res: Response, next: NextFunction): void {
  if (req.isOwner) return next();
  const target = encodeURIComponent(req.originalUrl);
  res.redirect(`/login?next=${target}`);
}

/** Voor git-clients: 401 met WWW-Authenticate, zodat git om inloggegevens vraagt. */
export function requireOwnerGit(res: Response): void {
  res.setHeader("WWW-Authenticate", 'Basic realm="pub-repo", charset="UTF-8"');
  res.status(401).type("text/plain").send("Authenticatie vereist.\n");
}