- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
5.6 KiB
PHP
114 lines
5.6 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')),
|
|
};
|
|
// 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 `/<Tab>/fremd.example` würde so wieder `//fremd.example`.
|
|
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target) || 1 === preg_match('/[\x00-\x20\x7F\\\\]/', $target)) {
|
|
return '/';
|
|
}
|
|
|
|
return $target;
|
|
}
|
|
}
|