feat(app): Verdrahtung, Routing und Security-Header

Setzt CSP, X-Frame-Options, Referrer-Policy, COOP/CORP und
Permissions-Policy; ueber HTTPS zusaetzlich HSTS und
upgrade-insecure-requests. Letzteres bewusst nur bei HTTPS, weil es
eine reine HTTP-Installation sonst unbrauchbar macht.

X-Forwarded-For und X-Forwarded-Proto werden nur hinter einem in
TRUSTED_PROXIES eingetragenen Proxy ausgewertet - sonst liesse sich die
Login-Sperre mit einem erfundenen Header umgehen. Ebenso fliesst der
Host-Header nur geprueft in erzeugte URLs ein.
This commit is contained in:
2026-08-03 18:11:40 +02:00
parent fabaef1f2b
commit 73a9bb09b1
+309
View File
@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace App;
use App\Controller\AuthController;
use App\Controller\DashboardController;
use App\Controller\LinkController;
use App\Controller\QrController;
use App\Controller\RedirectController;
use App\Controller\SetupController;
use App\Controller\StatsController;
use App\Http\Request;
use App\Http\Response;
use App\Http\Router;
use App\Qr\LogoStore;
use App\Qr\QrRenderer;
use App\Controller\DomainController;
use App\Repository\DomainRepository;
use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository;
use App\Repository\SettingRepository;
use App\Repository\VisitRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\RateLimiter;
use App\Security\Session;
use App\Security\SetupToken;
use App\Service\DomainChecker;
use Throwable;
/**
* Verdrahtung aller Bausteine und Einstiegspunkt fuer jeden Request.
*/
final class App
{
private Database $db;
private Session $session;
private Csrf $csrf;
private Auth $auth;
private View $view;
private LinkRepository $links;
private VisitRepository $visits;
private QrDesignRepository $designs;
private DomainRepository $domains;
private SettingRepository $settings;
private LogoStore $logos;
private QrRenderer $qr;
private RateLimiter $rateLimiter;
public function __construct(private readonly Config $config)
{
date_default_timezone_set($config->getString('APP_TIMEZONE', 'Europe/Berlin'));
$this->db = Database::fromConfig($config);
(new Migrator($this->db, $config->rootDir() . '/migrations'))->migrate();
$settings = new SettingRepository($this->db);
$this->settings = $settings;
$this->session = new Session($config);
$this->csrf = new Csrf($this->session);
$this->auth = new Auth($config, $this->session, $settings);
$this->links = new LinkRepository($this->db);
$this->visits = new VisitRepository($this->db);
$this->designs = new QrDesignRepository($this->db);
$this->domains = new DomainRepository($this->db);
$this->logos = new LogoStore(
$config->path($config->getString('UPLOAD_DIR', 'data/uploads')),
$config->getInt('UPLOAD_MAX_BYTES', 2097152),
);
$this->qr = new QrRenderer($this->logos);
$this->rateLimiter = new RateLimiter(
$this->db,
$config->getInt('LOGIN_MAX_ATTEMPTS', 5),
$config->getInt('LOGIN_LOCKOUT_SECONDS', 900),
);
$this->view = new View($config->rootDir() . '/templates');
$this->view->share('config', $config);
$this->view->share('csrf', $this->csrf);
$this->view->share('auth', $this->auth);
$this->view->share('baseUrl', $config->baseUrl());
}
public function run(): void
{
$request = Request::fromGlobals($this->config->trustedProxies());
// Nur ein syntaktisch gueltiger Host darf in erzeugte URLs
// gelangen - und auch nur, wenn APP_BASE_URL nichts vorgibt.
$host = $request->host();
if ($host !== '') {
$this->config->setFallbackBaseUrl(($request->isSecure() ? 'https://' : 'http://') . $host);
}
$this->session->start($request->isSecure());
$this->view->share('flashes', $this->session->takeFlashes());
$this->view->share('currentPath', $request->path);
try {
$response = $this->buildRouter()->dispatch($request);
} catch (Throwable $e) {
$response = $this->errorResponse($e);
}
$existingHeaders = $response->headers();
foreach ($this->securityHeaders($request) as $name => $value) {
if (!array_key_exists($name, $existingHeaders)) {
$response = $response->withHeader($name, $value);
}
}
$response->send();
$this->finishRequest();
try {
$response->runAfterSend();
} catch (Throwable $e) {
error_log('[urlshort] Nachlauf fehlgeschlagen: ' . $e->getMessage());
}
}
private function buildRouter(): Router
{
$router = new Router();
$setup = new SetupController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
new SetupToken($this->config->path($this->config->getString('DATA_DIR', 'data'))),
$this->rateLimiter,
);
$login = new AuthController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->rateLimiter);
$dashboard = new DashboardController($this->view, $this->auth, $this->links, $this->visits, $this->config, $this->domains);
$linkController = new LinkController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->domains);
$stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits);
$qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr);
$domainController = new DomainController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->domains,
new DomainChecker($this->settings),
$this->settings,
);
$redirect = new RedirectController($this->config, $this->view, $this->links, $this->visits, $this->domains);
// --- Setup (nur solange kein Admin-Konto existiert) ------------
$router->get('/setup', [$setup, 'show']);
$router->post('/setup', [$setup, 'save']);
// --- Login ------------------------------------------------------
$router->get('/login', [$login, 'show']);
$router->post('/login', [$login, 'authenticate']);
$router->post('/logout', [$login, 'logout']);
// --- Geschuetzter Bereich ---------------------------------------
$router->get('/', [$dashboard, 'index']);
$router->get('/admin', [$dashboard, 'index']);
$router->get('/admin/links', [$linkController, 'index']);
$router->get('/admin/links/new', [$linkController, 'create']);
$router->post('/admin/links/new', [$linkController, 'store']);
$router->get('/admin/links/{id}/edit', [$linkController, 'edit']);
$router->post('/admin/links/{id}/edit', [$linkController, 'update']);
$router->post('/admin/links/{id}/toggle', [$linkController, 'toggle']);
$router->post('/admin/links/{id}/delete', [$linkController, 'destroy']);
$router->get('/admin/links/{id}/stats', [$stats, 'show']);
$router->get('/admin/links/{id}/stats.json', [$stats, 'data']);
$router->get('/admin/links/{id}/qr', [$qrController, 'show']);
$router->post('/admin/links/{id}/qr', [$qrController, 'save']);
$router->post('/admin/links/{id}/qr/logo', [$qrController, 'uploadLogo']);
$router->post('/admin/links/{id}/qr/logo/delete', [$qrController, 'deleteLogo']);
$router->get('/admin/links/{id}/qr/preview', [$qrController, 'preview']);
$router->get('/admin/links/{id}/qr/download', [$qrController, 'download']);
$router->get('/admin/uploads/{file}', [$qrController, 'serveUpload']);
$router->get('/admin/domains', [$domainController, 'index']);
$router->get('/admin/domains/new', [$domainController, 'create']);
$router->post('/admin/domains/new', [$domainController, 'store']);
$router->get('/admin/domains/{id}/edit', [$domainController, 'edit']);
$router->post('/admin/domains/{id}/edit', [$domainController, 'update']);
$router->post('/admin/domains/{id}/default', [$domainController, 'makeDefault']);
$router->post('/admin/domains/{id}/delete', [$domainController, 'destroy']);
$router->post('/admin/domains/{id}/check', [$domainController, 'check']);
// Endpunkt der Domain-Pruefung: ohne Login erreichbar, aber nur
// mit gueltigem, kurzlebigem Token (sonst 404).
$router->get(DomainChecker::PATH, [$domainController, 'verify']);
// --- Oeffentlich: ausschliesslich die Weiterleitung -------------
$router->get('/{slug}', [$redirect, 'handle']);
$router->fallback([$redirect, 'notFound']);
return $router;
}
/**
* @return array<string,string>
*/
private function securityHeaders(Request $request): array
{
$isSecure = $request->isSecure();
$csp = [
"default-src 'self'",
// Skripte nur aus dem eigenen Verzeichnis und - mit
// Integritaetspruefung - Chart.js vom CDN. Kein
// 'unsafe-inline', kein 'unsafe-eval'.
"script-src 'self' https://cdn.jsdelivr.net",
// 'unsafe-inline' gilt hier nur fuer STYLE: die Seiten
// nutzen style-Attribute (Balkenbreiten, Farbfelder).
// Ohne diese Angabe blockiert der Browser sie. Auf
// Skripte hat das keinen Einfluss.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"frame-src 'none'",
"base-uri 'self'",
"object-src 'none'",
];
// Nur sinnvoll, wenn die Seite bereits ueber HTTPS laeuft.
// Auf einer reinen HTTP-Installation wuerde die Anweisung die
// eigenen Stylesheets und Skripte unerreichbar machen.
if ($isSecure) {
$csp[] = 'upgrade-insecure-requests';
}
$headers = [
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'DENY',
'X-Permitted-Cross-Domain-Policies' => 'none',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Cross-Origin-Opener-Policy' => 'same-origin',
'Cross-Origin-Resource-Policy' => 'same-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=(), payment=(), usb=()',
'Content-Security-Policy' => implode('; ', $csp),
];
// HSTS nur ueber HTTPS - sonst ignorieren Browser den Header
// ohnehin. Ohne includeSubDomains, damit nicht versehentlich
// fremde Subdomains derselben Domain mitgesperrt werden.
$maxAge = $this->config->getInt('HSTS_MAX_AGE', 31536000);
if ($maxAge > 0 && $isSecure) {
$headers['Strict-Transport-Security'] = 'max-age=' . $maxAge;
}
return $headers;
}
private function errorResponse(Throwable $e): Response
{
error_log('[urlshort] ' . $e::class . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$detail = $this->config->isDevelopment()
? $e::class . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString()
: null;
try {
$html = $this->view->render('errors/500', ['detail' => $detail], 'layout_plain');
} catch (Throwable) {
$html = '<h1>500 – Interner Fehler</h1>';
}
return Response::html($html, 500);
}
/**
* Antwort ausliefern und die Verbindung schliessen, damit Nacharbeiten
* (Klick-Protokollierung) den Nutzer nicht warten lassen.
*/
private function finishRequest(): void
{
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
return;
}
if (function_exists('litespeed_finish_request')) {
litespeed_finish_request();
return;
}
// mod_php / built-in server: wenigstens die Puffer leeren.
ignore_user_abort(true);
while (ob_get_level() > 0) {
ob_end_flush();
}
flush();
}
}