fix(seo): Sicherheits-Header, strukturierte Daten, llms.txt und 404-Seite #98

Merged
Phil merged 6 commits from fix/security-header-und-schema into main 2026-09-07 09:52:23 +00:00
Owner

Closes #84
Refs #59

Sicherheits-Header, ohne sie durch Inline-Code zu entwerten

Die Seite lieferte weder HSTS noch CSP, Referrer-Policy oder Permissions-Policy,
dafür X-Powered-By: Craft CMS auf jeder Antwort.

Drei Dinge daran waren nicht offensichtlich:

nginx vererbt Kopfzeilen nicht. Sobald ein Block eine eigene setzt, verliert
er alle der Ebene darüber. Sie liegen deshalb in einem Baustein, der pro Block
eingebunden wird.

Die CSP lässt sich nicht per Block ausnehmen. Jede Seite läuft über den
PHP-Block, weil der Haupt-Router dorthin weiterreicht, und nginx wertet die
Kopfzeile im Ziel-Block aus. Eine Ausnahme für /admin über einen eigenen Block
war messbar wirkungslos. Der Wert kommt jetzt aus einer Zuordnung; leer heißt,
die Kopfzeile entfällt.

Eine CSP mit unsafe-inline bei Skripten wäre Theater. Das einzige
Inline-Skript der Seite war der Matomo-Aufruf. Er liegt jetzt im JS-Bundle und
liest seine Konfiguration aus Datenattributen. Für Stile bleibt unsafe-inline,
weil die Seite an 44 Stellen berechnete Stilattribute setzt und Stil-Injektion
ohne Skriptausführung kaum Schaden anrichtet.

HSTS gilt ein Jahr, ohne Subdomain-Klausel und ohne preload: raven., cal. und
analytics.philflow.io sollen sich nicht über diese Seite aussperren können.

Der Rest von #84

  • 404-Seite bat um Indexierung, kanonisierte auf sich selbst, verwies auf
    Sprachfassungen, die es nicht gibt, und durfte eine Stunde lang öffentlich
    zwischengespeichert werden. Jetzt noindex, kein Canonical, keine
    Sprachverweise, no-store.
  • Strukturierte Daten gab es nur auf Blogartikeln. Jetzt Organisation und
    Website auf jeder Seite. Die FAQ der Startseite bleibt bewusst Microdata, sie
    zweimal auszuzeichnen wäre eine Fehlerquelle. Aus demselben Grund fällt die
    Microdata am Artikel weg. Am Artikel-Schema außerdem: Bild-URL war relativ
    (Google verwirft das), Herausgeber war eine Person statt der Organisation, die
    Profillinks stimmten mit nichts überein, leere Felder landeten als null.
  • Schrägstrich-Dubletten leiten jetzt um, der englische Site-Root ausgenommen.
  • llms.txt beschrieb nur das Blog, während die Seite Prozessautomatisierung
    verkauft. Neu gefasst aus dem, was die Seite tatsächlich sagt, zweisprachig.
    llms-full.txt fällt weg: 417 Zeilen alte Positionierung, nirgends verlinkt.

Was ein Prüfagent gefunden hat, und was ich selbst fand

Die Schrägstrich-Umleitung erzeugte an jedem Verzeichnis eine Endlosschleife:
/assets/images/ ging auf die schrägstrichlose Form, dort fand nginx ein
Verzeichnis und forderte per eigenem 301 die Schrägstrich-Form zurück. Aus einem
403 wurde ERR_TOO_MANY_REDIRECTS. Behoben.

Die Person Philipp Lütje war auf Artikelseiten zweimal mit widersprüchlichen
Namen
definiert. Und Organisation wie Person hingen an der jeweils aktuellen
Sprache, /en/ trug also eine eigene Kennung: dieselbe Firma erschien als zwei
Entitäten. Beide hängen jetzt an der Hauptsite.

Selbst gefunden: der Matomo-Start lag nur im Quellcode, nicht im gebauten Bundle.
Ohne den Build wäre die Reichweitenmessung still ausgefallen. Dazu drei
Absturzursachen im Vorschaubild-Rückfall, alle behoben und nachgemessen.

Gemessen, nicht angenommen

  • Alle elf geprüften Seiten liefern 200, /assets/images/ löst sich nach genau
    einer Umleitung in 404 auf, keine Schleife
  • Vier Sicherheits-Kopfzeilen auf jedem Antworttyp, X-Powered-By weg
  • /admin bekommt korrekt keine CSP, Craft setzt dort seine eigene
  • Null ausführbare Inline-Skripte auf sechs Seitentypen, keine externen
    Skript-Hosts, keine data:- oder blob:-Quellen, kein eval im Bundle
  • JSON-LD auf allen Seitentypen valide, keine doppelten Kennungen, keine
    null-Werte, alle Bild-URLs absolut, 404 trägt korrekt keins
  • Vorschaubild auf allen sechs Seitentypen gesetzt und absolut
  • Alle 24 Links in llms.txt liefern 200

