Changeset 29eb745 in Klonkt


Ignore:
Timestamp:
08/08/2026 08:53:20 PM (4 weeks ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
dc0da72
Parents:
ea211b9
git-author:
Robin <roboburr@…> (08/08/2026 08:53:18 PM)
git-committer:
roboburr <roboburr@…> (08/08/2026 08:53:20 PM)
Message:

Doorslaggevend schuift mee, en de daemons hoeven geen TLS

Drie dingen van Bart (8-8).

  1. DOORSLAGGEVEND IS EEN STAND, GEEN MOMENT -- een fout in wat ik een uur eerder

bouwde. Ik berekende shaer:decisive een keer bij het doorsturen en bevroor het.
Bij vijf guardians staat er dan "je beslist niets", en zodra er een ja bij komt IS
elk van de anderen de doorslag. Precies de stille kant: het scherm zwijgt op het
moment dat het moet spreken. Na elke stem die het besluit open laat krijgen de
overgeblevenen nu een herziene stand, en alleen zij.

  1. DE VOLGPOORT MAG UITGEROLD (Barts akkoord). Zit in deze reeks.
  1. GEEN TLS VOOR DE TESTKUDDE. Barts vraag: waarom via https, gewoon localhost

::1. Terecht -- een certificaat en een publieke hostname toevoegen aan een doos
die ook andermans sites draait, om honderd neptestkinderen te bereiken, is de
verkeerde prijs.

Daarvoor mag de SSRF-poort gericht open: AP_ALLOW_HOSTS, een lijst van precieze
host:poort-paren. Geen "loopback is oke"-vlag. [::1]:3060 opent niet 127.0.0.1,
niet 3061, en niets in het interne netwerk -- want een brede vlag zet de
bescherming uit in een dev-omgeving, en dev-omgevingen worden productie. Leeg
tenzij een beheerder hem vult, en dat is de stand van elke productie-instance.

En het toetst iets dat we toch moesten kunnen: http MET HTTP-signatures, zonder
TLS. Een echt federatiegeval, tot nu toe nergens uitgeprobeerd.

ONDERWEG EEN LATENTE FOUT GEVONDEN. URL.hostname geeft een IPv6-literal met
blokhaken, en net.isIP herkent die vorm niet -- elk IPv6-adres viel door naar de
DNS-tak en strandde daar op ENOTFOUND. Geweigerd, maar per ongeluk en met de
verkeerde reden; isBlockedIp strippte die haken al en verwachtte dus input die
nooit aankwam. Nu wordt een IPv6-literal echt gecontroleerd.

EN EEN SLAAGTEST, want die ontbrak. Al mijn SSRF-toetsen controleerden alleen
weigeringen: de lijst helemaal negeren gaf nul rode toetsen, en de farm zou
stilletjes nooit werken. Nu bewijst er een dat wat op de lijst staat er ook
doorheen komt.

Suite 755/755. Drie mutaties gecontroleerd.

Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rea211b9 r29eb745  
    110110  return true; // not an IP literal we recognise → refuse
    111111}
     112/**
     113 * Uitzonderingen op de SSRF-poort, voor een testkudde op de eigen machine
     114 * (shaer-6wt: honderd wards met een guardian, Barts opdracht 8-8).
     115 *
     116 * WAAROM DIT MAG BESTAAN. De bescherming hierboven is er omdat een actor-URI van
     117 * een VREEMDE komt: een aanvaller die "http://169.254.169.254/" doorgeeft laat
     118 * ons zijn werk doen. Deze lijst gaat niet over vreemden -- hij staat in de
     119 * omgeving van deze server, wordt door de beheerder gezet, en is leeg tenzij
     120 * iemand hem expliciet vult.
     121 *
     122 * WAAROM HIJ ZO SMAL IS. Geen vlag die "loopback is oke" zegt, maar een lijst
     123 * van precieze host:poort-paren. `[::1]:3060` opent niet 127.0.0.1, niet poort
     124 * 3061, en niets in het interne netwerk. Een brede vlag zou de bescherming in
     125 * een dev-omgeving uitzetten, en dev-omgevingen worden productie.
     126 *
     127 *   AP_ALLOW_HOSTS="[::1]:3060,[::1]:3061"
     128 */
     129const AP_ALLOW_HOSTS = new Set(
     130  String(process.env.AP_ALLOW_HOSTS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean),
     131);
     132function isAllowedTestHost(u) {
     133  if (!AP_ALLOW_HOSTS.size) return false;
     134  return AP_ALLOW_HOSTS.has(u.host.toLowerCase());
     135}
    112136async function assertPublicHost(hostname) {
    113   if (net.isIP(hostname)) { if (isBlockedIp(hostname)) throw new Error('ssrf-blocked-ip'); return; }
    114   const addrs = await dns.promises.lookup(hostname, { all: true });
     137  // URL.hostname geeft een IPv6-literal MET blokhaken ("[::1]"), en net.isIP
     138  // herkent die vorm niet. Zonder strippen viel elk IPv6-adres door naar de
     139  // DNS-tak, waar het strandde op ENOTFOUND: geweigerd, maar per ongeluk en met
     140  // de verkeerde reden. isBlockedIp strippde ze al -- die verwachtte dus input
     141  // die hier nooit aankwam.
     142  const naakt = String(hostname || '').replace(/^\[|\]$/g, '');
     143  if (net.isIP(naakt)) { if (isBlockedIp(naakt)) throw new Error('ssrf-blocked-ip'); return; }
     144  const addrs = await dns.promises.lookup(naakt, { all: true });
    115145  if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
    116146}
     
    128158    const u = new URL(target); // throws on malformed → caller's catch
    129159    if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
    130     await assertPublicHost(u.hostname);
     160    // Alleen op de precieze host:poort uit AP_ALLOW_HOSTS, en per hop opnieuw:
     161    // een omleiding naar een ANDER intern adres blijft geweigerd.
     162    if (!isAllowedTestHost(u)) await assertPublicHost(u.hostname);
    131163    const r = await fetch(target, {
    132164      ...opts,
  • src/services/guardianship/handshake.js

    rea211b9 r29eb745  
    4444  if (type !== 'Undo') return null;
    4545  return parseRelationship(activity && activity.object);
     46}
     47
     48/**
     49 * De overgebleven guardians opnieuw vertellen of ZIJ nu de doorslag geven
     50 * (shaer-8vt, Barts correctie 8-8).
     51 *
     52 * "Doorslaggevend" is geen eigenschap van een moment maar van een STAND: zodra
     53 * er nog een stem nodig is, is iedereen die nog moet antwoorden het. Eenmalig
     54 * berekenen bij het doorsturen bevriest een antwoord dat verandert.
     55 *
     56 * Nooit dragend: lukt de update niet, dan blijft de oude waarde staan. Die is
     57 * dan te voorzichtig of te stil -- en juist daarom staat de FAALSTAND aan de
     58 * kant van waarschuwen (isDecisive leest onbekend als "ja, jij beslist").
     59 */
     60function herzieDoorslag(site, offerId, gsOffer, laatsteStem) {
     61  try {
     62    const p = gated.gatedProgress(site.slug, gsOffer.feature);
     63    if (!gated.isDecisive(p.votes, p.need)) return;   // nog niets veranderd
     64    const me = deps.selfId(site.slug);
     65    const gestemd = new Set([gsOffer.proposer, laatsteStem].filter(Boolean));
     66    for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
     67      if (gestemd.has(g)) continue;
     68      deps.deliverTo(site, g, {
     69        id: offerId, type: 'Offer', actor: me, to: [g],
     70        object: { type: 'shaer:GatedSetting', 'shaer:ward': me, 'shaer:feature': gsOffer.feature, 'shaer:value': !!gsOffer.value },
     71        'shaer:proposer': gsOffer.proposer || undefined,
     72        'shaer:decisive': true,
     73      }).catch(() => { /* de bezorgwachtrij probeert opnieuw */ });
     74    }
     75  } catch { /* nooit dragend */ }
    4676}
    4777
     
    537567    const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
    538568    if (r.state === 'settled') answerGatedProposer(site, offerId, r);
     569    // DOORSLAGGEVEND SCHUIFT MEE (Barts correctie, 8-8). Ik berekende dit een
     570    // keer bij het doorsturen en bevroor het. Bij vijf guardians staat er dan
     571    // "je beslist niets" -- en zodra er een ja bij komt IS elk van de anderen de
     572    // doorslag. Dat is precies de stille kant: het scherm zwijgt op het moment
     573    // dat het moet spreken.
     574    //
     575    // Dus na elke stem die het open laat: de overgeblevenen opnieuw vertellen
     576    // waar ze staan. Alleen wie NOG NIET geantwoord heeft, en alleen als het
     577    // antwoord verandert -- anders is dit een bericht per stem per guardian.
     578    else herzieDoorslag(site, offerId, gsOffer, actor);
    539579    notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
    540580    return true;
  • test/decisive-vote.test.js

    rea211b9 r29eb745  
    4747  assert.equal(gated.isDecisive(null, null), true);
    4848});
     49
     50test('doorslaggevend is een STAND, geen moment', () => {
     51  // Barts correctie (8-8): het geldt voor ALLE opvolgende beslissers. Bij vijf
     52  // guardians is bij het doorsturen niemand het (drempel 3, een stem), maar na
     53  // de tweede ja is iedereen die nog moet antwoorden het wel.
     54  const drempel = gated.thresholdFor(5);          // 3
     55  assert.equal(gated.isDecisive(1, drempel), false, 'bij het doorsturen nog niet');
     56  assert.equal(gated.isDecisive(2, drempel), true, 'na een ja erbij wel');
     57});
     58
     59test('en bij drie guardians is het meteen zo', () => {
     60  assert.equal(gated.isDecisive(1, gated.thresholdFor(3)), true);
     61});
  • tools/ward-farm/farm.mjs

    rea211b9 r29eb745  
    3333const HERE = path.dirname(fileURLToPath(import.meta.url));
    3434const STATE = path.join(HERE, 'state.json');
    35 const BASE = process.env.BASE || 'https://wards.klonkt.com';
     35// GEEN TLS, en dat hoort ook zo (Barts vraag, 8-8: "waarom via https? gewoon
     36// localhost ::1"). Een certificaat en een publieke hostname toevoegen aan een
     37// doos die ook andermans sites draait, om honderd neptestkinderen te kunnen
     38// bereiken, is de verkeerde prijs. Klonkt laat dit adres door via AP_ALLOW_HOSTS
     39// -- een precies host:poort-paar, geen "loopback mag"-vlag.
     40//
     41// EN HET TOETST IETS DAT WE TOCH MOESTEN KUNNEN: http met HTTP-signatures, zonder
     42// TLS. Dat is een echt federatiegeval (interne netwerken, onion), en tot nu toe
     43// was het nergens uitgeprobeerd.
     44const BASE = process.env.BASE || 'http://[::1]:3060';
    3645const PORT = Number(process.env.PORT || 3060);
    3746const N = Number(process.env.WARDS || 100);
     
    276285bewaarStaat(staat);
    277286
    278 server.listen(PORT, '127.0.0.1', () => {
     287server.listen(PORT, '::1', () => {
    279288  console.log(`[farm] ${N} wards op ${BASE} (luistert op 127.0.0.1:${PORT})`);
    280289});
  • tools/ward-farm/offer-many.mjs

    rea211b9 r29eb745  
    2525
    2626const slug = args.slug || 'dev';
    27 const farmBase = (args.base || 'https://wards.klonkt.com').replace(/\/+$/, '');
     27const farmBase = (args.base || 'http://[::1]:3060').replace(/\/+$/, '');
    2828const van = Number(args.from || 1);
    2929const tot = Number(args.to || 100);
Note: See TracChangeset for help on using the changeset viewer.