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:
+309
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user