Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 434ba41da181445d9dd2c3d197b724f2f1089d68)
+++ src/services/ActivityPubService.js	(revision cec72bc498d962d80de014ddcb4f97072658dee3)
@@ -1687,4 +1687,42 @@
  *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
  */
+/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
+ *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
+ *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
+ *  doorstuurt; iets anders is dat niet. */
+function knownNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return false;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  try {
+    if (base && uri.startsWith(`${base}/ap/notes/`)) {
+      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
+      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
+      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
+    }
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
+    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
+  } catch { /* bij twijfel niet ophalen */ }
+  return false;
+}
+
+// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
+// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
+// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
+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;
+}
+function noteDerefFailure(uri) {
+  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());
+}
+
 async function dereferenceForwarded(act, claimedActor, type, slugParam) {
   if (type !== 'Create' && type !== 'Update') return null;
@@ -1695,12 +1733,32 @@
     if (new URL(objId).host !== new URL(claimedActor).host) return null;   // ankereis
   } catch { return null; }
-  // Ondertekend als de ontvangende site wanneer we die kennen; anders anoniem,
-  // wat voor een publieke note volstaat. Beide gaan door safeFetch.
-  const fetched = await signedGetJson(slugParam, objId).catch(() => null);
-  if (!fetched || fetched.id !== objId) return null;   // ook: geen omleiding naar iets anders
-  const attributed = typeof fetched.attributedTo === 'string'
+  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
+  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
+  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
+  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
+  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
+  // niets aan legitiem verkeer waarvan we de ouder kennen.
+  const parent = typeof o === 'object' && o
+    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
+    : null;
+  if (!knownNoteUri(parent)) {
+    console.log('[AP] inbox forwarded, skipped (unknown inReplyTo):', claimedActor, parent || '(none)');
+    return null;
+  }
+  if (derefRecentlyFailed(objId)) return null;
+  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
+  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
+  // reden als bij fetchActor sinds efe5633.
+  let fetched = await apGetJson(objId).catch(() => null);
+  if ((!fetched || fetched.id !== objId) && slugParam) {
+    fetched = await signedGetJson(slugParam, objId).catch(() => null);
+  }
+  const attributed = fetched && (typeof fetched.attributedTo === 'string'
     ? fetched.attributedTo
-    : (fetched.attributedTo && fetched.attributedTo.id);
-  if (attributed !== claimedActor) return null;
+    : (fetched.attributedTo && fetched.attributedTo.id));
+  if (!fetched || fetched.id !== objId || attributed !== claimedActor) {
+    noteDerefFailure(objId);
+    return null;
+  }
   return fetched;
 }
Index: test/forwarded-activities.test.js
===================================================================
--- test/forwarded-activities.test.js	(revision 434ba41da181445d9dd2c3d197b724f2f1089d68)
+++ test/forwarded-activities.test.js	(revision cec72bc498d962d80de014ddcb4f97072658dee3)
@@ -36,7 +36,7 @@
 let opgehaald = [];
 const echteFetch = globalThis.fetch;
