Ghost Magic Links absichern
Wie ich Ghosts Magic-Link-URL mit Cloudflare Turnstile, Nginx, njs und kurzlebigen Einmal-Tickets gegen Bots absichere.
0. Was ist passiert?
Bei Reputations- und Greylisting-Prüfungen fiel mir auf, dass mein Mailserver vereinzelt vertreten war. Die Analyse ergab, dass die Ursache nicht in einem kompromittierten Postfach lag: Bots missbrauchten den öffentlichen Ghost-Endpunkt, der Magic Links für Anmeldung und Registrierung verschickt. Jede automatisierte Anfrage kann eine echte E-Mail auslösen – und damit Zustellreputation, Empfänger und Infrastruktur belasten.
Ghost verwendet Magic Links für Anmeldung und Registrierung. Ein Besucher gibt seine E-Mail-Adresse ein, Ghost verschickt daraufhin eine Nachricht mit einem einmalig verwendbaren 0. Was ist passiert?Login-Link.
Genau dieser Mechanismus lässt sich automatisiert ansprechen. Ein Bot muss dazu weder ein Postfach übernehmen noch einen Ghost-Account besitzen. Er kann einfach immer wieder Anfragen an den Magic-Link-Endpunkt senden und Ghost dadurch zum Versand echter E-Mails veranlassen.
Das kann mehrere Folgen haben:
- unnötig viele ausgehende E-Mails,
- Belastung des eigenen SMTP-Servers,
- schlechtere Absender- und Domain-Reputation,
- Belästigung fremder Empfänger,
- Verbrauch von Versandkontingenten,
- zusätzliche Last auf Ghost und der Mail-Infrastruktur.
Ghost besitzt bereits einen eigenen Schutzmechanismus für diesen Ablauf. Mit verifyRequestIntegrity kann Ghost erzwingen, dass ein Client vor dem Magic-Link-Request zunächst ein kurzlebiges Integrity Token abruft und mitsendet. Das stoppt insbesondere Bots, die den Versand-Endpunkt direkt ansprechen.
Ein Integrity Token ist jedoch kein CAPTCHA und kein Nachweis einer menschlichen Interaktion. Ein angepasster Bot kann auch diesen vorgelagerten Ghost-Endpunkt ansprechen. Ich wollte deshalb zusätzlich eine serverseitig erzwungene Turnstile-Prüfung unmittelbar vor den Mailversand setzen. Mir fehlte jedoch eine zusätzliche Prüfung, die automatisierte Aufrufe zuverlässig stoppt und sowohl normale Theme-Formulare als auch Ghost Portal erfasst.
Ein einfacher JavaScript-Patch auf window.fetch reicht dafür nicht aus. Ghost Portal läuft in einem eigenen iframe-Kontext. Ein Schutz, der nur das JavaScript der Hauptseite beobachtet, kann Portal daher leicht übersehen.
Meine Lösung: Cloudflare Turnstile direkt vor den Magic-Link-Versand setzen.

Nginx und njs prüfen die Turnstile-Challenge serverseitig. Erst nach erfolgreicher Prüfung erhält der Browser ein kurzlebiges Einmal-Ticket. Nur mit diesem Ticket darf genau ein Magic-Link-Request Ghost erreichen.
Ghost selbst bleibt dabei unverändert.
Dieser Beitrag beschreibt mein technisches Konzept, die konkrete Integration und meine Erfahrungen aus dem Rollout. Er ist keine Garantie dafür, dass die Lösung in jeder Umgebung technisch, rechtlich oder datenschutzrechtlich vollständig passt. Prüft insbesondere Datenschutz, Proxy-Konfiguration, Logging und Request-Verarbeitung für eure eigene Infrastruktur.
1. Architektur und Schutzprinzip
1.1 Was genau wird geschützt?
Die eigentliche Sicherheitsgrenze liegt vor diesem Ghost-Endpunkt:
/members/api/send-magic-link
Ein POST auf diesen Endpunkt erreicht Ghost nur dann, wenn unmittelbar vorher eine gültige Turnstile-Prüfung durchgeführt wurde.
Andere Bereiche der Website bleiben außerhalb dieser zusätzlichen Schranke, unter anderem:
- normale Seiten,
- Ghost Admin,
- Content API,
- RSS,
- Sitemaps,
- Kommentare,
- ActivityPub,
- WebFinger.
Damit beschränkt sich die zusätzliche Schutzschicht auf den Teil von Ghost, der tatsächlich eine E-Mail auslösen kann.

1.2 Warum reicht ein CAPTCHA im Formular nicht?
Ein CAPTCHA nur optisch vor ein Formular zu setzen, wäre keine ausreichende Sicherheitsgrenze.
Ein Angreifer könnte das Formular umgehen und den Magic-Link-Endpunkt direkt ansprechen.
Die Entscheidung, ob der Request Ghost erreicht, muss deshalb serverseitig fallen.
Ein weiterer Punkt ist Ghost Portal: Portal läuft in einem eigenen iframe-Kontext. Ein einfacher Patch von window.fetch im Hauptfenster erfasst dessen Requests nicht zuverlässig.
Deshalb besteht die Lösung aus zwei Teilen:
Browserseite
Der Browser:
- erkennt eine Anmeldung oder Registrierung,
- startet Turnstile,
- sendet das Turnstile-Token an meinen eigenen Prüf-Endpunkt,
- erhält nach erfolgreicher Prüfung ein kurzlebiges Ticket,
- führt anschließend die ursprünglich gewünschte Anmeldung oder Registrierung aus.
Serverseite
Nginx und njs:
- validieren das Turnstile-Token bei Cloudflare,
- erzeugen ein Einmal-Ticket,
- speichern dieses Ticket kurzzeitig serverseitig,
- prüfen es beim Magic-Link-Request,
- verbrauchen es beim ersten erfolgreichen Zugriff.
Ohne gültiges Ticket erreicht der Request Ghost nicht.
1.3 Warum ein Einmal-Ticket?
Cloudflare Turnstile liefert nach erfolgreicher Challenge ein Token.
Dieses Token wird serverseitig über Cloudflare Siteverify validiert. Statt danach direkt einen bestimmten Ghost-Formularrequest weiterzuleiten, tausche ich es gegen ein eigenes, kurzlebiges Einmal-Ticket.
Der Ablauf:
- Der Browser führt Turnstile mit der Action
ghost_magic_linkaus. - Das Turnstile-Token geht an
/_ghost-turnstile/redeem. - njs sendet es serverseitig an Cloudflare Siteverify.
- Nur
success=true, der erwartete Hostname und die erwartete Action werden akzeptiert. - njs erzeugt eine zufällige UUID.
- Diese UUID wird maximal 120 Sekunden serverseitig gespeichert.
- Der Browser erhält sie als
HttpOnly,Secure,SameSite=LaxCookie. - Beim Magic-Link-Request prüft Nginx das Ticket.
- Das Ticket wird beim ersten erfolgreichen Zugriff atomar entfernt.
- Ein Replay desselben Tickets endet mit
403.
Eine erfolgreiche Turnstile-Prüfung berechtigt damit genau zu einem Magic-Link-Versand.
1.4 Ablauf im Überblick
Browser
|
| Anmeldung / Registrierung
v
Cloudflare Turnstile
|
| Challenge erfolgreich
v
/_ghost-turnstile/redeem
|
| serverseitige Prüfung
v
Cloudflare Siteverify
|
| Hostname + Action + success gültig
v
njs erzeugt Einmal-Ticket
|
| Cookie, maximal 120 Sekunden gültig
v
Browser
|
| POST /members/api/send-magic-link
v
Nginx prüft und verbraucht Ticket
|
v
Ghost
|
v
MailserverGhosts eigene Integrity-Prüfungen und Rate-Limits bleiben dabei weiterhin aktiv. Turnstile ist eine zusätzliche Schicht davor.

