Minimale git-remote met publiek/prive-toggle
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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");
}