Nicht geprüft: die CSP im echten Browser gegen die laufende Konfiguration.
Der Prüfagent kam nicht an den Browser, und der lokale nginx lief noch mit der
alten Konfiguration. Die Quelltextanalyse über sechs Seitentypen ist eindeutig,
aber das ersetzt keinen Konsolenlauf. Wird direkt nach dem Deploy live
nachgeholt; findet sich dort etwas, ist die Kopfzeile in einer Minute wieder
entschärft.

Offen gelassen (P3): selbst gebaute Umleitungen nehmen den vom Besucher
geschickten Host. Kein offener Redirect, aber unsauber. Und // liefert weiter
200 statt umzuleiten, das ist Altbestand.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N

Closes #84 Refs #59 ## Sicherheits-Header, ohne sie durch Inline-Code zu entwerten Die Seite lieferte weder HSTS noch CSP, Referrer-Policy oder Permissions-Policy, dafür `X-Powered-By: Craft CMS` auf jeder Antwort. Drei Dinge daran waren nicht offensichtlich: **nginx vererbt Kopfzeilen nicht.** Sobald ein Block eine eigene setzt, verliert er alle der Ebene darüber. Sie liegen deshalb in einem Baustein, der pro Block eingebunden wird. **Die CSP lässt sich nicht per Block ausnehmen.** Jede Seite läuft über den PHP-Block, weil der Haupt-Router dorthin weiterreicht, und nginx wertet die Kopfzeile im Ziel-Block aus. Eine Ausnahme für `/admin` über einen eigenen Block war messbar wirkungslos. Der Wert kommt jetzt aus einer Zuordnung; leer heißt, die Kopfzeile entfällt. **Eine CSP mit `unsafe-inline` bei Skripten wäre Theater.** Das einzige Inline-Skript der Seite war der Matomo-Aufruf. Er liegt jetzt im JS-Bundle und liest seine Konfiguration aus Datenattributen. Für Stile bleibt `unsafe-inline`, weil die Seite an 44 Stellen berechnete Stilattribute setzt und Stil-Injektion ohne Skriptausführung kaum Schaden anrichtet. HSTS gilt ein Jahr, ohne Subdomain-Klausel und ohne preload: `raven.`, `cal.` und `analytics.philflow.io` sollen sich nicht über diese Seite aussperren können. ## Der Rest von #84 - **404-Seite** bat um Indexierung, kanonisierte auf sich selbst, verwies auf Sprachfassungen, die es nicht gibt, und durfte eine Stunde lang öffentlich zwischengespeichert werden. Jetzt `noindex`, kein Canonical, keine Sprachverweise, `no-store`. - **Strukturierte Daten** gab es nur auf Blogartikeln. Jetzt Organisation und Website auf jeder Seite. Die FAQ der Startseite bleibt bewusst Microdata, sie zweimal auszuzeichnen wäre eine Fehlerquelle. Aus demselben Grund fällt die Microdata am Artikel weg. Am Artikel-Schema außerdem: Bild-URL war relativ (Google verwirft das), Herausgeber war eine Person statt der Organisation, die Profillinks stimmten mit nichts überein, leere Felder landeten als `null`. - **Schrägstrich-Dubletten** leiten jetzt um, der englische Site-Root ausgenommen. - **`llms.txt`** beschrieb nur das Blog, während die Seite Prozessautomatisierung verkauft. Neu gefasst aus dem, was die Seite tatsächlich sagt, zweisprachig. `llms-full.txt` fällt weg: 417 Zeilen alte Positionierung, nirgends verlinkt. ## Was ein Prüfagent gefunden hat, und was ich selbst fand Die Schrägstrich-Umleitung erzeugte an **jedem Verzeichnis eine Endlosschleife**: `/assets/images/` ging auf die schrägstrichlose Form, dort fand nginx ein Verzeichnis und forderte per eigenem 301 die Schrägstrich-Form zurück. Aus einem 403 wurde `ERR_TOO_MANY_REDIRECTS`. Behoben. Die Person Philipp Lütje war auf Artikelseiten **zweimal mit widersprüchlichen Namen** definiert. Und Organisation wie Person hingen an der jeweils aktuellen Sprache, `/en/` trug also eine eigene Kennung: dieselbe Firma erschien als zwei Entitäten. Beide hängen jetzt an der Hauptsite. Selbst gefunden: der Matomo-Start lag nur im Quellcode, nicht im gebauten Bundle. Ohne den Build wäre die Reichweitenmessung still ausgefallen. Dazu drei Absturzursachen im Vorschaubild-Rückfall, alle behoben und nachgemessen. ## Gemessen, nicht angenommen - Alle elf geprüften Seiten liefern 200, `/assets/images/` löst sich nach genau einer Umleitung in 404 auf, keine Schleife - Vier Sicherheits-Kopfzeilen auf jedem Antworttyp, `X-Powered-By` weg - `/admin` bekommt korrekt keine CSP, Craft setzt dort seine eigene - Null ausführbare Inline-Skripte auf sechs Seitentypen, keine externen Skript-Hosts, keine `data:`- oder `blob:`-Quellen, kein `eval` im Bundle - JSON-LD auf allen Seitentypen valide, keine doppelten Kennungen, keine `null`-Werte, alle Bild-URLs absolut, 404 trägt korrekt keins - Vorschaubild auf allen sechs Seitentypen gesetzt und absolut - Alle 24 Links in `llms.txt` liefern 200 **Nicht geprüft:** die CSP im echten Browser gegen die laufende Konfiguration. Der Prüfagent kam nicht an den Browser, und der lokale nginx lief noch mit der alten Konfiguration. Die Quelltextanalyse über sechs Seitentypen ist eindeutig, aber das ersetzt keinen Konsolenlauf. Wird direkt nach dem Deploy live nachgeholt; findet sich dort etwas, ist die Kopfzeile in einer Minute wieder entschärft. **Offen gelassen (P3):** selbst gebaute Umleitungen nehmen den vom Besucher geschickten Host. Kein offener Redirect, aber unsauber. Und `//` liefert weiter 200 statt umzuleiten, das ist Altbestand. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N
Die Seite lieferte weder HSTS noch CSP, Referrer-Policy oder
Permissions-Policy, dafuer "X-Powered-By: Craft CMS" auf jeder Antwort.

