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