Harden KantSites after the security review
- 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>
This commit is contained in:
1 parent
14e7a3b310
commit
ad7c333215
43 files changed
+810
-53
No files matched your search
@@ -102,7 +102,9 @@ final class ConsentController extends AbstractController
|
||||
$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)) {
|
||||
// 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 '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, false)) {
|
||||
return $blocked;
|
||||
}
|
||||
$terms = static function (string $raw): array {
|
||||
$names = array_filter(array_map(trim(...), explode(',', $raw)), static fn (string $n): bool => '' !== $n);
|
||||
|
||||
@@ -242,6 +245,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, false)) {
|
||||
return $blocked;
|
||||
}
|
||||
// An empty field means "the standard area"; that is stored as null, not as an address.
|
||||
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
|
||||
try {
|
||||
@@ -265,6 +271,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, true)) {
|
||||
return $blocked;
|
||||
}
|
||||
try {
|
||||
$moved = $this->pages->movePage(
|
||||
$id,
|
||||
@@ -282,6 +291,22 @@ final class EditorController extends AbstractController
|
||||
: sprintf('%d Adresse(n) geändert. Von den alten Adressen wird dauerhaft weitergeleitet.', $moved), 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Redakteur bearbeitet Entwürfe; was eine veröffentlichte Seite sofort verändert, darf
|
||||
* nur, wer veröffentlichen darf (ADR 116): Adresse, Papierkorb, Kopf- und Fußbereich,
|
||||
* Beitragsdaten. Bei Seiten, die nie veröffentlicht waren, bleibt alles wie bisher.
|
||||
*/
|
||||
private function liveNeedsPublisher(string $id, bool $subtree): ?Response
|
||||
{
|
||||
if ($this->isGranted('ROLE_PUBLISHER') || !$this->pages->isLive($id, $subtree)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->back($subtree
|
||||
? 'Diese Seite oder eine ihrer Unterseiten ist veröffentlicht. Adresse und Papierkorb ändert, wer veröffentlichen darf.'
|
||||
: 'Diese Seite ist veröffentlicht. Diese Angaben wirken sofort öffentlich und ändert deshalb, wer veröffentlichen darf.');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/schedule', name: 'admin_schedule', methods: ['POST'])]
|
||||
public function schedule(Request $request, string $id): Response
|
||||
{
|
||||
@@ -297,7 +322,7 @@ final class EditorController extends AbstractController
|
||||
|
||||
return $this->back('' === trim($request->request->getString('publishAt'))
|
||||
? 'Die geplante Veröffentlichung wurde entfernt.'
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
|
||||
: 'Die Veröffentlichung ist geplant – mit dem Entwurf von jetzt. Spätere Änderungen gehen erst mit erneutem Planen oder Veröffentlichen online. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
|
||||
@@ -357,6 +382,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ($blocked = $this->liveNeedsPublisher($id, true)) {
|
||||
return $blocked;
|
||||
}
|
||||
try {
|
||||
$count = $this->pages->trashPage($id, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
|
||||
@@ -46,12 +46,12 @@ final class FormController extends AbstractController
|
||||
if (null === $form || null === $page) {
|
||||
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
|
||||
}
|
||||
$senderHash = hash_hmac('sha256', (string) $request->getClientIp(), $this->secret);
|
||||
$senderHash = FormRepository::senderKey($request->getClientIp(), $this->secret);
|
||||
|
||||
// Three quiet checks before the real validation. A caught bot is answered like a success
|
||||
// so it learns nothing, but nothing is stored and nothing is sent.
|
||||
$silentlyDropped = '' !== trim($request->request->getString('website'))
|
||||
|| !$this->timingIsPlausible($request)
|
||||
|| !$this->timingIsPlausible($request, $slug)
|
||||
|| $this->forms->rateLimited($senderHash)
|
||||
// Die vierte, freiwillige Pruefung. Sie antwortet wie die drei anderen still mit
|
||||
// Erfolg: ein Absender, der erfaehrt, woran er gescheitert ist, versucht es gezielt
|
||||
@@ -72,6 +72,11 @@ final class FormController extends AbstractController
|
||||
// Re-render the page the form sits on, with the entered values and the messages.
|
||||
return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
// Kommt gerade für dieses Formular oder insgesamt zu viel an, wird nichts mehr gespeichert -
|
||||
// aber sichtbar, nicht still: ein echter Besucher soll wissen, dass er anrufen muss.
|
||||
if (!$silentlyDropped && $this->forms->flooded($form['id'])) {
|
||||
return $this->renderPage($request, $page, $form, ['errors' => ['_form' => 'Gerade gehen sehr viele Anfragen ein. Bitte versuchen Sie es später erneut oder melden Sie sich telefonisch.'], 'values' => $checked['values']], null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
if ($this->deliver($form, $checked['values'])) {
|
||||
@@ -86,10 +91,10 @@ final class FormController extends AbstractController
|
||||
}
|
||||
|
||||
/** The signed timestamp makes the age of the form checkable without storing anything. */
|
||||
private function timingIsPlausible(Request $request): bool
|
||||
private function timingIsPlausible(Request $request, string $slug): bool
|
||||
{
|
||||
$issued = $request->request->getInt('issued');
|
||||
if (!hash_equals(hash_hmac('sha256', (string) $issued, $this->secret), $request->request->getString('issuedSignature'))) {
|
||||
if (!hash_equals(FormRepository::stamp($slug, $issued, $this->secret), $request->request->getString('issuedSignature'))) {
|
||||
return false;
|
||||
}
|
||||
$age = time() - $issued;
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Controller;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\Installation;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -20,6 +22,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*
|
||||
* After the first account exists, every route in this file answers 404 - not because a file was
|
||||
* deleted, but because the database says there is an installation.
|
||||
*
|
||||
* Vorher öffnet sich kein Schritt ohne den Installationsschlüssel (ADR 116): Er steht auf der
|
||||
* Abschlussseite von install.php bzw. in var/install.key. Einmal eingegeben, bleibt im Browser
|
||||
* ein Cookie mit einem Nachweis darüber, nur für /install.
|
||||
*/
|
||||
final class InstallController extends AbstractController
|
||||
{
|
||||
@@ -27,10 +33,46 @@ final class InstallController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
|
||||
public function check(): Response
|
||||
/** Der Name des Cookies, das den Installationsschlüssel nachweist. */
|
||||
public const KEY_COOKIE = 'kant_install';
|
||||
|
||||
#[Route('/install/schluessel', name: 'install_key', methods: ['GET', 'POST'], priority: 11)]
|
||||
public function key(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($this->installation->isInstalled()) {
|
||||
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
|
||||
}
|
||||
$error = null;
|
||||
try {
|
||||
$this->installation->installKey();
|
||||
$given = $request->isMethod('POST') ? $request->request->getString('schluessel') : $request->query->getString('schluessel');
|
||||
if ('' !== $given) {
|
||||
if ($this->installation->acceptsKey($given)) {
|
||||
// Weiter ohne den Schlüssel in der Adresse: er soll weder im Verlauf noch in
|
||||
// einem Serverprotokoll als Verweis stehen bleiben.
|
||||
$response = $this->redirectToRoute('install');
|
||||
$response->headers->setCookie(Cookie::create(self::KEY_COOKIE, $this->installation->keyProof(), 0, '/install', null, $request->isSecure(), true, false, Cookie::SAMESITE_STRICT));
|
||||
|
||||
return $response;
|
||||
}
|
||||
$error = 'Dieser Schlüssel passt nicht.';
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
|
||||
$response = $this->render('install/key.html.twig', ['step' => 1, 'error' => $error, 'keyFile' => Installation::KEY_FILE]);
|
||||
$response->headers->set('Referrer-Policy', 'no-referrer');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/install', name: 'install', methods: ['GET'], priority: 10)]
|
||||
public function check(Request $request): Response
|
||||
{
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
$checks = $this->installation->checks();
|
||||
|
||||
return $this->render('install/check.html.twig', [
|
||||
@@ -43,7 +85,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/datenbank', name: 'install_database', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function database(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
$error = null;
|
||||
$driver = $request->request->getString('driver', 'sqlite');
|
||||
if ($request->isMethod('POST') && $this->signed($request, 'datenbank')) {
|
||||
@@ -89,7 +133,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/migration', name: 'install_migration', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function migration(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
if (!$this->installation->environmentWritten()) {
|
||||
return $this->redirectToRoute('install_database');
|
||||
}
|
||||
@@ -118,7 +164,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/website', name: 'install_website', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function website(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
if (!$this->installation->schemaReady()) {
|
||||
return $this->redirectToRoute('install_migration');
|
||||
}
|
||||
@@ -150,7 +198,9 @@ final class InstallController extends AbstractController
|
||||
#[Route('/install/konto', name: 'install_account', methods: ['GET', 'POST'], priority: 10)]
|
||||
public function account(Request $request): Response
|
||||
{
|
||||
$this->guard();
|
||||
if ($blocked = $this->guard($request)) {
|
||||
return $blocked;
|
||||
}
|
||||
if (!$this->installation->siteReady()) {
|
||||
return $this->redirectToRoute('install_website');
|
||||
}
|
||||
@@ -192,11 +242,21 @@ final class InstallController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
private function guard(): void
|
||||
/** 404 nach der Installation; davor ohne Schlüsselnachweis zurück zur Eingabe des Schlüssels. */
|
||||
private function guard(Request $request): ?Response
|
||||
{
|
||||
if ($this->installation->isInstalled()) {
|
||||
throw new NotFoundHttpException('Diese Installation ist abgeschlossen.');
|
||||
}
|
||||
try {
|
||||
if ($this->installation->acceptsProof($request->cookies->getString(self::KEY_COOKIE))) {
|
||||
return null;
|
||||
}
|
||||
} catch (\RuntimeException) {
|
||||
// Kein Schlüssel lesbar oder schreibbar: die Eingabeseite sagt, woran es liegt.
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->generateUrl('install_key'));
|
||||
}
|
||||
|
||||
private function token(string $step): array
|
||||
|
||||
@@ -532,7 +532,7 @@ final class PageController extends AbstractController
|
||||
'errors' => [], 'values' => [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
'issued' => $issued,
|
||||
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
|
||||
'issuedSignature' => FormRepository::stamp($slug, $issued, $this->secret),
|
||||
// Leer, solange die Pruefung aus ist - dann steht auch kein Widget da.
|
||||
'turnstileKey' => $this->turnstile->isActive() ? $this->turnstile->siteKey() : '',
|
||||
];
|
||||
|
||||
@@ -77,7 +77,7 @@ final class PartialController extends AbstractController
|
||||
$request->request->getString('slug') ?: $this->slugify($name),
|
||||
$name,
|
||||
// A shared area starts with one empty section, so there is something to click on.
|
||||
['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
|
||||
['schemaVersion' => DocumentValidator::SCHEMA_VERSION, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [
|
||||
['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []],
|
||||
]],
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user