Drei Dinge, die dabei nicht offensichtlich sind:

nginx vererbt add_header nicht: sobald ein location-Block eine eigene
Kopfzeile setzt, verliert er alle der Ebene darueber. Die Kopfzeilen liegen
deshalb in einem Baustein, der pro Block eingebunden wird.

Die CSP kann nicht ueber einen location-Block ausgenommen werden. Jede Seite
laeuft ueber "location ~ \.php$", weil location / per try_files dorthin
weiterreicht, und nginx wertet add_header im Ziel-Block aus. Eine Ausnahme
fuer /admin ueber einen eigenen Block war messbar wirkungslos. Der Wert kommt
jetzt aus einer Zuordnung; leer heisst, die Kopfzeile entfaellt.

Eine CSP mit 'unsafe-inline' bei script-src waere Theater. Das einzige
Inline-Script der Seite war der Matomo-Aufruf; er liegt jetzt im JS-Bundle und
liest seine Konfiguration aus data-Attributen. style-src behaelt 'unsafe-inline',
weil die Seite an rund 44 Stellen berechnete style-Attribute setzt und
Style-Injection ohne Script-Ausfuehrung kaum Schaden anrichtet.

HSTS gilt ein Jahr, bewusst ohne includeSubDomains und ohne preload: raven.,
cal. und analytics.philflow.io sollen sich nicht ueber diese Seite aussperren
koennen.

Dazu: Umleitung ueberfluessiger Schraegstriche (der EN-Site-Root ausgenommen,
sonst entstuende eine Schleife), server_tokens aus, sendPoweredByHeader aus.
Der lokale vhost liegt nicht mehr in conf.d, weil das Verzeichnis auf Prod
komplett gemountet wird und die Datei dort ein zweiter server-Block ohne
Sicherheits-Kopfzeilen war.

Refs #84

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N
Die Datei ist das erste, was ChatGPT und Perplexity lesen, wenn sie ueber
Philflow Auskunft geben. Sie beschrieb bis heute nur das Blog: sechs Artikel und
eine Sitemap, kein Wort ueber Automatisierung, Kunden, Pakete oder Preise. Die
Seite verkauft aber Prozessautomatisierung im Mittelstand.

Neu gefasst aus dem, was die Seite tatsaechlich sagt: Positionierung, die drei
selbst gebauten Belege, die Preisstaffel, dann das Blog. Zweisprachig, mit den
englischen Artikeln. Alle Buchungslinks zeigen auf raven.ceo, und die Datei sagt
ausdruecklich, dass es keine Kontaktseite gibt.

llms-full.txt faellt weg: 417 Zeilen alte Agentur-Positionierung
("AI-Betriebssysteme fuer den Mittelstand", "Intelligence Platform"), nirgends
verlinkt, eine zweite Wahrheit neben der ersten. Die nginx-Regel deckt jetzt nur
noch die eine Datei ab.

Refs #84

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N
Zwei Aenderungen an derselben Datei, deshalb zusammen.