-globalThis.fetch = async (url, opts) => {
+globalThis.fetch = async (url, opts = {}) => {
   const u = String(url);
-  opgehaald.push(u);
+  opgehaald.push({ url: u, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
   if (u === NOTE_ID && bron) {
     return new Response(JSON.stringify(bron), { status: 200, headers: { 'content-type': 'application/activity+json' } });
@@ -44,4 +44,10 @@
   return new Response('not found', { status: 404 });
 };
+
+// Een note die WIJ kennen: doorsturen gebeurt omdat wij in de thread zitten, en
+// sinds shaer-drf dereferencen we alleen als inReplyTo daarheen wijst.
+const ONZE_NOTE = 'https://203.0.113.10/notes/van-ons';
+db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+            VALUES (?,?,?,?,?,?)`).run(ONZE_NOTE, 'me', AUTEUR, 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
 
 /** Een doorgestuurde Create: ondertekend door de doorstuurder, geschreven door de auteur. */
@@ -51,5 +57,5 @@
     actor: AUTEUR,
     object: objectOverride !== undefined ? objectOverride : {
-      id: NOTE_ID, type: 'Note', attributedTo: AUTEUR,
+      id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE,
       content: '<p>BEZORGDE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
     },
@@ -65,5 +71,5 @@
   const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
   assert.notEqual(status, 401, 'een doorgestuurde reactie hoort niet meer geweigerd te worden');
-  assert.ok(opgehaald.includes(NOTE_ID), 'het object hoort bij de bron opgehaald te zijn');
+  assert.ok(opgehaald.some((v) => v.url === NOTE_ID), 'het object hoort bij de bron opgehaald te zijn');
   // Wat telt: de OPGEHAALDE inhoud wordt gebruikt, niet wat de doorstuurder gaf.
   assert.match(act.object.content, /ECHTE versie/);
@@ -75,5 +81,5 @@
   // is de waarheid; de payload wordt weggegooid.
   bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
-  const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>KOOP MIJN MUNTEN</p>' });
+  const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>KOOP MIJN MUNTEN</p>' });
   await AP.handleInbox(req(act), 'me', alsDoorstuurder);
   assert.doesNotMatch(act.object.content, /MUNTEN/);
@@ -83,6 +89,6 @@
   // Zonder deze ankereis wijst een doorsturer je naar een host die hij zelf
   // beheert, waar attributedTo alles kan beweren.
-  bron = { id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
-  const act = doorgestuurd({ id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' });
+  bron = { id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' };
+  const act = doorgestuurd({ id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
   const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
   assert.equal(status, 401);
@@ -116,5 +122,5 @@
   const status = await AP.handleInbox({ body: doorgestuurd(), headers: {}, ip: '203.0.113.9' }, 'me', null);
   assert.equal(status, 401);
-  assert.equal(opgehaald.includes(NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
 });
 
@@ -127,3 +133,69 @@
 });
 
+test('een antwoord op iets dat we NIET kennen wordt niet opgehaald', async () => {
+  // De vernauwing uit shaer-drf. Zonder deze eis zijn claimedActor en object.id
+  // allebei door de aanvaller gekozen en eist het host-anker alleen dat ze aan
+  // elkaar gelijk zijn -- dan kan iedereen met een werkende actor ons naar elke
+  // URL sturen.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const act = doorgestuurd({
+    id: NOTE_ID, type: 'Note', attributedTo: AUTEUR,
+    inReplyTo: 'https://203.0.113.10/notes/kennen-we-niet', content: '<p>x</p>',
+  });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
+});
+
+test('zonder inReplyTo wordt er niets opgehaald', async () => {
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' });
+  assert.equal(await AP.handleInbox(req(act), 'me', alsDoorstuurder), 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false);
+});
+
+test('de dereference haalt ONBETEKEND op als dat volstaat', async () => {
+  // Anders kan een ander ons een ondertekend verzoek naar een adres van zijn
+  // keuze laten sturen, met onze identiteit eronder.
+  //
+  // Eigen id: NOTE_ID is in een eerdere test mislukt en zit dus in de negatieve
+  // cache -- die zou deze poging overslaan. Dat de test daarop stukliep, is het
+  // bewijs dat de cache doet wat hij moet.
+  const VERS = 'https://203.0.113.10/notes/vers';
+  const stubOrig = globalThis.fetch;
+  globalThis.fetch = async (url, opts = {}) => {
+    if (String(url) === VERS) {
+      opgehaald.push({ url: VERS, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
+      return new Response(JSON.stringify({ id: VERS, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' }),
+        { status: 200, headers: { 'content-type': 'application/activity+json' } });
+    }
+    return stubOrig(url, opts);
+  };
+  opgehaald = [];
+  const act = doorgestuurd({ id: VERS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>y</p>' });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  globalThis.fetch = stubOrig;
+  assert.notEqual(status, 401);
+  const pogingen = opgehaald.filter((v) => v.url === VERS);
+  assert.equal(pogingen.length, 1, 'één poging');
+  assert.equal(pogingen[0].ondertekend, false, 'en die was onbetekend');
+});
+
+test('een mislukte poging wordt onthouden, zodat een retry hem niet herhaalt', async () => {
+  // Mastodon herhaalt dagenlang; zonder cache doet elke herhaling de fetch
+  // opnieuw. De tweede bezorging hoort geen tweede fetch op te leveren.
+  const MIS = 'https://203.0.113.10/notes/mislukt';
+  bron = null;   // de stub geeft 404
+  const act = () => doorgestuurd({ id: MIS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
+  opgehaald = [];
+  assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
+  const na1 = opgehaald.filter((v) => v.url === MIS).length;
+  assert.ok(na1 >= 1, 'de eerste poging haalt wel op');
+  opgehaald = [];
+  assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
+  assert.equal(opgehaald.filter((v) => v.url === MIS).length, 0, 'de tweede niet meer');
+});
+
 test.after(() => { globalThis.fetch = echteFetch; });
