Index: src/services/ap-inbox.js
===================================================================
--- src/services/ap-inbox.js	(revision 9041d35439fa69447925bc2f601d627dd18d1d3b)
+++ src/services/ap-inbox.js	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
@@ -142,19 +142,64 @@
 // dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
 // vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
+//
+// DE SLEUTEL IS DE HELE BESCHERMING (shaer-qawr). Er zijn twee soorten
+// mislukking en ze zeggen iets heel verschillends:
+//
+//   TRANSPORTFOUT -- de note is niet op te halen. Dat is een eigenschap van de
+//   note zelf en geldt voor iedereen die hem doorstuurt, dus de objId alleen is
+//   de goede sleutel.
+//
+//   attributedTo-MISMATCH -- de bron zegt dat iemand ANDERS de auteur is. Dat
+//   zegt alles over de doorstuurder en niets over de note, dus die onthouden we
+//   per (note, beweerde actor).
+//
+// Met een enkele sleutel voor allebei was dit een censuurknop: neem de echte
+// note-URI van je slachtoffer, zet er je eigen actor op dezelfde host bij en
+// wijs naar een van onze publieke notes. De fetch slaagt, de mismatch volgt, en
+// die note-URI stond dertig minuten op de zwarte lijst -- waarna het ECHTE
+// doorgestuurde antwoord erop stukliep. Elke dertig minuten herhalen gaf
+// onbeperkte, gerichte onderdrukking van een specifiek antwoord, voor een
+// verzoek per keer. Nu raakt de leugenaar alleen zijn eigen ingang.
+//
+// Query en fragment tellen niet mee. Ze horen zelden bij de identiteit van een
+// note, en met een kale URL als sleutel waren ?x=1, ?x=2 enzovoort losse
+// ingangen: dan is de rem geen rem, want varieren kost niets. Zelfde reden dat
+// de host in kleine letters gaat.
+//
+// GEEN rem per HOST, hoe verleidelijk ook: wie een handvol niet-bestaande
+// URL's op een host laat mislukken zou daarmee die HELE host het zwijgen
+// opleggen. Dat is een grotere versie van precies de fout die hier gerepareerd
+// wordt.
 const _derefMiss = new Map();
 const DEREF_MISS_MS = 30 * 60 * 1000;
-function derefRecentlyFailed(uri) {
-  const t = _derefMiss.get(uri);
-  if (t === undefined) return false;
-  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
-  return true;
+const derefKey = (uri, claimedActor) => {
+  let basis = String(uri || '');
+  try { const u = new URL(basis); basis = `${u.protocol}//${u.host.toLowerCase()}${u.pathname}`; }
+  catch { /* onparseerbaar: de kale string is dan de sleutel */ }
+  // Een NUL-teken als scheiding, als escape geschreven en niet als byte: het
+  // kan in geen enkele URL staan, dus een actor-sleutel is nooit per ongeluk
+  // als note-sleutel te lezen.
+  return claimedActor ? `${basis}\u0000${claimedActor}` : basis;
+};
+function derefRecentlyFailed(uri, claimedActor) {
+  for (const k of [derefKey(uri), derefKey(uri, claimedActor)]) {
+    const t = _derefMiss.get(k);
+    if (t === undefined) continue;
+    if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(k); continue; }
+    return true;
+  }
+  return false;
 }
-function noteDerefFailure(uri) {
+function noteDerefFailure(uri, claimedActor) {
   if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
     const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
     for (const [k] of oud) _derefMiss.delete(k);
   }
-  _derefMiss.set(uri, Date.now());
+  _derefMiss.set(derefKey(uri, claimedActor), Date.now());
 }
