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.