Die Fehlerseite lieferte korrekt Status 404, im Kopf aber "index, follow", ein
Canonical auf die nicht existierende Adresse selbst, Sprachverweise auf ebenso
nicht existierende Adressen und "Cache-Control: public, max-age=3600". Eine
Fehlerseite wurde damit eine Stunde lang oeffentlich zwischengespeichert. Sie
setzt jetzt eine Markierung auf oberster Ebene, bevor das Layout rendert;
seo.twig liefert daraufhin noindex, kein Canonical, keinen Link-Kopf, keine
Sprachverweise und "no-store". Gemessen mit abgeschaltetem Entwicklermodus,
weil Craft sonst gar nicht 404.twig rendert.

Nicht im Issue, selbst gemessen: das Vorschaubild fuer geteilte Links war auf
fuenf von sechs Seitentypen leer. Nur die beiden Startseiten hatten eins im
SEO-Feld, Blog, Artikel, Kategorie-Archiv und Rechtsseiten nicht. Es gibt jetzt
einen Rueckfall auf das Hero-Bild des Artikels und sonst auf das Bild der
Startseite, immer als absolute Adresse: der Asset-Filesystem liefert relative
URLs, und geteilte Links zeigen damit ins Leere.

Refs #84

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N
JSON-LD gab es nur auf Blogartikeln. Startseite, beide Blog-Uebersichten, beide
Kategorie-Archive und alle Rechtsseiten hatten keins.

Neu: eine site-weite Auszeichnung mit Organisation und Website, auf jeder Seite
ausser Fehlerseiten. Beide Knoten tragen stabile IDs, damit das Artikel-Schema
als Herausgeber darauf verweisen kann statt auf eine Person.

Die FAQ der Startseite ist bewusst nicht zusaetzlich als JSON-LD ausgezeichnet:
sie liegt bereits als Microdata im FAQ-Block. Dieselbe Sache zweimal
auszuzeichnen ist eine Fehlerquelle, keine Verbesserung. Aus demselben Grund
faellt die Microdata am Artikel weg, dort ist das JSON-LD die Auszeichnung.

Am Artikel-Schema ausserdem korrigiert: die Bild-URL war relativ, weil der
Asset-Filesystem relative URLs liefert, und Google verwirft ein solches Bild.
Die drei Profillinks stimmten weder mit dem Footer noch mit llms.txt ueberein.
Leere Felder landeten als null im JSON.

Und fuer den naechsten, der hier arbeitet: Twig-Kommentare duerfen nicht
innerhalb der Objektliterale stehen, der Parser bricht daran ab. Steht als
Warnung im Dateikopf.

Refs #84, #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N
Der Matomo-Start war nach dem Umzug aus dem Inline-Script nur im Quellcode,
nicht im gebauten Bundle. Ohne diesen Build waere die Reichweitenmessung auf
Prod still ausgefallen: kein Inline-Script mehr, aber auch kein Ersatz.

Refs #84

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N
Ein unabhaengiger Pruefer hat drei Fehler gefunden, alle hier behoben.

Die Trailing-Slash-Umleitung erzeugte an jedem Verzeichnis eine Endlosschleife:
/assets/images/ ging auf die schraegstrichlose Form, dort fand nginx ein
Verzeichnis und forderte per eigenem 301 die Schraegstrich-Form zurueck. Aus
einem 403 wurde ERR_TOO_MANY_REDIRECTS. Das Index-Modul faellt jetzt weg, unter
web/ liegt ohnehin keine einzige index.html; Verzeichnisse landen bei Craft und
damit auf der 404-Seite.

Die Person "Philipp Luetje" wurde auf Artikelseiten zweimal mit
widersprechenden Namen definiert, einmal site-weit und einmal im Artikel als
"Phil". Der Artikel referenziert sie jetzt nur noch, wie den Herausgeber auch.

Organisation und Person hingen an der jeweils aktuellen Site, /en/ trug also
eine eigene Kennung: dieselbe Firma erschien fuer Suchmaschinen als zwei
Entitaeten. Beide haengen jetzt an der Hauptsite. Die Website bleibt pro
Sprache, das ist richtig so.

Beim eigenen Nachmessen kamen drei Absturzursachen im Vorschaubild-Rueckfall
dazu: der Rueckfall wurde auf jeder Seite ausgewertet statt nur wenn noetig,
er erzwang eine Bildtransformation, die an einem 5,6 MB grossen Original
scheiterte und die ganze Seite mitriss, und `is defined` meldet ein Feld auch
dann als vorhanden, wenn der Entry-Typ es gar nicht hat.

Refs #84

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyiQJ8BM3hcqH3E3sPvg1N
Phil merged commit 5df9df1e3f into main 2026-09-07 09:52:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Phil/philflow.io!98
No description provided.