Een dating-app op basis van brieven, niet van gezichten!

main

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

5.8 kB Ruw bestand
/*
 * Eenvoudige rate-limiter in het geheugen (schuivend venster): per sleutel maximaal `max`
 * treffers per `windowMs`. Genoeg voor één serverproces; bij meerdere processen zou dit in
 * Redis of Postgres moeten. Doel: brute force op inloggen/registreren/wachtwoordbevestiging
 * afremmen (AVG art. 32 — passende beveiliging).
 *
 *   const login = rateLimiter({ windowMs: 15 * 60_000, max: 10 });
 *   const { ok, retryAfterSec } = login.hit(`ip:${ip}`);
 */
export function rateLimiter({ windowMs, max }) {
  const hits = new Map(); // key → [timestamps]

  const prune = (arr, now) => {
    while (arr.length && arr[0] <= now - windowMs) arr.shift();
    return arr;
  };

  /* Registreert een poging en zegt of die nog mag */
  const hit = (key) => {
    const now = Date.now();
    const arr = prune(hits.get(key) ?? [], now);
    if (arr.length >= max) {
      hits.set(key, arr);
      return { ok: false, retryAfterSec: Math.ceil((arr[0] + windowMs - now) / 1000) };
    }
    arr.push(now);
    hits.set(key, arr);
    return { ok: true, retryAfterSec: 0 };
  };

  /* Bij succes (goed wachtwoord) de teller van die sleutel wissen, zodat één vergissing niet blijft hangen */
  const reset = (key) => hits.delete(key);

  /* Af en toe lege sleutels opruimen, anders groeit de map met elk ooit gezien adres */
  const sweep = () => {
    const now = Date.now();
    for (const [key, arr] of hits) if (!prune(arr, now).length) hits.delete(key);
  };
  const timer = setInterval(sweep, windowMs);
  timer.unref?.();

  return { hit, reset };
}

/*
 * Het adres van de client, en dit is de gevoeligste regel van dit bestand: hierop hangt elke rem
 * die per adres telt (inloggen, registreren, herstel-links, en het slot op /promotieteam).
 *
 * Hier stond eerder het eerste element van X-Forwarded-For, met de toelichting dat nginx het echte
 * adres daar zet. Dat is precies andersom. `$proxy_add_x_forwarded_for` is
 * "$http_x_forwarded_for, $remote_addr" — nginx plákt het echte adres achteraan en laat staan wat de
 * client zelf meestuurde. Het eerste element is dus door de bezoeker zelf gekozen, en daarmee was
 * elke teller met één header leeg te houden: onbeperkt wachtwoorden raden, onbeperkt gratis plekken
 * claimen, onbeperkt herstelmail laten versturen.
 *
 * Nu drie regels, in deze volgorde:
 *
 *   1. Vertrouwen we de headers? Alleen als het verzoek van onze eigen machine of ons eigen netwerk
 *      binnenkomt — dat is nginx die proxyt. Komt het rechtstreeks van buiten, dan is er geen proxy
 *      en is elke Forwarded-header verzonnen; dan telt het socket-adres. Zo is er niets in te stellen
 *      en staat het toch goed: op de server via nginx (loopback) wél, op een blootgestelde poort niet.
 *      TRUST_PROXY=1 of 0 in de omgeving overrulet die afweging als een opstelling anders in elkaar zit.
 *   2. X-Real-IP als die er staat — daar zet nginx `$remote_addr` in en dus niets van de client.
 *      Zie de README bij Productie; die regel hoort in de site-configuratie.
 *   3. Anders het láátste element van X-Forwarded-For: wat onze eigen proxy erbij schreef.
 */
const kaal = (ip) => String(ip ?? "").trim().replace(/^::ffff:/i, "");

/* Loopback, de private reeksen (RFC 1918), link-local en unique-local IPv6 — "onze eigen kant" */
const EIGEN_NET = /^(?:127\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|::1$|f[cd])/i;

const TRUST = (process.env.TRUST_PROXY ?? "").trim().toLowerCase();
const PROXY_ALTIJD = ["1", "true", "ja"].includes(TRUST);
const PROXY_NOOIT = ["0", "false", "nee"].includes(TRUST);

/*
 * De keerzijde van "zonder proxy-header telt het socket-adres": staat de app wél achter nginx maar
 * zet die geen enkele header, dan komt iedereen op hetzelfde loopback-adres uit en delen alle
 * bezoekers samen één emmer — dan is één iemand die zich vertypt genoeg om de hele app op slot te
 * zetten. Dat is stil (het lijkt gewoon te werken) en daarom zegt hij het, één keer, met de regel
 * die het oplost erbij.
 */
let gewaarschuwd = false;
const eenEmmerVoorIedereen = () => {
  if (gewaarschuwd) return;
  gewaarschuwd = true;
  console.warn(
    "rate-limiting: elk verzoek komt binnen op een adres van onszelf en er is geen X-Real-IP of " +
    "X-Forwarded-For. Alle bezoekers delen nu één teller. Staat er nginx voor, zet dan " +
    "`proxy_set_header X-Real-IP $remote_addr;` in het location-block (zie README, Productie)."
  );
};

export function clientIp(c) {
  const socket = kaal(c.env?.incoming?.socket?.remoteAddress);
  const achterProxy = PROXY_ALTIJD || (!PROXY_NOOIT && (!socket || EIGEN_NET.test(socket)));
  if (!achterProxy) return socket || "onbekend";

  const real = kaal(c.req.header("x-real-ip"));
  if (real) return real;

  const xff = (c.req.header("x-forwarded-for") ?? "").split(",").map(kaal).filter(Boolean);
  if (xff.length) return xff.at(-1);

  eenEmmerVoorIedereen();
  return socket || "onbekend";
}

/*
 * "over 20 minuten" / "over 3 uur" — een wachttijd zoals je hem tegen iemand zegt. Boven het uur
 * naar boven afgerond: te vroeg terugkomen en er weer naast grijpen is vervelender dan wachten.
 */
export const inWords = (sec) => {
  const min = Math.max(1, Math.ceil(sec / 60));
  if (min < 60) return `over ${min} ${min === 1 ? "minuut" : "minuten"}`;
  const uur = Math.ceil(min / 60);
  return `over ${uur} ${uur === 1 ? "uur" : "uur"}`;
};

/*
 * Antwoord voor een geblokkeerde poging: 429 met Retry-After. De standaardtekst gaat over pogingen
 * (inloggen, registreren, bevestigen); wie iets begrenst dat géén poging is — een brief, een
 * zoekopdracht — geeft zijn eigen zin mee.
 */
export const tooMany = (c, retryAfterSec, message = null) => {
  c.header("Retry-After", String(Math.max(1, Math.ceil(retryAfterSec))));
  return c.json({ error: message ?? `Te veel pogingen — probeer het ${inWords(retryAfterSec)} opnieuw` }, 429);
};