SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber sends the other spelling there with 301, cms:site:origin writes the setting, the system screen shows it. The roles now match the role help (editor: drafts, publisher: live content, admin: design, settings, users, system). The installer no longer reopens on an unreachable database, the session cookie follows ADMIN_PATH, percent-encoded credentials no longer break the container, consent only accepts same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the editor, updater, download and public-output findings from the full review (ADR 099, ADR 100). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
107 lines
4.8 KiB
PHP
107 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\Consent;
|
|
use App\Content\SiteSettings;
|
|
use App\Content\Theme;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
/**
|
|
* Recording and changing the consent decision.
|
|
*
|
|
* **No CSRF token here, deliberately.** The request changes nothing on the server: it sets a
|
|
* cookie that only affects the browser that sent it. A token would need a session, and starting a
|
|
* session for every visitor is exactly what a consent banner exists to avoid. What is checked is
|
|
* the target of the redirect, so the form cannot be used to bounce someone to another site.
|
|
*/
|
|
final class ConsentController extends AbstractController
|
|
{
|
|
public function __construct(private Consent $consent, private SiteSettings $settings, private Theme $theme)
|
|
{
|
|
}
|
|
|
|
#[Route('/einwilligung', name: 'consent_save', methods: ['POST'])]
|
|
public function save(Request $request): Response
|
|
{
|
|
$choice = $request->request->getString('choice');
|
|
$groups = match ($choice) {
|
|
'all' => array_keys(Consent::GROUPS),
|
|
'none' => [],
|
|
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
|
|
};
|
|
$response = $this->redirect($this->backTo($request));
|
|
// Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular,
|
|
// das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und
|
|
// damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage.
|
|
if ($this->sameSite($request)) {
|
|
$this->consent->remember($request, $response, $groups);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/** Ob die Anfrage von dieser Website kommt - nach dem, was der Browser darueber sagt. */
|
|
private function sameSite(Request $request): bool
|
|
{
|
|
$site = $request->headers->get('Sec-Fetch-Site');
|
|
if (null !== $site) {
|
|
return \in_array($site, ['same-origin', 'same-site', 'none'], true);
|
|
}
|
|
$origin = $request->headers->get('Origin') ?? $request->headers->get('Referer');
|
|
if (null === $origin || '' === $origin) {
|
|
// Aeltere Browser nennen keine Herkunft; ohne Anhaltspunkt bleibt die Frage beantwortbar.
|
|
return true;
|
|
}
|
|
$host = parse_url($origin, PHP_URL_HOST);
|
|
|
|
return \is_string($host) && strtolower($host) === strtolower($request->getHost());
|
|
}
|
|
|
|
#[Route('/einwilligung', name: 'consent_page', methods: ['GET'])]
|
|
public function page(Request $request): Response
|
|
{
|
|
if ([] === $this->consent->askedGroups()) {
|
|
throw $this->createNotFoundException('Diese Website fragt nichts ab.');
|
|
}
|
|
// Deliberately without header and footer: this is a page about a decision, not a page of
|
|
// the website, and it must work even when an area is broken.
|
|
// Palette und Hinweisfarben wie auf den Seiten, damit die Entscheidung hier aussieht wie
|
|
// die Frage dort. Das Stylesheet ist ueber seinen Hash erlaubt, nichts weiter.
|
|
$theme = $this->theme->load();
|
|
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
|
$response = $this->render('consent.html.twig', [
|
|
'consent' => $this->consent->state($request),
|
|
'settings' => $this->settings->load(),
|
|
'pageStyles' => $styles,
|
|
]);
|
|
$response->headers->set('Cache-Control', 'no-store');
|
|
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
|
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
|
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
// The page carries no embeds, so it keeps the strictest policy - plus the one hashed style block.
|
|
$response->headers->set('Content-Security-Policy', sprintf("default-src 'self'; script-src 'none'; style-src 'self' 'sha256-%s'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", base64_encode(hash('sha256', $styles, true))));
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Back to where the form was submitted from, but only within this site: an open redirect
|
|
* would turn a consent button into a way to send people anywhere.
|
|
*/
|
|
private function backTo(Request $request): string
|
|
{
|
|
$target = $request->request->getString('back');
|
|
// Nur ein Pfad dieser Website: `//fremd.example` ist protokollrelativ, und `/\fremd.example`
|
|
// lesen Browser genauso.
|
|
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target)) {
|
|
return '/';
|
|
}
|
|
|
|
return $target;
|
|
}
|
|
}
|