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')), }; // consent.js schickt dasselbe Formular im Hintergrund und kennzeichnet das mit dieser // Kopfzeile: dann keine Weiterleitung, nur das Cookie - die Seite bleibt stehen und das // Skript tauscht aus, was jetzt geladen werden darf (ADR 115). Ein fremdes Formular kann // die Kopfzeile nicht setzen; ein fremdes Skript bräuchte dafür eine CORS-Freigabe. $response = '1' === $request->headers->get('X-Kant-Consent') ? new Response(status: Response::HTTP_NO_CONTENT) : $this->redirect($this->backTo($request)); $response->headers->set('Cache-Control', 'no-store'); // 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. // Steuerzeichen und Leerraum ebenfalls nicht: Browser entfernen Tabulator und Zeilenumbruch // aus Adressen, aus `//fremd.example` würde so wieder `//fremd.example`. if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target) || 1 === preg_match('/[\x00-\x20\x7F\\\\]/', $target)) { return '/'; } return $target; } }