Shopware 6: „PageController can't be requested via XmlHttpRequest“ – wer da anfragt und was du tun solltest
Im var/log/prod-*.log stehen ein paar hundert Einträge pro Tag, alle mit derselben Meldung, nur Route und URL wechseln. Mal ist es die Kategorieseite, mal die Registrierung, mal der Login. Der Shop läuft, Kunden beschweren sich nicht, und trotzdem landet jede dieser Zeilen auf Level error. Kaputt ist dabei fast nie etwas.
Die Fehlermeldung
request.ERROR: Uncaught PHP Exception Shopware\Core\Framework\Routing\RoutingException:
"PageController "frontend.navigation.page" ("https://example-shop.de/navigation/018f2c3d4e5f6a7b8c9d0e1f2a3b4c5d")
can't be requested via XmlHttpRequest. Requested by "https://example-shop.de/"."
at RoutingException.php line 89
Varianten mit frontend.account.register.save, frontend.account.login.page, frontend.search.page, frontend.detail.page, frontend.checkout.switch-language oder frontend.checkout.product.add-by-number gehören zur selben Familie. Auf Shops vor 6.7.1 fehlen Route, URL und Referer, dort steht nur PageController can't be requested via XmlHttpRequest. Die ausführlichere Meldung kam mit Shopware 6.7.1.0 (Changelog-Eintrag „Improved XmlHttpRequest denied exception information“).
Was dahintersteckt
Shopware unterscheidet in der Storefront zwischen ganzen Seiten und Widgets. Seiten liefern das komplette HTML mit Header, Footer und Meta-Daten. Widgets liefern Fragmente, die das Storefront-JavaScript nachlädt: die Offcanvas-Navigation unter /widgets/menu/offcanvas, das Produkt-Listing unter /widgets/cms/navigation/{navigationId}, der Variantenwechsel in der Buybox. Nur Routen, die in ihren Defaults 'XmlHttpRequest' => true tragen, dürfen per AJAX aufgerufen werden.
Die Prüfung sitzt in Shopware\Storefront\Framework\Routing\StorefrontSubscriber::preventPageLoadingFromXmlHttpRequest(). Gekürzt:
if (!$request->isXmlHttpRequest()) {
return;
}
// nur Routen im Storefront-Scope
$isAllowed = $request->attributes->getBoolean('XmlHttpRequest');
if ($isAllowed) {
return;
}
throw RoutingException::accessDeniedForXmlHttpRequest($route, $url, $referer);
Und isXmlHttpRequest() in Symfony macht nichts anderes, als den Header X-Requested-With mit dem Wert XMLHttpRequest zu vergleichen. Das ist der ganze Mechanismus. Wer diesen Header mitschickt und eine Seitenroute anfragt, bekommt eine 403 mit dem Fehlercode FRAMEWORK__ACCESS_DENIED_FOR_XML_HTTP_REQUEST. Weil Symfony nicht abgefangene HTTP-Exceptions unter 500 auf Level error loggt, steht jede dieser Anfragen in deinem Produktionslog.
Den Header setzt kein Browser von sich aus. Er kommt also immer von Code, und davon gibt es zwei Sorten.
Die erste sind Bots und Scanner. Viele Crawler und Schwachstellen-Scanner schicken X-Requested-With: XMLHttpRequest pauschal mit. Ein Beispiel aus echten Logs, anonymisiert: eine Suche nach {search_term_string}'||DBMS_PIPE.RECEIVE_MESSAGE(CHR(98)||CHR(98)||CHR(98),15)||'. Das ist eine Oracle-SQL-Injection-Probe gegen einen MySQL-Shop, und Shopware hat sie an dieser Stelle abgewiesen, bevor der Controller überhaupt lief. Dasselbe gilt für Massen von frontend.account.register.save-Treffern: Spam-Registrierungen per Skript.
Die zweite Sorte ist dein eigener Code. Ein Theme oder Plugin lädt eine komplette Kategorieseite per AJAX nach, etwa für Endlos-Scrollen, einen eigenen Filter oder ein Login-Popup. Der Storefront-HttpClient setzt den Header automatisch (er ist übrigens zum Entfernen in 6.8 markiert, natives fetch() setzt ihn nicht). Wenn so ein Aufruf eine Seitenroute statt eines Widgets trifft, scheitert er bei jedem Besuch, und das Feature funktioniert für Kunden nicht.
Prüfen, ob es wirklich das ist
Die Unterscheidung zwischen Bot und eigenem Code ist das Einzige, was hier zählt. Ab 6.7.1 liefert die Meldung die Hinweise gleich mit.
Schau dir den Referer an. Steht dort nur die Startseite oder gar nichts, und die URL enthält Payloads wie DBMS_PIPE, select(0)from oder %27, ist es ein Scanner. Ist der Referer eine echte Kategorie- oder Produktseite mit sinnvollen Parametern (?p=8&order=...), dann hat sehr wahrscheinlich JavaScript auf genau dieser Seite den Request abgeschickt.
Für ältere Versionen oder wenn du sicher sein willst, schreib den Header ins Access-Log. Für nginx:
log_format xhr '$remote_addr [$time_local] "$request" $status '
'"$http_referer" "$http_user_agent" xrw="$http_x_requested_with"';
access_log /var/log/nginx/shop-xhr.log xhr;
Danach findest du die betroffenen Anfragen mit:
grep 'xrw="XMLHttpRequest"' /var/log/nginx/shop-xhr.log | grep ' 403 ' | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head
Das gibt die häufigsten User-Agents aus. Kommen die Treffer fast alle von einer Handvoll IPs mit Python-, Go- oder Scanner-Agents, ist der Fall klar. Kommen sie von normalen Browsern verteilt über viele IPs, sucht dein eigenes Frontend die falsche Route.
Gegenprobe im Browser: Entwicklertools öffnen, Netzwerk-Tab, Seite laden und das verdächtige Feature benutzen. Ein 403 auf eine Seiten-URL mit X-Requested-With im Request-Header ist der Beweis.
Die Lösung
Für eigenen Code gibt es zwei saubere Wege. Brauchst du nur das Listing einer Kategorie, ruf das passende Widget auf statt der Seite:
// statt /navigation/{id} oder der SEO-URL der Kategorie
const response = await fetch(`/widgets/cms/navigation/${navigationId}?p=2`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
frontend.cms.navigation.page ist genau dafür gedacht und als XHR-fähig markiert. Liefert dein eigener Controller Fragmente, gib der Route das Flag selbst:
#[Route(
path: '/widgets/my-plugin/teaser',
name: 'frontend.my-plugin.teaser',
defaults: ['XmlHttpRequest' => true],
methods: ['GET']
)]
Was du nicht tun solltest: das Flag per Decorator oder Route-Override an Core-Seiten wie frontend.detail.page hängen. Im Shopware-Forum hat das jemand probiert, der Fehler war weg, und danach scheiterte das Rendern mit Call to a member function setMetaDescription() on null. Seitenrouten erwarten den vollen Seitenkontext. Wenn du wirklich eine ganze Seite brauchst, frag sie ohne den Header an.
Für Bot-Traffic gibt es nichts zu reparieren. Shopware macht hier genau das Richtige. Es lohnt sich trotzdem, den Lärm aus dem Log zu nehmen, sonst verdecken ein paar tausend Scanner-Zeilen die Fehler, die wirklich zählen. Seit 6.5.2 kannst du das Log-Level pro Fehlercode umstellen, in config/packages/shopware.yaml:
shopware:
logger:
error_code_log_levels:
FRAMEWORK__ACCESS_DENIED_FOR_XML_HTTP_REQUEST: notice
bin/console cache:clear
Mit der Standard-Monolog-Konfiguration für prod (fingers_crossed mit action_level: error) taucht der Eintrag danach nicht mehr im Log auf. Der Request wird weiterhin mit 403 abgewiesen, nur das Logging ändert sich.
Das ist bewusst ein Kompromiss. Wenn dein eigenes Frontend die falsche Route aufruft, siehst du es nach dieser Änderung auch nicht mehr im Log. Mach die Prüfung oben also zuerst und stell erst dann um.
Wenn das nicht hilft
Taucht der Fehler nach einem Theme- oder Plugin-Update plötzlich in großer Zahl auf, mit Browser-User-Agents und echten Referern, liegt es fast immer an geändertem JavaScript, das jetzt eine andere URL anfragt. Ein Blick in den Netzwerk-Tab auf der Referer-Seite findet die Stelle schneller als jede Log-Analyse.
Wie viele dieser Zeilen pro Tag normal sind, weiß man erst, wenn man es eine Weile beobachtet hat. ShopSignal zählt Fehler und Warnungen deiner Shopware-Shops über 24 Stunden, macht die Logs per Volltextsuche durchsuchbar und meldet sich über eigene Alarmregeln, wenn die Zahl plötzlich nach oben springt. Damit fällt der Unterschied zwischen dem üblichen Bot-Rauschen und einem kaputten Theme-Update auf, bevor ein Kunde ihn bemerkt.