+// Alleen voor de toets: de aanval speelt zich af in deze twee functies, en de
+// weg erheen (dereferenceForwarded) eist https en een echte fetch. De
+// dienstlaag exporteert ze niet, dus het uitvoeroppervlak blijft gelijk.
+export const _derefCacheForTests = { derefRecentlyFailed, noteDerefFailure };
 
 async function dereferenceForwarded(act, claimedActor, type, slugParam) {
@@ -184,5 +229,5 @@
     : null;
   if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
-  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
+  if (derefRecentlyFailed(objId, claimedActor)) return skipped('recent failure', objId);
   // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
   // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
@@ -207,5 +252,8 @@
   if (attributed !== claimedActor) {
     // Not a transport hiccup: the source itself says someone else wrote this.
-    noteDerefFailure(objId);
+    // Per (note, beweerde actor), nooit op de note alleen: dit zegt iets over
+    // DEZE doorstuurder, en op de note alleen was het een censuurknop op de
+    // note van een ander (shaer-qawr).
+    noteDerefFailure(objId, claimedActor);
     return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
   }
Index: test/deref-cache-poisoning.test.js
===================================================================
--- test/deref-cache-poisoning.test.js	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
+++ test/deref-cache-poisoning.test.js	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
@@ -0,0 +1,60 @@
+// De negatieve dereference-cache mocht geen censuurknop zijn (shaer-qawr).
+//
+// DE AANVAL. Neem de echte note-URI van je slachtoffer, zet je eigen actor op
+// dezelfde host als beweerde afzender, en wijs met inReplyTo naar een van onze
+// publieke notes. De fetch bij de bron SLAAGT, maar attributedTo is het
+// slachtoffer en niet jij: een mismatch. Werd die op de note-URI alleen
+// onthouden, dan stond die dertig minuten op de zwarte lijst en liep het ECHTE
+// doorgestuurde antwoord daarop stuk. Elke dertig minuten herhalen gaf
+// onbeperkte, gerichte onderdrukking van een specifiek antwoord.
+//
+// Op de cache getoetst en niet via handleInbox: de weg erheen eist https en een
+// echte fetch bij een vreemde host, en dat is in een toets geen bewijs maar een
+// netwerkafspraak. Deze twee functies ZIJN de kwetsbaarheid.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const { _derefCacheForTests } = await import('../src/services/ap-inbox.js');
+const { derefRecentlyFailed, noteDerefFailure } = _derefCacheForTests;
+
+const NOTE = 'https://mastodon.example/users/slachtoffer/statuses/1';
+const AANVALLER = 'https://mastodon.example/users/aanvaller';
+const DOORSTUURDER = 'https://mastodon.example/users/draadstarter';
+
+test('een uitgelokte mismatch blokkeert het echte antwoord niet', () => {
+  // De aanvaller lokt de mismatch uit op de note van het slachtoffer.
+  noteDerefFailure(NOTE, AANVALLER);
+
+  // Zijn eigen herhaling wordt wel geremd -- dat was het doel van de cache.
+  assert.equal(derefRecentlyFailed(NOTE, AANVALLER), true,
+    'dezelfde leugen nog eens kost geen tweede fetch');
+
+  // Maar het echte doorgestuurde antwoord, van de draadstarter, komt er langs.
+  assert.equal(derefRecentlyFailed(NOTE, DOORSTUURDER), false,
+    'een vreemde mag deze note niet voor een ander op slot zetten');
+});
+
+test('een transportfout geldt wel voor iedereen', () => {
+  // De note is niet op te halen. Dat is een eigenschap van de note zelf, dus
+  // die remt elke doorstuurder -- daar is de cache voor.
+  const stuk = 'https://mastodon.example/users/x/statuses/onbereikbaar';
+  noteDerefFailure(stuk);
+  assert.equal(derefRecentlyFailed(stuk, DOORSTUURDER), true);
+  assert.equal(derefRecentlyFailed(stuk, AANVALLER), true);
+});
+
+test('de rem remt: een andere query is dezelfde ingang', () => {
+  // Met een kale URL als sleutel waren ?x=1 en ?x=2 losse ingangen, en dan kost
+  // varieren niets en is er geen rem.
+  const basis = 'https://mastodon.example/users/y/statuses/9';
+  noteDerefFailure(`${basis}?x=1`);
+  assert.equal(derefRecentlyFailed(`${basis}?x=2`, DOORSTUURDER), true, 'query telt niet mee');
+  assert.equal(derefRecentlyFailed(`${basis}#frag`, DOORSTUURDER), true, 'fragment ook niet');
+  // De HOST wel, want dat is een ander adres en een andere partij.
+  assert.equal(derefRecentlyFailed('https://elders.example/users/y/statuses/9', DOORSTUURDER), false);
+  // Het PAD ook, anders zou een mislukking de buren meenemen.
+  assert.equal(derefRecentlyFailed('https://mastodon.example/users/y/statuses/10', DOORSTUURDER), false);
+});