2. Voraussetzungen
2.1 Beispielumgebung dieser Anleitung
Alle Beispiele verwenden konsistent folgende Platzhalter:
- öffentliche Domain:
www.example.com - Ghost-Verzeichnis:
/var/www/ghost - Ghost-Upstream:
http://127.0.0.1:2368 - Projekt- und Hilfsskripte:
/opt/ghost-turnstile - njs-Handler:
/etc/nginx/njs/ghost_turnstile.js - Turnstile-Konfiguration:
/etc/nginx/ghost-turnstile-secret.json - globale Nginx-Konfiguration:
/etc/nginx/snippets/ghost-turnstile-http.conf - vHost-Konfiguration:
/etc/nginx/snippets/ghost-turnstile-server.conf - Browseradapter:
/var/www/ghost/system/nginx-root/assets/ghost-turnstile-client.js
Diese Werte müsst ihr an eure Umgebung anpassen.
2.2 Womit ich getestet habe
Meine Implementierung wurde mit folgender Umgebung entwickelt und produktiv getestet:
- Ghost 6.52.1,
- zusätzliche Kompatibilitätsprüfung mit Ghost 6.62.0,
- Ubuntu 24.04 LTS Standard - Nginx 1.24.0 mit
--with-compat, - Nginx-Modul
http_auth_request, - Selbst kompiliert: njs 1.0.1,
- Node.js 22,
- Linux mit systemd,
- Ghost Members,
- Ghost Portal,
- Theme-Formulare mit
data-members-form="subscribe"unddata-members-form="signin".
Mein Theme basiert auf Ghost Taste. Das Theme selbst ist für diese Lösung aber nicht entscheidend.
2.3 Was ihr benötigt
Für die Installation benötigt ihr:
- eine funktionierende Ghost-Installation,
- Root- oder vergleichbaren administrativen Zugriff,
- Nginx als Reverse Proxy vor Ghost,
- eine funktionierende HTTPS-Konfiguration,
- das Nginx-Modul
http_auth_request, - njs in einer zum installierten Nginx passenden Version,
- ausgehendes HTTPS zu
challenges.cloudflare.com, - Ghost Members,
- Ghost Portal und/oder Members-Formulare im Theme,
- Zugriff auf die globale Ghost Header Code Injection,
- einen Cloudflare-Account mit Turnstile.
Falls ihr njs selbst als dynamisches Modul baut, zusätzlich beispielsweise:
- GCC,
- make,
- binutils,
- libssl-dev,
- libpcre2-dev,
- zlib1g-dev,
- den njs-Quellcode.
Für automatisierte Browsertests ist Playwright hilfreich, aber nicht zwingend erforderlich.
2.4 Anforderungen an Theme und Ghost Portal
Theme-Formulare sollten die von Ghost vorgesehenen Attribute verwenden, beispielsweise:
data-members-form="subscribe"
oder:
data-members-form="signin"
Ghost Portal ist etwas spezieller.
In der von mir getesteten Version erscheint Portal als iframe derselben Website. Dadurch kann mein Browseradapter auf dessen Inhalt zugreifen.
Er erkennt das Portal unter anderem an:
title="portal-popup"
und sucht darin nach den Login- beziehungsweise Signup-Elementen.
Diese DOM-Struktur ist keine Schnittstelle, auf deren dauerhafte Stabilität ich mich verlassen würde.
Nach Ghost- oder Portal-Updates sollte deshalb getestet werden, ob der Adapter weiterhin funktioniert.
3. Implementierung
Schritt 1: Backup erstellen
Bevor ihr Änderungen an einer produktiven Ghost- oder Nginx-Installation vornehmt: Macht ein Backup.
Sichert mindestens:
- die aktive Nginx-Konfiguration,
- Ghost-Konfiguration,
- Code Injection,
- alle Dateien, die ihr verändern oder neu anlegen wollt.
Prüft außerdem, wie ihr im Fehlerfall zum vorherigen Zustand zurückkehrt.
Eine konkrete Backup- und Restore-Strategie ist nicht Bestandteil dieser Anleitung.
Schritt 2: Cloudflare Turnstile anlegen
Widget erstellen
Im Cloudflare-Dashboard:
- Turnstile öffnen.
- Ein neues Widget anlegen.
- Widget Mode auf "Managed" setzen.
- Den kanonischen Produktionshost eintragen, hier
www.example.com. - Andere Hostnamen wie
example.comgegebenenfalls auf den kanonischen Host umleiten.
Sitekey und Secret
Cloudflare liefert zwei Werte:
- Sitekey: öffentlich, wird im Browser verwendet.
- Secret: vertraulich, bleibt ausschließlich auf dem Server.
Fallstrick: Bei meinem ersten Versuch blieb das Widget unsichtbar, weil die Dashboard-Konfiguration nicht zur erwarteten Darstellung passte. Nach der Umstellung auf Managed erschien die Challenge wie vorgesehen.
Die Browseroption appearance: 'interaction-only' steuert nur, wann ein interaktives Widget sichtbar wird. Sie ersetzt nicht die Widget-Konfiguration im Cloudflare-Dashboard.
Schritt 3: Eine passende njs-Version bereitstellen
Auf meinem System stellte die Distribution eine ältere njs-Version bereit.
Für einen neuen produktiven Rollout wollte ich diese aufgrund inzwischen veröffentlichter Sicherheitshinweise nicht einsetzen. Deshalb habe ich njs 1.0.1 als dynamisches Modul passend zum bereits installierten Nginx 1.24.0 gebaut.
Warum nicht einfach ein fertiges Modul kopieren?
Ein dynamisches Nginx-Modul muss zum installierten Nginx-Build und dessen ABI passen.
Ein Binärmodul von einem anderen Server sollte deshalb nicht ungeprüft übernommen werden.
Build-Skript
Datei: /opt/ghost-turnstile/build-njs-module.sh
#!/bin/sh
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Purpose:
# Example build script for compiling the njs HTTP module against a matching
# Nginx source tree.
#
# Disclaimer:
# Example implementation without warranty. Review versions, checksums,
# compiler options and paths before using this on a production system.
# A dynamic module must match the Nginx build it is loaded into.
# -----------------------------------------------------------------------------
set -eu
project=/opt/ghost-turnstile
nginx_version=1.24.0
njs_version=1.0.1
nginx_archive="$project/build/sources/nginx-$nginx_version.tar.gz"
njs_archive="$project/build/sources/njs-$njs_version.tar.gz"
output="$project/build/output/ngx_http_js_module_$njs_version.so"
nginx_sha256=77a2541637b92a621e3ee76776c8b7b40cf6d707e69ba53a940283e30ff2f55d
njs_sha256=74372cfcbf11eb0a71bc555e19dc785f61d561d5663d254474da6c8c9e50a6a7
# Check required build tools.
for command in nginx gcc make tar sha256sum mktemp install; do
command -v "$command" >/dev/null 2>&1 || {
printf '%s\n' "Missing build command: $command" >&2
exit 1
}
done
# Verify that the script is being used against the expected Nginx version.
installed_version=$(nginx -v 2>&1)
installed_version=${installed_version#nginx version: nginx/}
if [ "$installed_version" != "$nginx_version" ]; then
printf '%s\n' \
"Refusing ABI build: installed Nginx is $installed_version, expected $nginx_version." >&2
exit 1
fi
# The documented build expects Nginx to provide --with-compat.
nginx -V 2>&1 | grep -q -- '--with-compat' || {
printf '%s\n' 'Refusing ABI build: installed Nginx lacks --with-compat.' >&2
exit 1
}
check_hash() {
file=$1
expected=$2
[ -f "$file" ] || {
printf '%s\n' "Missing source archive: $file" >&2
exit 1
}
actual=$(sha256sum "$file")
actual=${actual%% *}
[ "$actual" = "$expected" ] || {
printf '%s\n' "Checksum mismatch: $file" >&2
exit 1
}
}
check_hash "$nginx_archive" "$nginx_sha256"
check_hash "$njs_archive" "$njs_sha256"
# Build in a temporary directory and clean it up afterwards.
build_root=$(mktemp -d /tmp/ghost-turnstile-build.XXXXXX)
cleanup() {
case "$build_root" in
/tmp/ghost-turnstile-build.*)
rm -rf -- "$build_root"
;;
*)
printf '%s\n' 'Refusing unexpected cleanup path.' >&2
;;
esac
}
trap cleanup EXIT HUP INT TERM
tar -xzf "$nginx_archive" -C "$build_root"
tar -xzf "$njs_archive" -C "$build_root"
nginx_source="$build_root/nginx-$nginx_version"
njs_source="$build_root/njs-$njs_version"
(
cd "$nginx_source"
# Disable optional components not required for this use case.
NJS_LIBXSLT=NO \
NJS_ZLIB=NO \
NJS_QUICKJS=NO \
./configure \
--with-compat \
--add-dynamic-module="$njs_source/nginx"
make -j2 modules
)
mkdir -p "$(dirname "$output")"
install -m 0644 \
"$nginx_source/objs/ngx_http_js_module.so" \
"$output"
# Print the resulting checksum for documentation.
sha256sum "$output"
printf '%s\n' 'Build completed.'Modul laden
Nach dem Build wird das Modul beispielsweise an folgende Stelle installiert:
/usr/local/lib/nginx/modules/ngx_http_js_module_1.0.1.so
Datei: /etc/nginx/modules-enabled/50-ghost-turnstile-njs.conf
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Disclaimer:
# Example configuration without warranty.
# The module path and ABI compatibility must match the installed Nginx build.
# -----------------------------------------------------------------------------
# Load the custom-built njs HTTP module once.
load_module /usr/local/lib/nginx/modules/ngx_http_js_module_1.0.1.so;Danach nginx prüfen:
nginx -t
Schritt 4: Turnstile-Secret installieren
Das Turnstile-Secret gehört nicht:
- in's Git,
- in Shellargumente,
- in Chatverläufe,
- in Logs,
- in öffentlich lesbare Konfigurationsdateien.
Ich speichere die serverseitige Konfiguration unter:
/etc/nginx/ghost-turnstile-secret.json
Damit die Secrets nicht in irgendeiner History landen und die Dateien sicher erzeugt werden, nutze ich ein Helferscript. Ihr könnt dies natürlich auch von Hand tun.
Installationsskript
Datei: /opt/ghost-turnstile/install-secret.sh
#!/bin/sh
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Purpose:
# Install the Cloudflare Turnstile secret without exposing it through command
# line arguments or normal terminal output.
#
# Disclaimer:
# Example implementation without warranty.
# Review hostname, file paths and secret handling for your environment.
# -----------------------------------------------------------------------------
set -eu
target=/etc/nginx/ghost-turnstile-secret.json
# Make newly created files private by default.
umask 077
temporary=
terminal_state=
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' \
'Run this script with sudo; the secret is read silently from the controlling terminal.' >&2
exit 1
fi
cleanup() {
secret=
# Restore terminal settings if execution was interrupted.
if [ -n "$terminal_state" ]; then
stty "$terminal_state" </dev/tty
fi
[ -z "$temporary" ] || [ ! -e "$temporary" ] || rm -f -- "$temporary"
}
trap cleanup EXIT HUP INT TERM
printf '%s' 'Turnstile production secret (input hidden): ' >/dev/tty
terminal_state=$(stty -g </dev/tty)
stty -echo </dev/tty
IFS= read -r secret </dev/tty
stty "$terminal_state" </dev/tty
terminal_state=
printf '\n' >/dev/tty
# Reject clearly malformed values.
case "$secret" in
''|*[!A-Za-z0-9_-]*)
secret=
printf '%s\n' 'Rejected: unexpected secret format.' >&2
exit 1
;;
esac
if [ "${#secret}" -lt 20 ] || [ "${#secret}" -gt 128 ]; then
secret=
printf '%s\n' 'Rejected: unexpected secret length.' >&2
exit 1
fi
# Create the new configuration atomically.
temporary=$(mktemp /etc/nginx/.ghost-turnstile-secret.XXXXXX)
printf '%s\n' \
"{\"secret\":\"$secret\",\"hostnames\":[\"www.example.com\"],\"origin\":\"https://www.example.com\",\"action\":\"ghost_magic_link\",\"ticketTtlSeconds\":120}" \
>"$temporary"
secret=
chown root:root "$temporary"
chmod 600 "$temporary"
mv -f -- "$temporary" "$target"
trap - EXIT HUP INT TERM
temporary=
printf '%s\n' \
"Installed $target as root:root mode 0600; no value was echoed."Ausführen:
sudo /opt/ghost-turnstile/install-secret.sh
Die erzeugte Konfiguration enthält:
- Secret,
- erlaubten Hostnamen,
- erwarteten Origin,
- erwartete Action,
- Ticket-Gültigkeitsdauer.
Schritt 5: njs-Handler installieren
Der njs-Handler erledigt die eigentliche serverseitige Logik:
- Turnstile-Token über Siteverify prüfen,
- Hostname und Action validieren,
- Einmal-Ticket erzeugen,
- Ticket später beim Ghost-Request verbrauchen.
Datei: /etc/nginx/njs/ghost_turnstile.js
/*
* -----------------------------------------------------------------------------
* Ghost Turnstile Protection for Ghost CMS
*
* Author / Copyright:
* initinsights.de
*
* Purpose:
* Server-side Cloudflare Turnstile validation and one-time authorization
* tickets for Ghost's Magic-Link endpoint.
*
* Disclaimer:
* Example implementation without warranty.
* Review security assumptions, Nginx/njs compatibility, hostname policy,
* logging and proxy configuration before production use.
* -----------------------------------------------------------------------------
*/
const REDEEM_PATH = '/_ghost-turnstile/siteverify';
const COOKIE_NAME = '__Secure-ghost_turnstile';
const TICKET_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const JSON_TYPE =
/^application\/json(?:\s*;\s*charset=utf-8)?$/i;
/*
* Validate the preloaded server-side configuration.
* Unexpected values cause a fail-closed response.
*/
function config() {
const value = globalThis.ghost_turnstile_config;
if (!value ||
typeof value.secret !== 'string' ||
value.secret.length < 20) {
throw new Error('invalid Turnstile configuration');
}
if (!Array.isArray(value.hostnames) ||
value.hostnames.length === 0) {
throw new Error('empty Turnstile hostname allowlist');
}
if (typeof value.origin !== 'string' ||
value.origin !== `https://${value.hostnames[0]}`) {
throw new Error('invalid Turnstile origin');
}
if (value.action !== 'ghost_magic_link' ||
value.ticketTtlSeconds !== 120) {
throw new Error('invalid Turnstile policy configuration');
}
return value;
}
function setCommonHeaders(r) {
r.headersOut['Cache-Control'] = 'no-store';
r.headersOut['Content-Type'] = 'application/json; charset=utf-8';
r.headersOut['X-Content-Type-Options'] = 'nosniff';
}
/*
* Only return fixed error codes.
* Do not reflect secrets, tokens or user-supplied values.
*/
function reject(r, status, code) {
setCommonHeaders(r);
r.return(status, JSON.stringify({
ok: false,
code
}));
}
function fixedWarning(r, event) {
r.warn(`ghost_turnstile event=${event}`);
}
function hostnameAllowed(hostnames, hostname) {
for (let index = 0; index < hostnames.length; index += 1) {
if (hostnames[index] === hostname) {
return true;
}
}
return false;
}
function parseCookies(header) {
const result = Object.create(null);
if (typeof header !== 'string') {
return result;
}
const parts = header.split(';');
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
const separator = part.indexOf('=');
if (separator < 1) {
continue;
}
const name = part.slice(0, separator).trim();
const value = part.slice(separator + 1).trim();
if (!(name in result)) {
result[name] = value;
}
}
return result;
}
/*
* Create and store a short-lived one-time ticket.
*/
function issueTicket(r, cfg) {
const tickets =
globalThis.ngx.shared.ghost_turnstile_tickets;
for (let attempt = 0; attempt < 3; attempt += 1) {
const ticket = globalThis.crypto.randomUUID();
if (!TICKET_PATTERN.test(ticket) ||
!tickets.add(ticket, '1')) {
continue;
}
r.headersOut['Set-Cookie'] =
`${COOKIE_NAME}=${ticket}; ` +
`Max-Age=${cfg.ticketTtlSeconds}; ` +
'Path=/members/api/send-magic-link; ' +
'HttpOnly; Secure; SameSite=Lax';
setCommonHeaders(r);
r.return(204);
return;
}
fixedWarning(r, 'ticket_allocation_failed');
reject(r, 503, 'temporarily_unavailable');
}
/*
* Validate a Cloudflare Turnstile token and exchange it for a local ticket.
*/
async function redeem(r) {
let cfg;
try {
cfg = config();
} catch (_) {
fixedWarning(r, 'configuration_invalid');
reject(r, 503, 'temporarily_unavailable');
return;
}
if (r.method !== 'POST') {
reject(r, 405, 'method_not_allowed');
return;
}
if (r.headersIn.Origin !== cfg.origin) {
reject(r, 403, 'invalid_request');
return;
}
if (!JSON_TYPE.test(r.headersIn['Content-Type'] || '')) {
reject(r, 415, 'invalid_request');
return;
}
let body;
try {
body = await r.readRequestJSON();
} catch (_) {
reject(r, 400, 'invalid_request');
return;
}
const token = body && body.token;
if (typeof token !== 'string' ||
token.length === 0 ||
token.length > 2048) {
reject(r, 400, 'invalid_request');
return;
}
/*
* remoteip is deliberately omitted here.
*/
const form =
`secret=${encodeURIComponent(cfg.secret)}` +
`&response=${encodeURIComponent(token)}`;
let reply;
try {
reply = await r.subrequest(REDEEM_PATH, {
method: 'POST',
body: form
});
} catch (_) {
fixedWarning(r, 'siteverify_unavailable');
reject(r, 503, 'temporarily_unavailable');
return;
}
if (!reply ||
reply.status !== 200 ||
typeof reply.responseText !== 'string' ||
reply.responseText.length > 4096) {
fixedWarning(r, 'siteverify_invalid_transport');
reject(r, 503, 'temporarily_unavailable');
return;
}
let verification;
try {
verification = JSON.parse(reply.responseText);
} catch (_) {
fixedWarning(r, 'siteverify_invalid_json');
reject(r, 503, 'temporarily_unavailable');
return;
}
/*
* A generic success is not enough.
* Hostname and action must also match the configured policy.
*/
if (verification.success !== true ||
!hostnameAllowed(cfg.hostnames, verification.hostname) ||
verification.action !== cfg.action) {
reject(r, 403, 'verification_failed');
return;
}
issueTicket(r, cfg);
}
/*
* Called through Nginx auth_request.
* pop() consumes the ticket atomically.
*/
function authorize(r) {
const cookies = parseCookies(r.headersIn.Cookie);
const ticket = cookies[COOKIE_NAME];
if (typeof ticket !== 'string' ||
!TICKET_PATTERN.test(ticket)) {
r.return(403);
return;
}
const value =
globalThis.ngx.shared.ghost_turnstile_tickets.pop(ticket);
if (value !== '1') {
r.return(403);
return;
}
r.return(204);
}
export default {
authorize,
redeem
};Was der Handler akzeptiert
Der Redemption-Endpunkt akzeptiert bewusst nur:
- HTTP Methode:
POST, - exakt den erwarteten
Origin, application/json,- ein nichtleeres Token bis maximal 2048 Zeichen,
- eine begrenzte Siteverify-Antwort.
Fehlerantworten enthalten nur feste Fehlercodes.
Schritt 6: Globale Nginx-Konfiguration
Einige Direktiven gehören genau einmal in den globalen http-Kontext von Nginx.
Datei: /etc/nginx/snippets/ghost-turnstile-http.conf
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Scope:
# Global configuration for the Nginx http context.
#
# Disclaimer:
# Example configuration without warranty.
# Review client-IP handling, rate limits, module compatibility and file paths
# for your environment before production use.
# -----------------------------------------------------------------------------
# Import the njs handler.
js_import ghost_turnstile
from /etc/nginx/njs/ghost_turnstile.js;
# Load the root-only Turnstile policy and secret.
js_preload_object ghost_turnstile_config
from /etc/nginx/ghost-turnstile-secret.json;
# Shared-memory storage for short-lived one-time tickets.
js_shared_dict_zone
zone=ghost_turnstile_tickets:1m
timeout=120s
evict;
# Limit Turnstile redemption attempts per client address.
limit_req_zone
$binary_remote_addr
zone=ghost_turnstile_redeem:1m
rate=6r/m;
# Additional rate limit for the actual Magic-Link endpoint.
limit_req_zone
$binary_remote_addr
zone=ghost_magic_link:1m
rate=3r/m;
# Bound the response exposed to njs by the internal Siteverify subrequest.
subrequest_output_buffer_size 8k;Einbindung
Die Datei wird einmal aus dem http-Kontext der /etc/nginx/nginx.conf eingebunden:
include /etc/nginx/snippets/ghost-turnstile-http.conf;
Client-IP-Adresse beachten
Die Rate-Limits basieren hier auf $binary_remote_addr.
Das funktioniert nur sinnvoll, wenn Nginx dort tatsächlich die vertrauenswürdige Client-IP sieht.
Steht ein CDN, Reverse Proxy oder Load Balancer davor, muss real_ip vorher korrekt und spoofing-sicher konfiguriert werden.
Schritt 7: Magic-Link-Endpunkt im vHost schützen
Die eigentliche Zugriffskontrolle gehört in den kanonischen HTTPS-vHost.
Datei: /etc/nginx/snippets/ghost-turnstile-server.conf
# -----------------------------------------------------------------------------
# Ghost Turnstile Protection for Ghost CMS
#
# Author / Copyright:
# initinsights.de
#
# Scope:
# Include this file ONLY in the canonical HTTPS server block for
# www.example.com.
#
# Example Ghost upstream:
# http://127.0.0.1:2368
#
# Disclaimer:
# Example configuration without warranty.
# Review hostname, Ghost upstream, proxy headers, rate limits and paths before
# production use.
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Browser adapter
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/client.js {
alias /var/www/ghost/system/nginx-root/assets/ghost-turnstile-client.js;
default_type application/javascript;
add_header Cache-Control "public, max-age=300" always;
add_header X-Content-Type-Options nosniff always;
access_log off;
}
# ---------------------------------------------------------------------------
# Turnstile token redemption
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/redeem {
client_max_body_size 4k;
client_body_buffer_size 4k;
limit_req zone=ghost_turnstile_redeem burst=3 nodelay;
limit_req_status 429;
limit_req_log_level notice;
js_content ghost_turnstile.redeem;
# The request contains a Turnstile token.
access_log off;
}
# ---------------------------------------------------------------------------
# Internal Cloudflare Siteverify proxy
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/siteverify {
internal;
proxy_pass
https://challenges.cloudflare.com/turnstile/v0/siteverify;
# Forward only the headers required by Siteverify.
proxy_pass_request_headers off;
proxy_set_header Host challenges.cloudflare.com;
proxy_set_header Content-Type application/x-www-form-urlencoded;
proxy_ssl_server_name on;
proxy_ssl_name challenges.cloudflare.com;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate
/etc/ssl/certs/ca-certificates.crt;
proxy_ssl_verify_depth 3;
proxy_connect_timeout 3s;
proxy_send_timeout 5s;
proxy_read_timeout 5s;
access_log off;
}
# ---------------------------------------------------------------------------
# Internal authorization handler
# ---------------------------------------------------------------------------
location = /_ghost-turnstile/auth {
internal;
js_content ghost_turnstile.authorize;
access_log off;
}
# ---------------------------------------------------------------------------
# Ghost Magic-Link endpoint without trailing slash
# ---------------------------------------------------------------------------
location = /members/api/send-magic-link {
if ($request_method != POST) {
return 405;
}
client_max_body_size 128k;
limit_req zone=ghost_magic_link burst=2 nodelay;
limit_req_status 429;
limit_req_log_level notice;
# Ghost is reached only after successful ticket authorization.
auth_request /_ghost-turnstile/auth;
# Clear the short-lived browser cookie after use.
add_header Set-Cookie
"__Secure-ghost_turnstile=; Max-Age=0; Path=/members/api/send-magic-link; HttpOnly; Secure; SameSite=Lax"
always;
add_header X-Content-Type-Options nosniff always;
proxy_set_header X-Forwarded-For
$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto
$scheme;
proxy_set_header X-Real-IP
$remote_addr;
proxy_set_header Host
$http_host;
proxy_pass http://127.0.0.1:2368;
}
# ---------------------------------------------------------------------------
# Ghost Magic-Link endpoint with trailing slash
# ---------------------------------------------------------------------------
location = /members/api/send-magic-link/ {
if ($request_method != POST) {
return 405;
}
client_max_body_size 128k;
limit_req zone=ghost_magic_link burst=2 nodelay;
limit_req_status 429;
limit_req_log_level notice;
auth_request /_ghost-turnstile/auth;
add_header Set-Cookie
"__Secure-ghost_turnstile=; Max-Age=0; Path=/members/api/send-magic-link; HttpOnly; Secure; SameSite=Lax"
always;
add_header X-Content-Type-Options nosniff always;
proxy_set_header X-Forwarded-For
$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto
$scheme;
proxy_set_header X-Real-IP
$remote_addr;
proxy_set_header Host
$http_host;
proxy_pass http://127.0.0.1:2368;
}Warum zwei Magic-Link-Locations?
Clients können den Endpunkt mit oder ohne abschließenden Slash ansprechen:
/members/api/send-magic-link/members/api/send-magic-link/
Ich schütze deshalb beide Varianten explizit.
Include im vHost
Im kanonischen HTTPS-server-Block:
include /etc/nginx/snippets/ghost-turnstile-server.conf;
Danach:
nginx -t
und erst bei erfolgreicher Prüfung:
systemctl reload nginx
Schritt 8: Browseradapter installieren
Der Browseradapter hält Anmelde- und Registrierungsaktionen zunächst an.
Er führt Turnstile aus und setzt die ursprüngliche Aktion erst fort, wenn der Server ein gültiges Ticket ausgestellt hat.
Dabei patche ich nicht global window.fetch.
Stattdessen werden konkrete Ereignisse abgefangen:
submitbei Theme-Formularen,clickin Ghost Portal,Enterim E-Mail-Feld von Ghost Portal.
Datei im Ghost-Verzeichnis: /var/www/ghost/system/nginx-root/assets/ghost-turnstile-client.js
/*
* -----------------------------------------------------------------------------
* Ghost Turnstile Protection for Ghost CMS
*
* Author / Copyright:
* initinsights.de
*
* Purpose:
* Browser adapter for Ghost Members theme forms and Ghost Portal.
* A valid one-time server ticket is acquired before the original action is
* replayed.
*
* Disclaimer:
* Example implementation without warranty.
* Ghost Portal uses internal DOM structures that may change after updates.
* Re-test Theme signup/signin and Portal signup/signin after Ghost updates.
* -----------------------------------------------------------------------------
*/
(function () {
'use strict';
const CONFIG_ID = 'ghost-turnstile-config';
const PORTAL_TITLE = 'portal-popup';
const SAFETY_MARGIN_MS = 5000;
const CHALLENGE_TIMEOUT_MS = 30000;
const bypass = new WeakSet();
let config;
let widgetId;
let inflight;
let resolveInflight;
let rejectInflight;
let challengeTimer;
let ticketExpiresAt = 0;
let ui;
function language() {
return (document.documentElement.lang || '')
.toLowerCase()
.startsWith('de')
? 'de'
: 'en';
}
function messages() {
return language() === 'de'
? {
checking: 'Sicherheitsprüfung wird vorbereitet …',
interactive: 'Bitte schließe die Sicherheitsprüfung ab.',
failed: 'Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.',
retry: 'Erneut versuchen'
}
: {
checking: 'Preparing security check …',
interactive: 'Please complete the security check.',
failed: 'The security check failed. Please try again.',
retry: 'Try again'
};
}
/*
* Read and validate the public configuration from Ghost Code Injection.
*/
function parseConfig() {
const element = document.getElementById(CONFIG_ID);
if (!element) {
return null;
}
try {
const value = JSON.parse(element.textContent || '{}');
if (value.hostname !== window.location.hostname ||
value.action !== 'ghost_magic_link' ||
typeof value.sitekey !== 'string' ||
!value.sitekey ||
value.redeemUrl !== '/_ghost-turnstile/redeem' ||
value.ticketTtlSeconds !== 120) {
return null;
}
return value;
} catch (_) {
return null;
}
}
/*
* Create the small status UI only when required.
*/
function createUi() {
const text = messages();
const style = document.createElement('style');
style.textContent =
'#ghost-turnstile-ui{' +
'position:fixed;' +
'right:1rem;' +
'bottom:1rem;' +
'z-index:2147483646;' +
'max-width:min(22rem,calc(100vw - 2rem));' +
'padding:.75rem;' +
'background:#fff;' +
'color:#241f1c;' +
'border:1px solid #76665d;' +
'border-radius:.5rem;' +
'box-shadow:0 .25rem 1.25rem rgba(0,0,0,.18);' +
'font:14px/1.4 system-ui,sans-serif' +
'}' +
'#ghost-turnstile-ui[hidden]{display:none}' +
'#ghost-turnstile-status{margin:.25rem 0 .5rem}' +
'#ghost-turnstile-retry{' +
'border:0;' +
'border-radius:999px;' +
'padding:.55rem .9rem;' +
'background:#c44f00;' +
'color:#fff;' +
'font:inherit;' +
'font-weight:700;' +
'cursor:pointer' +
'}' +
'#ghost-turnstile-widget{min-height:1px}';
document.head.appendChild(style);
const root = document.createElement('section');
root.id = 'ghost-turnstile-ui';
root.hidden = true;
root.setAttribute('role', 'status');
root.setAttribute('aria-live', 'polite');
const status = document.createElement('p');
status.id = 'ghost-turnstile-status';
status.textContent = text.checking;
const widget = document.createElement('div');
widget.id = 'ghost-turnstile-widget';
const retry = document.createElement('button');
retry.id = 'ghost-turnstile-retry';
retry.type = 'button';
retry.textContent = text.retry;
retry.hidden = true;
retry.addEventListener('click', function () {
ticketExpiresAt = 0;
acquireTicket().catch(function () {});
});
root.append(status, widget, retry);
document.body.appendChild(root);
return {
root,
status,
widget,
retry
};
}
function show(statusKey, canRetry) {
if (!ui) {
ui = createUi();
}
ui.status.textContent = messages()[statusKey];
ui.retry.hidden = !canRetry;
ui.root.hidden = false;
}
function hide() {
if (ui) {
ui.root.hidden = true;
}
}
/*
* Resolve or reject one running challenge.
*/
function settle(error) {
window.clearTimeout(challengeTimer);
const resolve = resolveInflight;
const reject = rejectInflight;
inflight = null;
resolveInflight = null;
rejectInflight = null;
if (error) {
ticketExpiresAt = 0;
show('failed', true);
if (reject) {
reject(error);
}
} else {
hide();
if (resolve) {
resolve();
}
}
}
/*
* Exchange the Cloudflare token for a server-side one-time ticket.
*/
async function redeem(token) {
try {
const response =
await fetch(config.redeemUrl, {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token
})
});
if (!response.ok) {
throw new Error('verification failed');
}
ticketExpiresAt =
Date.now() +
config.ticketTtlSeconds * 1000;
settle();
} catch (error) {
settle(error);
}
}
function renderWidget() {
if (widgetId !== undefined ||
!window.turnstile) {
return;
}
if (!ui) {
ui = createUi();
}
widgetId =
window.turnstile.render(ui.widget, {
sitekey: config.sitekey,
action: config.action,
execution: 'execute',
appearance: 'interaction-only',
retry: 'never',
'refresh-expired': 'manual',
callback: redeem,
'before-interactive-callback': function () {
show('interactive', false);
},
'error-callback': function () {
settle(new Error('challenge error'));
},
'expired-callback': function () {
settle(new Error('challenge expired'));
},
'timeout-callback': function () {
settle(new Error('challenge timeout'));
}
});
}
/*
* Wait only for a bounded period for Cloudflare's client API.
*/
function waitForTurnstile(deadline) {
if (window.turnstile) {
renderWidget();
window.turnstile.reset(widgetId);
window.turnstile.execute(widgetId);
return;
}
if (Date.now() >= deadline) {
settle(new Error('challenge unavailable'));
return;
}
window.setTimeout(function () {
waitForTurnstile(deadline);
}, 100);
}
function acquireTicket() {
if (ticketExpiresAt - Date.now() >
SAFETY_MARGIN_MS) {
return Promise.resolve();
}
if (inflight) {
return inflight;
}
show('checking', false);
inflight =
new Promise(function (resolve, reject) {
resolveInflight = resolve;
rejectInflight = reject;
});
challengeTimer =
window.setTimeout(function () {
settle(new Error('challenge timeout'));
}, CHALLENGE_TIMEOUT_MS);
waitForTurnstile(Date.now() + 10000);
return inflight;
}
function consumeLocalTicket() {
ticketExpiresAt = 0;
}
/*
* Replay the original user action only after authorization succeeded.
*/
async function replay(target, mode) {
try {
await acquireTicket();
bypass.add(target);
if (mode === 'submit') {
target.requestSubmit();
} else {
target.click();
}
consumeLocalTicket();
} catch (_) {
// Fail closed: never submit without authorization.
}
}
/*
* Protect regular Ghost Members forms from the active theme.
*/
function gateThemeSubmit(event) {
const form = event.target;
if (!(form instanceof HTMLFormElement) ||
!form.matches(
'[data-members-form="subscribe"], ' +
'[data-members-form="signin"]'
)) {
return;
}
if (bypass.delete(form)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
replay(form, 'submit');
}
/*
* Find the currently relevant submit button inside Ghost Portal.
*/
function portalEmailButton(doc, target) {
const wrapper =
doc.querySelector(
'.gh-portal-popup-wrapper.signin, ' +
'.gh-portal-popup-wrapper.signup'
);
const email =
doc.querySelector(
'input[type="email"][name="email"]'
);
if (!wrapper ||
!email ||
email.disabled) {
return null;
}
if (target && target.closest) {
const button =
target.closest('button[type="submit"]');
if (button) {
return button;
}
}
return doc.querySelector('button[type="submit"]');
}
/*
* Attach handlers directly inside the readable Ghost Portal iframe.
*/
function attachPortal(frame) {
if (frame.dataset.ghostTurnstileAttached === '1') {
return;
}
const doc = frame.contentDocument;
if (!doc ||
!doc.querySelector('.gh-portal-popup-wrapper')) {
return;
}
frame.dataset.ghostTurnstileAttached = '1';
doc.addEventListener(
'click',
function (event) {
const button =
portalEmailButton(
doc,
event.target
);
if (!button ||
bypass.delete(button)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
replay(button, 'click');
},
true
);
doc.addEventListener(
'keydown',
function (event) {
if (event.key !== 'Enter' ||
event.isComposing) {
return;
}
const button =
portalEmailButton(
doc,
event.target
);
if (!button ||
event.target.type !== 'email') {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
replay(button, 'click');
},
true
);
}
/*
* Ghost Portal is created dynamically.
*/
function discoverPortal() {
for (const frame of
document.querySelectorAll(
`iframe[title="${PORTAL_TITLE}"]`
)) {
try {
attachPortal(frame);
} catch (_) {
// Nginx remains the final security boundary.
}
}
}
/*
* Start Turnstile early when the user interacts with a relevant field.
*/
function prewarm(event) {
const target = event.target;
if (!target ||
!target.closest) {
return;
}
if (target.closest(
'[data-portal], ' +
'[data-members-form] input[type="email"]'
)) {
acquireTicket().catch(function () {});
}
}
function start() {
config = parseConfig();
if (!config) {
return;
}
document.addEventListener(
'submit',
gateThemeSubmit,
true
);
document.addEventListener(
'focusin',
prewarm,
true
);
document.addEventListener(
'pointerdown',
prewarm,
true
);
new MutationObserver(
discoverPortal
).observe(
document.documentElement,
{
childList: true,
subtree: true
}
);
discoverPortal();
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
start,
{
once: true
}
);
} else {
start();
}
})();Wichtiger Punkt
Der Browseradapter sorgt für eine saubere Benutzerführung.
Die eigentliche Sicherheitsgrenze bleibt aber Nginx.
Selbst wenn sich Ghost Portal nach einem Update so verändert, dass der Browseradapter nicht mehr korrekt funktioniert, darf ein direkter unautorisierter Magic-Link-Request weiterhin nicht Ghost erreichen.
Schritt 9: Ghost Code Injection ergänzen
Die öffentliche Browserkonfiguration kommt in die globale Ghost Header Code Injection.
Einfügeort: Ghost Admin → Settings → Advanced → Code injection → Site Header
<!-- Turnstile Protection for Ghost CMS -->
<script id="ghost-turnstile-config" type="application/json">
{
"sitekey": "YOUR_PUBLIC_SITEKEY",
"hostname": "www.example.com",
"action": "ghost_magic_link",
"redeemUrl": "/_ghost-turnstile/redeem",
"ticketTtlSeconds": 120
}
</script>
<!-- Local browser adapter. -->
<script
defer
src="/_ghost-turnstile/client.js?v=ASSET_HASH">
</script>
<!-- Official Cloudflare Turnstile browser API. -->
<script
defer
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit">
</script>Anpassen
YOUR_PUBLIC_SITEKEY wird durch euren öffentlichen Turnstile-Sitekey ersetzt.
ASSET_HASH dient nur als Cachebuster, beispielsweise als kurzer Präfix des SHA-256-Hashes der JavaScript-Datei.
4. Tests vor dem produktiven Einsatz
Die Schutzschicht sollte nicht nur danach beurteilt werden, ob irgendwo ein Turnstile-Widget erscheint.
Entscheidend ist, dass sich der geschützte Endpunkt nicht umgehen lässt und Ghost ansonsten normal weiterarbeitet.
4.1 Nginx-Konfiguration prüfen
Zuerst:
nginx -tErst danach:
systemctl reload nginx4.2 Negativtest: direkter Magic-Link-Request
Ein Request ohne gültiges Einmal-Ticket muss blockiert werden.
Terminal:
# A direct request without a valid one-time ticket must not reach Ghost.
curl \
-o /dev/null \
-w "%{http_code}\n" \
-X POST \
-H "Content-Type: application/json" \
--data '{}' \
https://www.example.com/members/api/send-magic-linkErwartet wird: HTTP Return-Code 403
Dasselbe muss für die Variante mit abschließendem Slash gelten: /members/api/send-magic-link/
4.3 Theme-Formulare testen
Mindestens testen:
Anmeldung
- E-Mail eingeben,
- Absenden,
- Turnstile muss vor dem Ghost-Request abgeschlossen werden,
- anschließend genau ein Magic-Link-Request.
Registrierung / Subscribe
- dasselbe Verhalten,
- keine direkte Umgehung des Turnstile-Schritts.
Falls die Site mehrsprachig ist, sollten alle Sprachvarianten getestet werden.
4.4 Ghost Portal testen
Portal sollte separat geprüft werden.
Mein Adapter erwartet aktuell unter anderem:
iframe[title="portal-popup"].gh-portal-popup-wrapper.signin.gh-portal-popup-wrapper.signupinput[type="email"][name="email"]button[type="submit"]
Folgende Fälle sollten funktionieren:
- Portal Signup per Klick,
- Portal Sign-in per Klick,
- Absenden mit Enter im E-Mail-Feld.
Nach Ghost-Updates sollte dieser Test erneut durchgeführt werden.
4.5 Desktop und Mobil testen
Turnstile kann sich abhängig von Browser, Bildschirmgröße und Risikoeinschätzung unterschiedlich verhalten.
Deshalb teste ich mindestens:
- Desktop,
- Mobilansicht,
- interaktive Challenge,
- nicht-interaktive erfolgreiche Prüfung,
- Fehler- beziehungsweise Retry-Fall.
4.6 Einen echten Mailversand testen
Erst wenn die vorherigen Tests erfolgreich sind, folgt ein echter Versandtest.
Dafür reicht eine dafür vorgesehene Testadresse.
Ziel ist nicht, hunderte Testmails zu erzeugen, sondern zu bestätigen:
- Turnstile erfolgreich,
- Ticket erfolgreich,
- Ghost erhält genau einen Request,
- Magic-Link-Mail kommt an.
4.7 Rest der Website prüfen
Zusätzlich kontrolliere ich, ob andere Bereiche unverändert funktionieren:
- Startseite,
- Ghost Admin,
- Content API,
- RSS,
- Sitemaps,
- Kommentare,
- Analytics,
- ActivityPub und WebFinger, sofern verwendet,
- Datenschutzseiten.
4.8 Logs prüfen
Nach dem Test sollten die Logs kontrolliert werden.
Insbesondere sollten dort nicht auftauchen:
- das Turnstile-Secret,
- vollständige Turnstile-Tokens,
- Ticket-Cookie-Werte,
- Test-E-Mail-Adressen,
- vollständige Siteverify-Payloads.
Hilfreich sind dagegen feste technische Ereignisse wie:
configuration_invalidticket_allocation_failedsiteverify_unavailablesiteverify_invalid_transportsiteverify_invalid_json
5. Was bei meinem Rollout nicht auf Anhieb funktionierte
Ein produktiver Erfahrungsbericht ist aus meiner Sicht hilfreicher, wenn er nicht nur den fertigen Zustand zeigt.
Die mitgelieferte njs-Version war mir zu alt
Meine Distribution stellte eine ältere njs-Version bereit.
Für den neuen produktiven Einsatz habe ich deshalb eine aktuellere Version passend zum vorhandenen Nginx gebaut.
Der erste Build scheiterte an fehlenden Abhängigkeiten
Zusätzliche Entwicklungsbibliotheken wurden benötigt.
Außerdem wollten die Standard-Buildoptionen Komponenten einbinden, die ich für diesen Anwendungsfall nicht benötigte. Diese habe ich gezielt deaktiviert.
Ghost Admin API Tokens konnten die Code Injection nicht verändern
Lesen funktionierte in meiner Umgebung.
Schreibzugriffe auf die entsprechenden Settings wurden jedoch abgewiesen. Deshalb habe ich die Code Injection manuell über Ghost Admin eingetragen.
Das Turnstile-Widget blieb zunächst unsichtbar
Die Ursache lag in der Widget-Konfiguration im Cloudflare-Dashboard.
Nach der Umstellung auf Managed funktionierte die erwartete Darstellung.
Mein erster Mobiltest hatte eine falsche Annahme
Der Test erwartete eine breitere Widgetbox als Cloudflare auf kleinen Displays tatsächlich verwendet.
Ich habe den Test deshalb an das reale Verhalten angepasst und nicht umgekehrt.
Bereits vorhandene Probleme müssen getrennt betrachtet werden
Wenn Ghost oder der Server bereits vor dem Rollout gelegentlich Timeouts oder andere Auffälligkeiten zeigen, sollte das dokumentiert werden.
Sonst besteht die Gefahr, alte Probleme später fälschlicherweise der neuen Schutzschicht zuzuschreiben.
Meine Regel bei unerwarteten Produktionsproblemen:
Erreichbarkeit zuerst wiederherstellen, Ursachenanalyse danach.
6. Datenschutz
Turnstile erzeugt eine direkte Verbindung zwischen dem Browser des Besuchers und Cloudflare.
Das sollte transparent in der Datenschutzerklärung beschrieben werden.
Ich dokumentiere mindestens:
- den Zweck: Schutz von Anmeldung, Registrierung und Magic-Link-Versand vor automatisiertem Missbrauch,
- die direkte Verbindung zu Cloudflare,
- mögliche technische Verbindungs-, Browser-, Geräte- und Seitendaten,
- die serverseitige Siteverify-Prüfung,
- ob
remoteipzusätzlich übertragen wird, - das kurzlebige Einmal-Ticket,
- die Cookie-Eigenschaften,
- den Anbieter,
- einen Link auf Cloudflares Datenschutzhinweise.
In meiner Implementierung sende ich remoteip nicht zusätzlich an Siteverify.
Das ist eine technische Beschreibung meiner Umsetzung und keine rechtliche Bewertung.
7. Betrieb und Sicherheitsgrenzen
7.1 Turnstile ist keine absolute Bot-Sperre
Turnstile erschwert automatisierten Missbrauch deutlich, verhindert aber nicht jede mögliche Automation.
Denkbar bleiben beispielsweise:
- menschliche CAPTCHA-Solver,
- hochwertige Browserautomation,
- kompromittierte echte Browser,
- Missbrauch innerhalb der erlaubten Rate-Limits nach erfolgreicher Prüfung.
7.2 Das Ticket ist lokal
Das njs Shared Dictionary existiert nur auf dem jeweiligen Nginx-Origin.
Bei mehreren Originservern benötigt ihr beispielsweise:
- Sticky Routing,
- oder einen gemeinsamen Ticketspeicher.
7.3 Nginx-Reload kann Tickets verwerfen
Ein Nginx-Reload kann gerade ausgestellte Tickets ungültig machen.
Da sie ohnehin nur maximal zwei Minuten gelten, halte ich das für vertretbar.
Im schlimmsten Fall muss ein Besucher Turnstile erneut durchlaufen.
7.4 Verhalten bei Cloudflare-Ausfall
Meine Konfiguration arbeitet fail closed.
Bei einem Ausfall der Turnstile-Prüfung funktionieren weiterhin:
- normale Seiten,
- Ghost Admin,
- Content API,
- RSS,
- Sitemaps.
Nicht möglich ist vorübergehend:
- neuer Magic-Link-Versand.
Für einen Endpunkt, der externe E-Mails auslösen kann, halte ich dieses Verhalten für sinnvoller als einen automatischen ungeschützten Fallback.
7.5 Secrets
Das Turnstile-Secret sollte sofort rotiert werden, wenn es versehentlich in:
- Logs,
- Backups,
- Chats,
- Ticketsystemen,
- Shellargumenten
auftaucht.
Der Sitekey dagegen ist öffentlich.
8. Mehrere Ghost-Instanzen hinter demselben Nginx
Die bisherige Konfiguration ist bewusst für eine einzelne Ghost-Website ausgelegt.
Bei mehreren Instanzen sollten globale Bestandteile nicht für jede Site erneut definiert werden.
Global nur einmal
Zum Beispiel:
- njs-Modul,
js_import,- Shared Dictionary,
- Rate-Limit-Zonen.
Pro Site getrennt
Zum Beispiel:
- Turnstile-Secret,
- Origin,
- Hostname,
- Sitekey,
- Ghost-Upstream,
- vHost-Locations.
Eine mögliche Konfiguration könnte mehrere Sites enthalten:
{
"sites": {
"www.site-a.example": {
"secret": "SECRET_A",
"origin": "https://www.site-a.example",
"action": "ghost_magic_link",
"ticketTtlSeconds": 120
},
"www.site-b.example": {
"secret": "SECRET_B",
"origin": "https://www.site-b.example",
"action": "ghost_magic_link",
"ticketTtlSeconds": 120
}
}
}Der njs-Code müsste dann anhand des normalisierten Request-Hosts den richtigen Site-Eintrag auswählen.
Für gemeinsam genutzte Browserassets würde ich bei mehreren Ghost-Instanzen außerdem einen neutralen Pfad wie /usr/local/share/ghost-turnstile/ bevorzugen.
9. Fazit
Die entscheidende Idee ist nicht, einfach ein CAPTCHA vor ein Ghost-Formular zu setzen.
Die eigentliche Sicherheitsgrenze muss serverseitig vor dem Endpunkt liegen, der tatsächlich die E-Mail auslöst.
Der Browser führt zunächst Turnstile aus. Eine erfolgreiche Prüfung wird serverseitig gegen Cloudflare Siteverify validiert und anschließend in ein kurzlebiges Einmal-Ticket übersetzt.
Nginx akzeptiert genau einen Magic-Link-Request mit diesem Ticket und verwirft es danach.
Damit sind Theme und Ghost Portal zwar wichtig für die Benutzerführung, aber nicht die eigentliche Sicherheitsgrenze.
Die liegt vor:
/members/api/send-magic-link
Ghost selbst musste ich dafür weder patchen noch forken.
Es läuft auch kein zusätzlicher eigener Anwendungsdienst.
Die Lösung besteht aus einer klar abgegrenzten Schutzschicht aus:
- Cloudflare Turnstile,
- Nginx,
- njs,
- einem Browseradapter,
- kurzlebigen Einmal-Tickets.
Sie lässt sich getrennt testen, zurückrollen und nach Ghost-Updates gezielt auf Kompatibilität prüfen.
Genau das war für mich wichtiger als eine tief in Ghost eingebaute Sonderlösung.
10. Offizielle Dokumentation
- Cloudflare Siteverify: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
- Cloudflare Client Rendering: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
- Cloudflare Widget Configuration: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/
- Cloudflare Test Keys: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
- Cloudflare Hostname Management: https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/
- Nginx
auth_request: https://nginx.org/en/docs/http/ngx_http_auth_request_module.html - njs HTTP Module: https://nginx.org/en/docs/http/ngx_http_js_module.html
- njs Reference: https://nginx.org/en/docs/njs/reference.html
- njs Security Advisories: https://nginx.org/en/docs/njs/security.html
- Ghost Members in Themes: https://docs.ghost.org/themes/members
- Ghost Admin API: https://docs.ghost.org/admin-api