From 73a9bb09b1f1da481b5d5d1666999080344fc862 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:40 +0200 Subject: [PATCH] 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. --- src/App.php | 309 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 src/App.php diff --git a/src/App.php b/src/App.php new file mode 100644 index 0000000..785b6f8 --- /dev/null +++ b/src/App.php @@ -0,0 +1,309 @@ +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 + */ + 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 = '

500 – Interner Fehler

'; + } + + 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(); + } +}