/* ===================================================================== DER WEG NACH DRAUSSEN ZU TIKTOK — gemeinsam genutzt (07.10.2026) Diese Datei begann als ein VERSCHIEBEN, keine Neuerfindung. `istTikTok` und `holeMitFrist` standen bis heute in `workspace-video.js` und haben sich dort bewährt — inklusive der Reparatur vom 05.10.2026, bei der auffiel, dass die Frist nur den Anfang der Anfrage bewachte. Ab jetzt holt auch die Kampagnen-Kachel eine Seite von TikTok. Zwei Fassungen derselben Regel wären der Fehler, den CLAUDE.md am 11.09.2026 beschreibt: eine abgeschriebene Liste, die auseinander- läuft. Gerade bei `istTikTok` wäre das gefährlich — es ist die eine Stelle, die entscheidet, welchen fremden Rechner unser Server überhaupt anfragt. Die zweite Fassung würde beim nächsten Fund vergessen. --------------------------------------------------------------------- WAS SICH BEIM VERSCHIEBEN GEÄNDERT HAT — und nur das 1. `holeMitFrist` nimmt die Frist jetzt als Zusatz entgegen, mit 8000 ms als Vorgabe. Das Video bleibt damit Zeichen für Zeichen beim alten Verhalten; die Kampagnenseite braucht mehr (1,1 MB statt einer kleinen Auskunft) und sagt das am Aufrufort, nicht über eine zweite Konstante irgendwo. 2. `istTikTok` erkennt die Prüfumgebung jetzt an BEIDEN Umstellschaltern — `TIKTOK_OEMBED_BASIS` (Video) und `TIKTOK_KAMPAGNE_BASIS` (Kampagne). Ohne das zweite müsste die Kampagnenprüfung die echte Regel umgehen und prüfte damit eine andere als die, die im Betrieb gilt. --------------------------------------------------------------------- KEINE DATENBANK, KEIN EXPRESS Absichtlich: Diese Datei lässt sich in einer Prüfung einzeln einbinden, ohne dass ein Server hochfährt. Dasselbe Argument wie bei `helfer-tag.mjs`. ===================================================================== */ /* GRENZEN FÜR DEN WEG NACH DRAUSSEN. Ein fremder Dienst, der nicht antwortet, darf unsere Anfrage nicht mit sich ziehen -- genau daran ist in diesem Haus schon ein ganzer Pruefdurchlauf drei Stunden lang haengengeblieben. */ export const FRIST_MS = 8000; /** Ist das ueberhaupt eine TikTok-Adresse? Keine Zeichenkettensuche, * sondern der Rechnername -- "tiktok.com.beispiel.de" enthaelt * "tiktok.com" und gehoert jemand anderem. */ export function istTikTok(roh) { try { const u = new URL(String(roh)); const h = u.hostname.toLowerCase(); /* NUR IN EINER PRUEFUNG: Dann liegt der nachgebaute Dienst auf 127.0.0.1, und die Adresse des "Videos" auch. Ohne diese Zeile muesste die Pruefung die echte Regel umgehen -- und pruefte damit eine andere als die, die im Betrieb gilt. */ if ((process.env.TIKTOK_OEMBED_BASIS || process.env.TIKTOK_KAMPAGNE_BASIS) && (h === "127.0.0.1" || h === "localhost")) { return true; } if (u.protocol !== "https:") return false; return h === "tiktok.com" || h.endsWith(".tiktok.com"); } catch { return false; } } /* ===================================================================== DER WEG ZU EINER GANZEN SEITE (07.10.2026) — Etappe 2 Bis hierher holte dieses Haus bei TikTok nur kleine Auskuenfte: eine JSON-Antwort zu einem Video, ein Vorschaubild. Eine Kampagnenseite ist etwas anderes -- 1,1 MB serverseitig gerendertes HTML, und die Adresse dahin kommt aus einem Text, den ein Mensch eingefuegt hat. DAS IST DIE EINZIGE STELLE IM GANZEN HAUS, an der unser Server eine FREMDBESTIMMTE Adresse abruft. Ueberall sonst steht das Ziel im Quelltext. Deshalb stehen hier drei Grenzen, und jede hat einen gemessenen Grund: 1. JEDE STATION EINZELN. holeMitFrist folgt Umleitungen selbst (redirect "follow"). Das heisst: istTikTok() hat nur die EINGABE gesehen, und wohin der Kurzlink wirklich fuehrt, weiss niemand. Hier wird deshalb Sprung fuer Sprung gegangen (redirect "manual", hoechstens fuenf), und JEDE Zwischenadresse geht durch istTikTok(). Verlaesst die Kette TikTok, bricht sie ab und sagt, wohin. 2. ZWOELF SEKUNDEN, NICHT ACHT. Gemessen am 07.10.2026: allein 750 ms beim Ursprungsserver, dazu die Uebertragung von 1,1 MB. Acht Sekunden reichen an einem langsamen Nachmittag nicht, und eine Frist, die im Normalfall zuschlaegt, ist keine Grenze, sondern ein Fehler. 3. DREI MEGABYTE, GEZOGEN BEIM LESEN. Nicht hinterher geprueft. Wer erst arrayBuffer() abwartet und dann die Laenge ansieht, hat die 50 MB schon im Speicher, bevor er sie ablehnt -- die Grenze schuetzt dann genau nichts. Gelesen wird deshalb stueckweise, und beim Ueberschreiten wird die Verbindung abgebrochen. Dazu ein ehrlicher Erkennungsname: Wer gefragt wird, soll sehen, wer fragt. Ein Abruf, der sich als Browser ausgibt, ist eine Luege gegenueber einem Dienst, der uns eingeladen hat. ===================================================================== */ /* Fuer eine ganze Seite. Siehe Grund 2 oben. */ export const FRIST_SEITE_MS = 12_000; /* Siehe Grund 3. 1,1 MB ist der gemessene Normalfall, 3 MB lassen Luft fuer eine Kampagne mit mehr Bausteinen -- und sind weit unter dem, was einem Server wehtut. */ export const SEITE_MAX = 3 * 1024 * 1024; /* Hoechstens fuenf Spruenge. Ein Kurzlink braucht einen, TikToks Weiterleitung auf die Langform einen zweiten. Wer fuenf braucht, hat kein Ziel, sondern eine Schleife. */ export const UMLEITUNGEN_MAX = 5; /* WER FRAGT DA? Keine Browser-Verkleidung. Steht eine Adresse dahinter, kann TikTok nachsehen, wer wir sind, statt zu raten. */ export const KENNUNG = "DogfatherUniverse-Workspace/1.0 (+https://workspace.dogfather-universe.com; " + "Kampagnen-Einleser, ein Abruf je eingefuegtem Link)"; /** Der Rechnername einer Adresse, fuer Meldungen an Menschen. * Gibt einen Ausschnitt der Eingabe zurueck, wenn sie keine ist. */ export function hostVon(roh) { try { return new URL(String(roh)).hostname; } catch { return String(roh).slice(0, 60); } } /* Adressen in einem Fliesstext. Das Muster FINDET nur -- geprueft wird mit new URL() und istTikTok(). Ein regulaerer Ausdruck, der entscheidet, ob eine Adresse zu TikTok gehoert, ist derselbe Fehler wie eine Zeichenkettensuche nach "tiktok.com". */ const LINK_MUSTER = /https?:\/\/[^\s"'<>)\]}]+/gi; /** Die erste TikTok-Adresse in einem Text -- oder null. * * WARUM DER GANZE TEXT HINEINDARF: Die Einladung kommt so, wie TikTok * sie schickt, mit Fliesstext drumherum: * * Dein Creator Network laedt dich ein, an einer Kampagne * teilzunehmen. Klicke hier, um weitere Informationen zu * erhalten: https://www.tiktok.com/t/ZSbqPsFa8/ * * Niemand soll den Link erst heraussuchen muessen. */ export function linkAusText(text) { for (const treffer of String(text || "").matchAll(LINK_MUSTER)) { /* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse -- mit einem Punkt dahinter waere es eine andere Seite. */ const sauber = treffer[0].replace(/[.,;:!?"'»«)\]]+$/u, ""); if (istTikTok(sauber)) return sauber; } return null; } /* Was NICHT abgerufen wird, egal wer es vorschlaegt. Buchstabenform, keine Namensaufloesung: Eine Pruefung, die erst DNS fragt, kann zwischen Frage und Abruf ueberholt werden (DNS-Rebinding). Was hier steht, faengt den realistischen Fall -- eine Adresse, die auf unseren eigenen Rechner oder ins Hausnetz zeigt. */ const PRIVATE_NAMEN = /^(localhost|.*\.local|.*\.internal|.*\.home\.arpa)$/i; const PRIVATE_ZAHLEN = new RegExp([ "^127\\.", // Rueckschleife "^10\\.", // privates Netz "^192\\.168\\.", // privates Netz "^172\\.(1[6-9]|2\\d|3[01])\\.", // privates Netz "^169\\.254\\.", // Link-lokal, darunter 169.254.169.254 "^0\\.", "^::1$", "^\\[?::1\\]?$", "^f[cd]", "^fe80:", ].join("|"), "i"); /** Darf unser Server dieses BILD holen? * * WARUM ES DIESE FRAGE GIBT: Die Banneradresse steht in der Seite, * die TikTok ausliefert -- sie ist FREMDBESTIMMT. Ohne Schranke * wuerde unser Server abrufen, was dort steht, auch * http://127.0.0.1:4100/... oder http://169.254.169.254/ (die * Metadatenadresse vieler Rechenzentren). Das ist der Lehrbuchfall * SSRF, und er kostet nichts zu verhindern. * * WAS DAS NICHT KANN: Ein Name, der auf eine private Adresse * zeigt, faellt hier nicht auf -- dafuer muesste aufgeloest werden, * und zwischen Aufloesung und Abruf laesst sich die Antwort * tauschen. Das steht hier, damit niemand mehr Schutz annimmt, als * da ist. */ export function bildAdresseErlaubt(roh) { let u; try { u = new URL(String(roh)); } catch { return false; } const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, ""); /* NUR IN EINER PRUEFUNG: dann liegt der nachgebaute Bilddienst auf 127.0.0.1. Derselbe Schalter wie bei istTikTok -- eine Pruefung, die die echte Regel umgeht, prueft eine andere. */ if (process.env.TIKTOK_KAMPAGNE_BASIS && (h === "127.0.0.1" || h === "localhost")) { return true; } if (u.protocol !== "https:") return false; if (u.port && u.port !== "443") return false; if (PRIVATE_NAMEN.test(h)) return false; if (PRIVATE_ZAHLEN.test(h)) return false; return true; } /** Folgt einer Umleitung Station fuer Station. * * Gibt { ok: true, url, weg } oder { ok: false, grund, ... } mit * grund aus: "fremd" (verlaesst TikTok), "zu_viele", "unlesbar", * "frist", "netz". * * Der Koerper jeder Zwischenantwort wird WEGGEWORFEN, nicht gelesen: * Sonst bleibt die Verbindung offen und wir laden jede Zwischenseite * umsonst herunter. */ export async function folgeUmleitung(roh, { frist = FRIST_SEITE_MS, schritte = UMLEITUNGEN_MAX } = {}) { let hier = String(roh || ""); if (!istTikTok(hier)) { return { ok: false, grund: "fremd", url: hier, host: hostVon(hier), weg: [] }; } const weg = [hier]; /* EINE Frist fuer die GANZE Kette. Fuenf Spruenge mit je zwoelf Sekunden waeren eine Minute -- und ein Mensch, der auf einen Knopf gedrueckt hat, wartet keine Minute. */ const abbruch = new AbortController(); const uhr = setTimeout(() => abbruch.abort(), frist); try { for (let i = 0; i < schritte; i++) { let a; try { a = await fetch(hier, { method: "GET", redirect: "manual", signal: abbruch.signal, headers: { "user-agent": KENNUNG }, }); } catch (f) { return { ok: false, grund: abbruch.signal.aborted ? "frist" : "netz", url: hier, host: hostVon(hier), weg, fehler: f?.message }; } try { await a.body?.cancel(); } catch { /* egal */ } const ort = a.headers.get("location"); if (!ort) return { ok: true, url: hier, weg, status: a.status }; let naechste; try { naechste = new URL(ort, hier).toString(); } catch { return { ok: false, grund: "unlesbar", url: ort, host: hostVon(ort), weg }; } if (!istTikTok(naechste)) { return { ok: false, grund: "fremd", url: naechste, host: hostVon(naechste), weg }; } hier = naechste; weg.push(hier); } return { ok: false, grund: "zu_viele", url: hier, host: hostVon(hier), weg }; } finally { clearTimeout(uhr); } } /** Holt eine ganze Seite -- mit Frist UND mit einer Grenze, die beim * LESEN gezogen wird. * * Gibt { ok: true, status, text, bytes } oder { ok: false, grund } * mit grund aus: "frist", "zu_gross", "netz", "status". Ein Aufrufer, * der jeden dieser Faelle verschieden beantworten muss, soll sie * nicht aus einer Fehlermeldung raten. */ export async function holeSeite(adresse, { frist = FRIST_SEITE_MS, maxBytes = SEITE_MAX } = {}) { const abbruch = new AbortController(); const uhr = setTimeout(() => abbruch.abort(), frist); try { let a; try { a = await fetch(adresse, { signal: abbruch.signal, redirect: "error", headers: { "user-agent": KENNUNG, accept: "text/html" }, }); } catch (f) { return { ok: false, grund: abbruch.signal.aborted ? "frist" : "netz", fehler: f?.message }; } if (!a.ok) { try { await a.body?.cancel(); } catch { /* egal */ } return { ok: false, grund: "status", status: a.status }; } /* Ein Kopf, der die Groesse schon ansagt, erspart das Herunterladen -- aber er ist eine BEHAUPTUNG des fremden Servers und kann fehlen oder luegen. Deshalb beides: hier nachsehen UND unten beim Lesen mitzaehlen. */ const angesagt = Number(a.headers.get("content-length")); if (Number.isFinite(angesagt) && angesagt > maxBytes) { try { await a.body?.cancel(); } catch { /* egal */ } return { ok: false, grund: "zu_gross", bytes: angesagt, angesagt: true }; } const leser = a.body?.getReader?.(); if (!leser) return { ok: false, grund: "netz", fehler: "kein Koerper" }; const stuecke = []; let gesamt = 0; for (;;) { let stueck; try { stueck = await leser.read(); } catch (f) { return { ok: false, grund: abbruch.signal.aborted ? "frist" : "netz", fehler: f?.message }; } if (stueck.done) break; gesamt += stueck.value.byteLength; if (gesamt > maxBytes) { try { await leser.cancel(); } catch { /* egal */ } return { ok: false, grund: "zu_gross", bytes: gesamt, angesagt: false }; } stuecke.push(Buffer.from(stueck.value)); } return { ok: true, status: a.status, bytes: gesamt, text: Buffer.concat(stuecke).toString("utf8") }; } finally { clearTimeout(uhr); } } /** Holt etwas von draussen -- MIT Frist ueber den ganzen Vorgang. * * DIE ALTE FASSUNG HAT NUR DEN ANFANG BEWACHT (behoben 05.10.2026). * * Sie gab die Antwort zurueck, sobald die Kopfzeilen da waren, und * raeumte im `finally` die Uhr weg. Was danach kam -- `a.json()` beim * Abruf der Videodaten, `a.arrayBuffer()` beim Vorschaubild -- lief * OHNE jede Frist. Bleibt der fremde Server mitten im Koerper * stehen, wartet unsere Anfrage fuer immer. * * Genau das sieht man als "der Knopf bleibt auf `wird geholt ...` * stehen": keine Fehlermeldung, kein Eintrag, kein Protokolleintrag * -- weil der Vorgang nie endet. Und es braucht keine Aenderung bei * uns, um anzufangen: Es haengt am fremden Netz. "Es ging doch immer * bis jetzt" ist bei so einem Fehler die Regel, nicht die Ausnahme. * * Der Koerper wird deshalb GELESEN, solange die Uhr laeuft, und erst * danach abgeraeumt. Die Frist gilt damit fuer alles zusammen. */ export async function holeMitFrist(adresse, kopf = {}, { frist = FRIST_MS } = {}) { const abbruch = new AbortController(); const uhr = setTimeout(() => abbruch.abort(), frist); try { const a = await fetch(adresse, { signal: abbruch.signal, headers: kopf, redirect: "follow" }); /* Bei einem Fehlschlag interessiert der Koerper nicht -- aber er muss trotzdem weg, sonst bleibt die Verbindung offen. */ const roh = Buffer.from(await a.arrayBuffer()); return { ok: a.ok, status: a.status, headers: a.headers, roh, text: () => roh.toString("utf8"), }; } finally { clearTimeout(uhr); } }