PageSourceSearch

https://ostpreussen.net/wp-content/plugins/einfach-ein-spamschutz/assets/pow.js?ver=0.37.1

js ostpreussen.net collected 2026-10-02 12:19:05 UTC 31,641 bytes, 832 lines download raw bytes

1/**
2 * EinfachEinSpamschutz — Browser-Proof-of-Work.
3 *
4 * Sucht alle Formulare mit dem versteckten Marker-Feld [data-eess-token]
5 * und holt eine Challenge ab. Sobald die PoW gelöst ist, wird das Token
6 * in das Hidden-Field geschrieben. Form-Submit wartet nicht aktiv —
7 * sollte der Nutzer schneller absenden, läuft die PoW im Hintergrund weiter
8 * und das Form wird falls nötig kurz pausiert (preventDefault + retry).
9 */
10(function () {
11	'use strict';
12
13	/** Wie oft darf der Server eine schwerere Challenge nachfordern? */
14	const MAX_ESKALATIONEN = 1;
15
16	/** Wie viele Re-Scans nach DOM-Änderungen je Seitenaufruf höchstens? */
17	const MAX_RESCANS = 20;
18
19	/** DOM-Änderungen erst zusammenfassen, dann einmal scannen. */
20	const RESCAN_VERZOEGERUNG_MS = 250;
21
22	/**
23	 * Sicherheitsabstand zur Token-Laufzeit.
24	 *
25	 * Ein gemerktes Token gilt uns schon als abgelaufen, bevor der Server es
26	 * ablehnt — sonst schriebe man dem Besucher genau an der Grenze eines
27	 * zurueck, das unterwegs verfaellt.
28	 */
29	const SICHERHEITSABSTAND_MS = 30000;
30
31	if (typeof window === 'undefined' || !window.crypto || !window.crypto.subtle) {
32		return;
33	}
34
35	/**
36	 * Konfiguration ermitteln — in dieser Reihenfolge:
37	 *
38	 *   1. data-Merkmale am eigenen <script>-Tag (statische Seiten)
39	 *   2. window.eessConfig (WordPress, via wp_localize_script)
40	 *   3. Vorgabe /eess-challenge.php
41	 *
42	 * WARUM NICHT MEHR NUR window.eessConfig: Das Objekt kam bisher aus einem
43	 * INLINE-Script, das die Seite vor pow.js ausgeben musste. Zwei Probleme:
44	 *
45	 *   a) Eine strenge Content-Security-Policy (script-src ohne
46	 *      'unsafe-inline') verbietet Inline-Scripte. Das Objekt bliebe dann
47	 *      undefiniert, pow.js stiege wortlos aus, kein Token — und unter einer
48	 *      Policy, die fehlende Tokens blockt, waere das Formular tot.
49	 *   b) Wer das Inline-Script beim Einbauen vergisst, bekommt denselben
50	 *      lautlosen Ausfall. Genau das war am 20.08.2026 auf zwei Seiten
51	 *      (heizung-sanitaer-schwab, grand-event-vogl) der Fall.
52	 *
53	 * Beides faellt weg, wenn die Konfiguration am Skript-Tag klebt und es
54	 * ausserdem eine brauchbare Vorgabe gibt. Eine statische Seite braucht jetzt
55	 * nur noch:
56	 *
57	 *     <script src="/assets/pow.js" defer></script>
58	 */
59	function leseKonfiguration() {
60		// Bei `defer` ist document.currentScript waehrend der Ausfuehrung gesetzt.
61		// Der Rueckfall auf die Suche im DOM deckt exotische Einbindungen ab.
62		const tag = document.currentScript
63			|| document.querySelector('script[data-eess-challenge]')
64			|| document.querySelector('script[src*="pow.js"]');
65		const daten = (tag && tag.dataset) ? tag.dataset : {};
66		const global = window.eessConfig || {};
67
68		return {
69			restUrl: daten.eessChallenge || global.restUrl || '/eess-challenge.php',
70			verifyUrl: daten.eessVerify || global.verifyUrl || '',
71		};
72	}
73
74	const config = leseKonfiguration();
75
76	function b64UrlEncodeBytes(bytes) {
77		let bin = '';
78		for (let i = 0; i < bytes.length; i++) {
79			bin += String.fromCharCode(bytes[i]);
80		}
81		return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
82	}
83
84	async function sha256Bytes(strBytes) {
85		const enc = new TextEncoder();
86		const buf = await window.crypto.subtle.digest('SHA-256', enc.encode(strBytes));
87		return new Uint8Array(buf);
88	}
89
90	function hasLeadingZeroBits(bytes, bits) {
91		const fullBytes = Math.floor(bits / 8);
92		const remaining = bits % 8;
93		for (let i = 0; i < fullBytes; i++) {
94			if (bytes[i] !== 0) {
95				return false;
96			}
97		}
98		if (remaining === 0) {
99			return true;
100		}
101		const mask = (0xff << (8 - remaining)) & 0xff;
102		return (bytes[fullBytes] & mask) === 0;
103	}
104
105	async function solvePow(salt, difficulty) {
106		let nonce = 0;
107		// Großzügig bemessen, damit auch eine erhöhte Schwierigkeit (Eskalation)
108		// nicht am Limit scheitert und ein leeres Token hinterlässt: difficulty 22
109		// braucht im Mittel ~4,2 Mio. Versuche — 5 Mio. war zu knapp.
110		const maxAttempts = 20_000_000;
111		while (nonce < maxAttempts) {
112			const nonceHex = nonce.toString(16);
113			const digest = await sha256Bytes(salt + nonceHex);
114			if (hasLeadingZeroBits(digest, difficulty)) {
115				return nonceHex;
116			}
117			nonce++;
118			if (nonce % 5000 === 0) {
119				// gib dem Hauptthread Luft
120				await new Promise((r) => setTimeout(r, 0));
121			}
122		}
123		throw new Error('eess_pow_no_solution');
124	}
125
126	async function fetchChallenge(vector) {
127		const fd = new FormData();
128		fd.append('vector', vector);
129		const resp = await fetch(config.restUrl, {
130			method: 'POST',
131			body: fd,
132			credentials: 'same-origin',
133		});
134		if (!resp.ok) {
135			throw new Error('eess_challenge_failed_' + resp.status);
136		}
137		return resp.json();
138	}
139
140	/**
141	 * Holt die Challenge SOFORT und merkt sie am Formular vor.
142	 *
143	 * Wichtig fürs Timing-Signal: Der Server stempelt den Render-Zeitstempel in
144	 * den Token, und der Verifier misst daran, wie schnell abgeschickt wurde.
145	 * Würde die Challenge erst beim Absenden geholt, läge dieser Zeitstempel
146	 * direkt am Submit — ein echter Mensch sähe dann aus wie ein Bot, der
147	 * sofort abschickt. Deshalb wird das Netzwerk-Holen (billig) früh erledigt
148	 * und nur das rechenintensive Lösen in den Idle-Zeitraum verschoben.
149	 */
150	function primeChallenge(form) {
151		if (form.eessChallenge) {
152			return form.eessChallenge;
153		}
154		const vectorField = form.querySelector('input[name="eess_vector"]');
155		const vector = vectorField ? vectorField.value : (form.dataset.eessVector || 'default');
156		form.eessChallenge = fetchChallenge(vector);
157		return form.eessChallenge;
158	}
159
160	/*
161	 * ────────────────────────────────────────────────────────────────────────
162	 * Ein einmal geloestes Token ueberlebt das Neurendern des Formulars.
163	 *
164	 * DAS PROBLEM (nachgestellt am 22.08.2026, betraf auch alle Vorversionen):
165	 * Formulare mit bedingter Logik — Formidable, Elementor-Popups, jedes
166	 * AJAX-Formular — ersetzen nach dem Laden Teile ihres eigenen Markups. Das
167	 * Token-Feld kommt dabei frisch vom Server, also mit value="". Das muehsam
168	 * geloeste Token ist weg.
169	 *
170	 * Zwei Faelle, beide gemessen:
171	 *   A) Nur der INHALT des <form> wird ersetzt. Das Element ueberlebt — und
172	 *      mit ihm `data-eess-attached`. processAllForms() ueberspringt das
173	 *      Formular deshalb, und das Token kommt NIE zurueck. Jede Einsendung
174	 *      wird ab da mit pow_invalid (+70) bewertet.
175	 *   B) Das ganze <form>-Element wird ersetzt. Dann greift der Beobachter,
176	 *      aber erst nach der Zusammenfassung — und holt eine ZWEITE Challenge,
177	 *      obwohl langst eine geloest ist.
178	 *
179	 * DIE LOESUNG: das geloeste Token je Vektor merken und sofort
180	 * zurueckschreiben — ohne Netz, ohne Rechnen, ohne Verzoegerung.
181	 *
182	 * ⚠️ Warum die Pruefung `tokenInVerwendung`: Zwei Formulare desselben
183	 * Vektors duerfen NICHT dasselbe Token tragen. Wuerden beide abgeschickt,
184	 * bekaeme das zweite `pow_replay` (+90) — aus einer Hilfe wuerde eine
185	 * Sperre. Steht das Token noch in einem Feld, ist es vergeben; dann wird
186	 * eine frische Challenge geholt.
187	 * ────────────────────────────────────────────────────────────────────────
188	 */
189	const geloest = new Map();
190
191	/** Wie viele geloeste Token je Vektor hoechstens vorgehalten werden. */
192	const MAX_GEMERKTE_TOKEN = 8;
193
194	/**
195	 * Ein geloestes Token merken — MIT Ablaufzeit.
196	 *
197	 * ⚠️ Ohne die Ablaufzeit koennte ein lange gemerktes Token zurueckgeschrieben
198	 * werden, das der Server als `token_expired` ablehnt. Das waere SCHLECHTER
199	 * als gar keines: Der Besucher bekaeme `pow_invalid` (+70), obwohl alles
200	 * funktioniert hat — nur zu spaet. Betrifft jede Seite, auf der ein Formular
201	 * lange offensteht und dann nachrendert.
202	 */
203	function merkeToken(vektor, token, gueltigBis) {
204		const liste = geloest.get(vektor) || [];
205		if (!liste.some(function (e) { return e.token === token; })) {
206			liste.push({ token: token, bis: gueltigBis });
207			while (liste.length > MAX_GEMERKTE_TOKEN) {
208				liste.shift();
209			}
210			geloest.set(vektor, liste);
211		}
212	}
213
214	/**
215	 * Gueltigkeitsende aus der Challenge-Antwort.
216	 *
217	 * ⚠️ KEINE Untergrenze auf die Laufzeit. Der erste Entwurf hatte
218	 * `Math.max(60, …)` — damit galt jedes Token mindestens 30 Sekunden, und die
219	 * Ablaufpruefung lief ins Leere. Der Testfall hat es gemeldet. Meldet der
220	 * Server eine sehr kurze Laufzeit, ist „gar nicht zurueckschreiben" die
221	 * richtige Antwort, nicht „trotzdem".
222	 */
223	function gueltigBisAus(ch) {
224		return Date.now() + Math.max(1, ch.ttl || 900) * 1000 - SICHERHEITSABSTAND_MS;
225	}
226
227	function tokenFeld(form) {
228		return form.querySelector('input[data-eess-token]');
229	}
230
231	function vektorVon(form) {
232		const feld = form.querySelector('input[name="eess_vector"]');
233		return feld ? feld.value : (form.dataset.eessVector || 'default');
234	}
235
236	function tokenInVerwendung(token) {
237		return Array.prototype.some.call(
238			document.querySelectorAll('input[data-eess-token]'),
239			function (f) { return f.value === token; }
240		);
241	}
242
243	/** Fehlt diesem Formular ein Token? */
244	function brauchtToken(form) {
245		const feld = tokenFeld(form);
246		return !!feld && feld.value === '';
247	}
248
249	/** Schreibt ein bereits geloestes Token zurueck. true = wiederhergestellt. */
250	function stelleWiederHer(form) {
251		const feld = tokenFeld(form);
252		if (!feld || feld.value !== '') {
253			return false;
254		}
255
256		/*
257		 * ⚠️ EINE LISTE, KEIN EINZELPLATZ.
258		 *
259		 * Bis 0.12.12 merkte sich pow.js GENAU EIN Token je Vektor. Stehen auf
260		 * einer Seite zwei Formulare desselben Vektors — auf tvb-gmbh.de der
261		 * Fall, erkennbar an ZWEI Challenge-Abrufen beim Seitenaufbau —, dann
262		 * ueberschrieb das zweite den Merker des ersten. Rendert danach das
263		 * erste neu, findet es nur noch das Token des zweiten; das ist aber
264		 * belegt, also verweigert `tokenInVerwendung` zu Recht die Herausgabe —
265		 * und das Feld blieb LEER. Fuer immer.
266		 *
267		 * Deshalb: alle geloesten Token des Vektors vorhalten und das erste
268		 * nehmen, das gerade niemand traegt. Die Belegt-Pruefung bleibt
269		 * unverzichtbar, sonst truegen zwei Formulare dasselbe Token und die
270		 * zweite Einsendung bekaeme `pow_replay` (+90).
271		 */
272		const liste = geloest.get(vektorVon(form)) || [];
273		const jetzt = Date.now();
274		for (let i = 0; i < liste.length; i++) {
275			if (liste[i].bis > jetzt && !tokenInVerwendung(liste[i].token)) {
276				feld.value = liste[i].token;
277				return true;
278			}
279		}
280		return false;
281	}
282
283	/**
284	 * Challenge anfordern und das Loesen einplanen.
285	 *
286	 * Die Anfrage geht SOFORT raus; nur das rechenintensive Loesen wartet auf
287	 * eine Leerlaufphase, damit der Seitenaufbau nicht ruckelt.
288	 */
289	/**
290	 * Ist der erste Durchlauf vorbei? Dann kommt jedes weitere Formular vom
291	 * Besucher — und der wartet.
292	 */
293	let seiteAufgebaut = false;
294
295	function holeUndLoese(form) {
296		primeChallenge(form);
297
298		/*
299		 * ⚠️ WARTEN ODER NICHT — das haengt davon ab, WANN das Formular kommt.
300		 *
301		 * Beim Seitenaufbau ist `requestIdleCallback` richtig: Das Rechnen darf
302		 * den Aufbau nicht ruckeln lassen, und niemand tippt zu diesem Zeitpunkt
303		 * schon.
304		 *
305		 * Erscheint ein Formular SPAETER, hat der Besucher es gerade selbst
306		 * geoeffnet — ein Popup, ein aufgeklappter Bereich. Dann zaehlt Tempo,
307		 * nicht Ruhe. Am 22.08.2026 auf tvb-gmbh.de gemessen: vom Klick auf
308		 * „Kontakt" bis zum fertigen Token vergingen 6 Sekunden, davon rund 1,5
309		 * allein im Warten auf eine Leerlaufphase — die auf einer
310		 * Elementor-Seite spaet kommt. Wer mit Autovervollstaendigung arbeitet,
311		 * ist schneller als das und sendet ohne Token.
312		 */
313		if (seiteAufgebaut) {
314			attachToken(form);
315			return;
316		}
317		if (window.requestIdleCallback) {
318			window.requestIdleCallback(function () { attachToken(form); }, { timeout: 1500 });
319		} else {
320			window.setTimeout(function () { attachToken(form); }, 200);
321		}
322	}
323
324	function stelleAlleWiederHer() {
325		// Ueber die TOKEN-FELDER gehen, nicht ueber `form[data-eess-attached]`:
326		// Wird das ganze <form>-Element ersetzt (Fall B), traegt das neue noch
327		// kein Merkmal — und bliebe sonst bis zum naechsten Vollscan leer.
328		document.querySelectorAll('input[data-eess-token]').forEach(function (feld) {
329			const form = feld.closest('form');
330			if (form) {
331				stelleWiederHer(form);
332			}
333		});
334	}
335
336	/**
337	 * Haelt das Token frisch, solange das Formular offen ist.
338	 *
339	 * ⚠️ WARUM DAS NOETIG IST: Ein Token wird beim Seitenaufbau geloest und
340	 * gilt dann eine feste Zeit. Wer laenger an seinem Text sitzt — in einem
341	 * Forum der Normalfall, nicht die Ausnahme — schickt beim Absenden ein
342	 * abgelaufenes Token: `token_expired` → `pow_invalid` (70) → Block. Der
343	 * Beitrag ist weg, und in der Sichtung sieht es aus wie ein abgewehrter
344	 * Bot. Man verliert also echte Leute, ohne es zu merken.
345	 *
346	 * Aufgefrischt wird bei 80 % der Restlaufzeit, fruehestens nach 30 s. Das
347	 * ist eine Anfrage je Laufzeit — bei einer Stunde also alle 48 Minuten.
348	 *
349	 * ⚠️ Das alte Token wird NICHT vorher geleert: Wuerde die Auffrischung
350	 * scheitern (Netz weg, Server antwortet nicht), stuende das Formular sonst
351	 * ohne Token da — schlechter als mit einem abgelaufenen. Erst wenn das neue
352	 * da ist, wird getauscht.
353	 */
354	function planeAuffrischung(form, gueltigBis) {
355		if (form.eessAuffrischung) {
356			clearTimeout(form.eessAuffrischung);
357			form.eessAuffrischung = null;
358		}
359		const restMs = gueltigBis - Date.now();
360		if (!isFinite(restMs) || restMs <= 0) {
361			return;
362		}
363		const wartezeit = Math.max(30000, Math.floor(restMs * 0.8));
364		form.eessAuffrischung = setTimeout(function () {
365			form.eessAuffrischung = null;
366			// Formular aus dem Dokument genommen? Dann gibt es nichts zu tun.
367			if (!document.body || !document.body.contains(form)) {
368				return;
369			}
370			const feld = tokenFeld(form);
371			if (!feld) {
372				return;
373			}
374			const altesToken = feld.value;
375			form.eessChallenge = null;   // frische Challenge erzwingen
376			feld.value = '';
377			attachToken(form).then(function () {
378				// Nichts bekommen? Dann lieber das alte zurueck als gar keins.
379				if (feld.value === '' && altesToken !== '') {
380					feld.value = altesToken;
381				}
382			});
383		}, wartezeit);
384	}
385
386	async function attachToken(form) {
387		const tokenField = form.querySelector('input[data-eess-token]');
388		if (!tokenField || tokenField.value !== '') {
389			return;
390		}
391
392		try {
393			const ch = await primeChallenge(form);
394			const nonceHex = await solvePow(ch.salt, ch.difficulty);
395			tokenField.value = ch.payload + '.' + ch.payload_hmac + '.' + nonceHex;
396			const bis = gueltigBisAus(ch);
397			merkeToken(vektorVon(form), tokenField.value, bis);
398			planeAuffrischung(form, bis);
399		} catch (err) {
400			// Bei Fehler: Token leer lassen — der Server entscheidet dann via Score.
401			// Beim nächsten Versuch neu holen, statt eine kaputte Zusage zu behalten.
402			form.eessChallenge = null;
403			if (window.console && window.console.warn) {
404				window.console.warn('eess pow', err);
405			}
406		}
407	}
408
409	/**
410	 * Versucht eine Verifikation gegen /eess/v1/verify und löst bei 202 (challenge)
411	 * automatisch eine zweite, schwerere PoW. Nützlich für eigene AJAX-Clients.
412	 * Gibt {decision, token, score} zurück.
413	 */
414	async function verifyWithEscalation(vector, token, extra = {}, stufe = 0) {
415		if (!config.verifyUrl) {
416			return { decision: 'allow', token, score: 0 };
417		}
418		// Der Kommentar sagte seit jeher "max. 1 Eskalation" — der Code kannte
419		// keine Grenze. Antwortet der Server wiederholt mit 202, rechnete der
420		// Browser endlos Proof of Work. Jetzt steht die Grenze auch da.
421		if (stufe > MAX_ESKALATIONEN) {
422			return { decision: 'block', token, score: 0 };
423		}
424		const fd = new FormData();
425		fd.append('vector', vector);
426		fd.append('token', token);
427		Object.entries(extra).forEach(([k, v]) => fd.append(k, v));
428
429		const resp = await fetch(config.verifyUrl, {
430			method: 'POST',
431			body: fd,
432			credentials: 'same-origin',
433		});
434
435		if (resp.status === 202) {
436			const body = await resp.json();
437			if (body.challenge) {
438				const nonceHex = await solvePow(body.challenge.salt, body.challenge.difficulty);
439				const newToken =
440					body.challenge.payload + '.' + body.challenge.payload_hmac + '.' + nonceHex;
441				// Rekursiver Re-Verify mit neuem Token.
442				return verifyWithEscalation(vector, newToken, extra, stufe + 1);
443			}
444		}
445
446		const body = await resp.json().catch(() => ({}));
447		return {
448			decision: body.decision || (resp.ok ? 'allow' : 'block'),
449			token,
450			score: body.score || 0,
451		};
452	}
453
454	/*
455	 * ════════════════════════════════════════════════════════════════════════
456	 * Öffentliche API für Clients OHNE Formular.
457	 *
458	 * Alles darüber setzt ein <form> mit einem versteckten Token-Feld voraus.
459	 * Ein Chat-Widget hat das nicht — es baut seine Oberfläche selbst und ruft
460	 * `admin-ajax.php` direkt. Bis 0.14.1 gab es für diesen Fall keine Stelle,
461	 * an der ein Token hätte entstehen können: `einfach-ein-chat` schickte
462	 * deshalb NIE eines, wurde als `pow_invalid` (70) bewertet und mit 429
463	 * abgewiesen — jede einzelne Anfrage, seit die Anbindung 1.3.0 existiert.
464	 *
465	 * ⚠️ Die Rechenlogik bleibt HIER. Sie gehört nicht in die aufrufenden
466	 * Plugins kopiert: Gleiches Verhalten überall, eine Stelle zum Ändern.
467	 *
468	 * Zwei Aufrufe:
469	 *
470	 *   window.eess.vorbereiten( 'chat_message' )
471	 *       Holt eine Challenge und löst sie im Hintergrund. Aufzurufen, sobald
472	 *       sich abzeichnet, dass gleich etwas abgeschickt wird — beim ersten
473	 *       Tastendruck etwa.
474	 *
475	 *   window.eess.holeToken( 'chat_message' ) → Promise<string|null>
476	 *       Liefert ein gelöstes Token und nimmt es aus dem Vorrat. `null`
477	 *       heisst „nicht zu haben" — der Aufrufer sendet dann ohne, und der
478	 *       Server entscheidet über den Score.
479	 *
480	 * ⚠️ WARUM VORRAT UND NICHT ERST BEIM ABSENDEN: Der Server stempelt den
481	 * Ausstellungszeitpunkt HMAC-signiert in das Token, und der Verifier misst
482	 * daran, wie schnell abgeschickt wurde. Wer die Challenge erst im Moment des
483	 * Absendens holt, erzeugt `submit_too_fast` (30) — das liegt genau auf
484	 * `allow_max` und geht gerade noch durch, kippt aber mit jedem weiteren
485	 * Signal in die Sperre.
486	 *
487	 * ⚠️ WARUM EIN EIGENER VORRAT und nicht `geloest`: Aus jenem Vorrat bedient
488	 * sich `stelleWiederHer()` für Formulare, und `tokenInVerwendung()` prüft
489	 * dafür die DOM-Felder. Ein an einen AJAX-Client herausgegebenes Token steht
490	 * in keinem Feld — es sähe für ein Formular also frei aus. Beide schickten
491	 * dasselbe Token, das zweite bekäme `token_replay` (90). Deshalb getrennt
492	 * und beim Herausgeben SOFORT entnommen.
493	 * ════════════════════════════════════════════════════════════════════════
494	 */
495
496	/** Gelöste Token je Vektor für Clients ohne Formular. */
497	const ajaxVorrat = new Map();
498
499	/** Laufende Nachfüll-Vorgänge je Vektor — verhindert doppelte 
499Challenges. */
500	const ajaxNachschub = new Map();
501
502	/** Wie viele gelöste Token je Vektor vorgehalten werden. */
503	const AJAX_VORRAT_ZIEL = 1;
504
505	/**
506	 * Mindestalter eines herausgegebenen Tokens.
507	 *
508	 * ⚠️ WARUM GEWARTET WIRD. Der Server misst am signierten Ausstellungs-
509	 * zeitpunkt, wie schnell abgeschickt wurde; unter 1,5 Sekunden feuert
510	 * `submit_too_fast` (30). Allein sperrt das nichts — zusammen mit
511	 * `rate_limit_hit` (40) sind es 70 und damit ein Block.
512	 *
513	 * Genau das ist am 26.08.2026 gemessen worden: Von zehn Nachrichten in
514	 * zwanzig Sekunden wurde eine abgewiesen, weil ihr Token gerade eben erst
515	 * nachgefüllt worden war —
516	 *     block 70 {"submit_too_fast":30,"rate_limit_hit":40}
517	 *
518	 * Das ist ein Fehlalarm gegen einen echten Menschen, der schnell tippt. Die
519	 * Antwort darauf ist NICHT, die Schwelle zu senken — die Regel bleibt, wie
520	 * sie ist. Wir geben das Token nur nicht heraus, bevor es alt genug ist. Der
521	 * Normalfall wartet dabei nie: Der Vorrat wird bei der ersten Berührung des
522	 * Widgets gefüllt und ist längst reif, wenn jemand zu Ende getippt hat.
523	 */
524	const MINDESTALTER_MS = 1800;
525
526	function warte(ms) {
527		return ms > 0 ? new Promise(function (r) { window.setTimeout(r, ms); }) : Promise.resolve();
528	}
529
530	/** Wartet, bis das Token das Mindestalter erreicht hat. */
531	function reifenLassen(eintrag) {
532		return warte(MINDESTALTER_MS - (Date.now() - eintrag.ab)).then(function () { return eintrag.token; });
533	}
534
535	function ajaxAufraeumen(vektor) {
536		const jetzt = Date.now();
537		const liste = (ajaxVorrat.get(vektor) || []).filter(function (e) { return e.bis > jetzt; });
538		ajaxVorrat.set(vektor, liste);
539		return liste;
540	}
541
542	async function ajaxLoesen(vektor) {
543		const ch = await fetchChallenge(vektor);
544		// NACH dem Abruf stempeln, nicht davor: Der Wert soll nie FRÜHER liegen
545		// als der Zeitstempel im Token, sonst wartet `reifenLassen` zu kurz.
546		const ab = Date.now();
547		const nonceHex = await solvePow(ch.salt, ch.difficulty);
548		return {
549			token: ch.payload + '.' + ch.payload_hmac + '.' + nonceHex,
550			bis: gueltigBisAus(ch),
551			ab: ab,
552		};
553	}
554
555	function ajaxNachfuellen(vektor) {
556		if (ajaxNachschub.has(vektor)) {
557			return ajaxNachschub.get(vektor);
558		}
559		if (ajaxAufraeumen(vektor).length >= AJAX_VORRAT_ZIEL) {
560			return Promise.resolve();
561		}
562
563		const lauf = ajaxLoesen(vektor)
564			.then(function (eintrag) {
565				ajaxAufraeumen(vektor).push(eintrag);
566			})
567			.catch(function (err) {
568				if (window.console && window.console.warn) {
569					window.console.warn('eess vorbereiten', err);
570				}
571			})
572			.finally(function () {
573				ajaxNachschub.delete(vektor);
574			});
575
576		ajaxNachschub.set(vektor, lauf);
577		return lauf;
578	}
579
580	/**
581	 * Den Vorrat eines Vektors wegwerfen.
582	 *
583	 * ⚠️ NÖTIG NACH EINER ABWEISUNG. Wurde der Client zwischenzeitlich auf eine
584	 * höhere Schwierigkeit hochgestuft (siehe WordPress\PowEscalation), ist ein
585	 * vorher gelöstes Token zu leicht — der Server antwortet mit
586	 * `pow_difficulty_too_low`, und das zählt als `pow_invalid` (70). Ein
587	 * gebunkertes Token wäre dann SCHLECHTER als gar keines, weil der Aufrufer
588	 * es beim nächsten Versuch wieder anböte und wieder scheiterte.
589	 *
590	 * Am 26.08.2026 beim Anbinden des Chats gemessen: zwei abgewiesene Anfragen
591	 * hoben die Schwierigkeit, die dritte trug das alte Vorrats-Token und wurde
592	 * mit 70 bewertet, obwohl sie sauber gelöst war.
593	 */
594	function vorratVerwerfen(vektor) {
595		ajaxVorrat.delete(vektor);
596	}
597
598	/**
599	 * @param {string}  vektor
600	 * @param {object}  [optionen]
601	 * @param {boolean} [optionen.frisch] Vorrat übergehen und neu lösen. Für den
602	 *        zweiten Versuch nach einer Abweisung: Er soll die inzwischen
603	 *        geforderte Schwierigkeit tatsächlich bezahlen.
604	 */
605	async function holeToken(vektor, optionen) {
606		if (!vektor) {
607			return null;
608		}
609
610		if (optionen && optionen.frisch) {
611			vorratVerwerfen(vektor);
612			try {
613				const eintrag = await ajaxLoesen(vektor);
614				return await reifenLassen(eintrag);
615			} catch (err) {
616				if (window.console && window.console.warn) {
617					window.console.warn('eess holeToken frisch', err);
618				}
619				return null;
620			}
621		}
622
623		// Erst der Vorrat — der trägt den älteren, also unverdächtigen Zeitstempel.
624		const liste = ajaxAufraeumen(vektor);
625		if (liste.length > 0) {
626			const eintrag = liste.shift();
627			// Sofort wieder auffüllen, damit der nächste Griff ebenfalls trifft.
628			ajaxNachfuellen(vektor);
629			return await reifenLassen(eintrag);
630		}
631
632		// Nichts da: jetzt lösen.
633		try {
634			const eintrag = await ajaxLoesen(vektor);
635			return await reifenLassen(eintrag);
636		} catch (err) {
637			if (window.console && window.console.warn) {
638				window.console.warn('eess holeToken', err);
639			}
640			return null;
641		}
642	}
643
644	// Öffentliche API für eigene AJAX-Clients.
645	window.eessPow = window.eessPow || {};
646	window.eessPow.solve = solvePow;
647	window.eessPow.fetchChallenge = fetchChallenge;
648	window.eessPow.verifyWithEscalation = verifyWithEscalation;
649
650	window.eess = window.eess || {};
651	window.eess.holeToken = holeToken;
652	window.eess.vorbereiten = function (vektor) { return ajaxNachfuellen(vektor); };
653	window.eess.vorratVerwerfen = vorratVerwerfen;
654
655	function processAllForms() {
656		document.querySelectorAll('form').forEach(function (form) {
657			if (!form.querySelector('input[data-eess-token]')) {
658				return;
659			}
660			if (form.dataset.eessAttached) {
661				/*
662				 * „Angehaengt" heisst NICHT „hat ein Token".
663				 *
664				 * Zwei Wege fuehren hierher mit leerem Feld:
665				 *   - Nur der Inhalt des Formulars wurde ersetzt (Fall A). Dann
666				 *     genuegt das Zurueckschreiben des gemerkten Tokens.
667				 *   - Das Formular kam als KLON zurueck (Elementor-Popup, Fall E):
668				 *     `data-eess-attached` ist ein echtes Attribut und ueberlebt
669				 *     das Serialisieren, der Token-Wert als JS-Eigenschaft nicht.
670				 *     Dann ist nichts zu retten — es braucht eine frische
671				 *     Challenge.
672				 *
673				 * ⚠️ Bis 0.12.13 stand hier nur `stelleWiederHer(form); return;`.
674				 * Schlug das Zurueckschreiben fehl, stieg der Code wortlos aus und
675				 * holte NIE wieder eine Challenge. Auf tvb-gmbh.de blieb das
676				 * Kontaktformular dadurch dauerhaft ohne Token;
676 jede Einsendung
677				 * bekam pow_invalid (+70). Im echten Browser nachgemessen.
678				 */
679				if (brauchtToken(form) && !stelleWiederHer(form)) {
680					// Eine mitgeschleppte Zusage waere verbraucht — verwerfen.
681					form.eessChallenge = null;
682					holeUndLoese(form);
683				}
684				return;
685			}
686			form.dataset.eessAttached = '1';
687
688			/*
689			 * Haben wir fuer diesen Vektor schon ein geloestes Token, das gerade
690			 * niemand traegt, dann gehoert es hierher — keine zweite Challenge
691			 * fuer dieselbe Sache. Das ist der Normalfall, wenn ein Formular
692			 * komplett neu eingehaengt wurde (Fall B).
693			 *
694			 * ⚠️ Der Kommentar hier behauptete bis 0.12.11, primeChallenge
695			 * „stempelt den Render-Zeitstempel frueh". Das stimmt nicht:
696			 * `eess_render_ts` kommt ausschliesslich vom Server, pow.js fasst es
697			 * nirgends an.
698			 */
699			// ⚠️ Reihenfolge beachten: stelleWiederHer() meldet auch dann false,
700			// wenn das Feld laengst gefuellt ist. Ohne brauchtToken() holte
701			// Fall B deshalb eine zweite Challenge fuer ein Token, das schon
702			// im Feld stand (gemessen am 22.08.2026).
703			if (brauchtToken(form) && !stelleWiederHer(form)) {
704				holeUndLoese(form);
705			}
706
707			// AJAX-Formulare von Formidable Pro (frm_ajax_submit) NICHT anfassen.
708			// Der frühere Capture-Phase-Eingriff (preventDefault + requestSubmit,
709			// um ein noch fehlendes Token nachzuziehen) kollidiert mit Formidables
710			// eigenem AJAX-Submit: Es entstanden zwei Einsendungen und ein
711			// Seiten-Reload ohne Rückmeldung (auf tvb live beobachtet). Hier genügt
712			// das Vorab-Füllen oben; steht das Token beim Absenden doch nicht, wird
713			// es serverseitig tolerant bewertet (Vektor formidable: allow_max).
714			if ( form.classList && form.classList.contains( 'frm_ajax_submit' ) ) {
715				/*
716				 * Kein preventDefault, kein requestSubmit — genau das erzeugte
717				 * frueher zwei Einsendungen und einen Reload ohne Rueckmeldung.
718				 * Aber ein geloestes Token duerfen wir hier synchron
719				 * zurueckschreiben: Die Capture-Phase laeuft VOR Formidables
720				 * eigenem Handler, der das Formular erst danach einsammelt.
721				 * Nichts wird angehalten, nichts doppelt abgeschickt — im
722				 * schlechtesten Fall aendert sich gar nichts.
723				 */
724				form.addEventListener('submit', function () { stelleWiederHer(form); }, true);
725				return;
726			}
727
728			// Sicherheitsnetz beim Absenden, falls der Token noch nicht steht.
729			//
730			// CAPTURE-Phase (dritter Parameter true): klassische (nicht-AJAX)
731			// Formulare, die per normalem POST abschicken. Fehlt der Token, halten
732			// wir diese Submit-Runde an, ziehen den Token nach und lösen den Submit
733			// erneut aus.
734			form.addEventListener(
735				'submit',
736				function (ev) {
737					const tokenField = form.querySelector('input[data-eess-token]');
738					// Token vorhanden ODER bereits einmal nachgezogen → normal weiterlaufen
739					// lassen (Formidable/nativer Submit übernimmt). eessSubmitting
740					// verhindert eine Endlosschleife, falls das Nachziehen scheitert —
741					// dann geht der Submit mit leerem Token durch und der Server bewertet.
742					if (!tokenField || tokenField.value !== '' || form.dataset.eessSubmitting === '1') {
743						return;
744					}
745					// Haben wir eins auf Lager, ist das Anhalten unnoetig.
746					if (stelleWiederHer(form)) {
747						return;
748					}
749					ev.preventDefault();
750					ev.stopImmediatePropagation();
751					form.dataset.eessSubmitting = '1';
752					const submitter = ev.submitter || null;
753					attachToken(form).then(function () {
754						if (typeof form.requestSubmit === 'function') {
755							form.requestSubmit(submitter);
756						} else {
757							form.submit();
758						}
759					});
760				},
761				true
762			);
763		});
764	}
765
766	function ersterDurchlauf() {
767		processAllForms();
768		seiteAufgebaut = true;
769	}
770
771	if (document.readyState === 'loading') {
772		document.addEventListener('DOMContentLoaded', ersterDurchlauf);
773	} else {
774		ersterDurchlauf();
775	}
776
777	/*
778	 * Re-Scan bei dynamisch eingefügten Formularen (Elementor/Formidable AJAX).
779	 *
780	 * ⚠️ Bis 0.12.9 lief processAllForms() bei JEDER DOM-Änderung, und das ist
781	 * jedes Mal ein querySelectorAll('form') über die ganze Seite. Auf einer
782	 * Seite mit Karussell, Chat-Widget oder Popup ist das Dauerlast — und
783	 * schlimmer: Wird ein Formular neu eingehängt, fällt sein
784	 * `dataset.eessAttached` weg und es wird eine NEUE Challenge geholt. Ein
785	 * einzelner echter Besucher konnte so sein Kontingent von 60 Challenges pro
786	 * Minute verbrennen, bekam 429, hatte kein Token — und wurde als
787	 * `pow_invalid` bewertet. Ein Fehlalarm, den niemand auf das Karussell
788	 * zurückgeführt hätte.
789	 *
790	 * Deshalb: zusammenfassen statt sofort reagieren, und eine Obergrenze für
791	 * die Zahl der Durchläufe je Seitenaufruf.
792	 */
793	let rescanTimer = null;
794	let rescans = 0;
795	let herstellGeplant = false;
796
797	const observer = new MutationObserver(function () {
798		/*
799		 * ZUERST das Billige, und zwar SOFORT: ein bereits geloestes Token
800		 * zurueckschreiben. Das kostet keine Anfrage und kein Rechnen, nur eine
801		 * gezielte Abfrage auf `form[data-eess-attached]`. Es darf NICHT hinter
802		 * der Zusammenfassung liegen — wer direkt nach der letzten Auswahl auf
803		 * „Senden" klickt, schickte sonst ein leeres Feld ab. Ein Bild pro
804		 * Bildwiederholung genuegt und deckelt die Last bei Mutationsstuermen.
805		 *
806		 * Diese Wiederherstellung steht bewusst AUSSERHALB von MAX_RESCANS: Der
807		 * Deckel schuetzt vor unnoetigen Challenges, nicht vor dem Zurueckgeben
808		 * dessen, was wir schon haben.
809		 */
810		if (!herstellGeplant) {
811			herstellGeplant = true;
812			window.requestAnimationFrame(function () {
813				herstellGeplant = false;
814				stelleAlleWiederHer();
815			});
816		}
817
818		// DANACH das Teure: nach wirklich neuen Formularen suchen.
819		if (rescans >= MAX_RESCANS) {
820			return;
821		}
822		if (rescanTimer !== null) {
823			return;
824		}
825		rescanTimer = window.setTimeout(function () {
826			rescanTimer = null;
827			rescans++;
828			processAllForms();
829		}, RESCAN_VERZOEGERUNG_MS);
830	});
831	observer.observe(document.body, { childList: true, subtree: true });
832})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.