Vícevrstvá obrana: CSRF, SQL Injection a ochrana proti Path Traversal
Jak Laravel a Caddy chrání aplikaci před nejčastějšími OWASP zranitelnostmi: CSRF tokeny, PDO prepared statements, prevence path traversal a bezpečnostní HTTP hlavičky.
Vícevrstvá bezpečnostní architektura (Defense in Depth)
Bezpečnost webové aplikace nesmí spoléhat na jedinou ochrannou zeď. V profesionálním vývoji aplikujeme princip Defense in Depth (Hloubková obrana) — kombinaci síťové vrstvy (reverzní proxy Caddy / FrankenPHP), frameworkových middleware a striktní validace v aplikační doméně.
1. Ochrana na úrovni webového serveru (Caddyfile)
V našem kontejnerizovaném prostředí běží FrankenPHP se zabudovaným webserverem Caddy. Přímo v docker/Caddyfile vynucujeme zásadní bezpečnostní HTTP hlavičky:
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
-Server
}
Co jednotlivé direktivy dělají?
X-Content-Type-Options nosniff: Zabraňuje prohlížeči odhadovat MIME typ souboru (tzv. MIME sniffing), což eliminuje zranitelnosti, kdy by útočník nahrál škodlivý JavaScript maskovaný jako obrázek.X-Frame-Options SAMEORIGIN: Chrání aplikaci před Clickjackingem tím, že zakazuje vkládat naše stránky do<iframe>na cizích doménách.Referrer-Policy strict-origin-when-cross-origin: Při přechodu na externí domény odesílá pouze název domény bez citlivé URL cesty a parametrů.-Server: Odstraňuje hlavičku identifikující přesnou verzi webserveru, čímž útočníkům ztěžuje cílené zneužití známých chyb (Security through Obscurity jako doplňková vrstva).
2. Ochrana proti CSRF (Cross-Site Request Forgery)
Laravel automaticky chrání všechny stav měnící požadavky (POST, PUT, PATCH, DELETE) ve skupině web middlewarem ValidateCsrfToken.
Při každém vykreslení formuláře direktivou @csrf nebo při Livewire komunikaci framework vygeneruje kryptograficky bezpečný token svázaný se session uživatele:
<form action="{{ route('course.switch-profile') }}" method="POST">
@csrf
<input type="text" name="transfer_code" placeholder="AGY-XXXX-XXXX">
<button type="submit">Obnovit profil</button>
</form>
Pokud útočník z jiné domény přiměje prohlížeč oběti odeslat POST požadavek, požadavek selže s chybou 419 Page Expired, protože cizí stránka nemůže token ze session oběti přečíst (Same-Origin Policy).
3. Ochrana proti SQL Injection v Eloquentu
Laravel Database Query Builder i Eloquent ORM interně využívají PDO Prepared Statements s parametrickým navazováním hodnot. Když napíšete:
StudentProfile::where('transfer_code', $userInput)->first();
Hodnota $userInput není nikdy přímo vložena (konkatenována) do SQL řetězce. Do PostgreSQL databázového enginu putuje samostatně parametrizovaný příkaz SELECT * FROM student_profiles WHERE transfer_code = $1 LIMIT 1 a hodnota $userInput je předána odděleně jako parametr $1. Tím je SQL Injection technicky znemožněna.
4. Ochrana proti Path Traversal v AnatomyInspectorService
Když aplikace umožňuje čtení souborů z disku, útočník se pokusí poslat cestu jako ../../../../etc/passwd nebo .env.
V souboru app/Services/AnatomyInspectorService.php kombinujeme:
- Zákaz fragmentů: Pole
FORBIDDEN_FRAGMENTSokamžitě odmítne jakýkoliv výskyt..,.env,.gitnebovendor/. - Whitelist povolených adresářů: Soubor musí začínat bezpečným prefixem (
app/,docker/,routes/atd.). - Kanonizaci přes
realpath(): Funkcerealpath()přeloží všechny relativní skoky a symlinky na absolutní cestu na disku. Teprve po ověření, že výsledná cesta začíná řetězcemrealpath(base_path()) . DIRECTORY_SEPARATOR, služba povolí soubor otevřít.
// app/Services/AnatomyInspectorService.php:32-86
private const array FORBIDDEN_FRAGMENTS = [
'.env',
'.git',
'vendor/',
'node_modules/',
'storage/',
'..',
];
public function isAllowedPath(string $relativePath): bool
{
$cleanPath = ltrim(trim($relativePath), '/');
foreach (self::FORBIDDEN_FRAGMENTS as $fragment) {
if (str_contains($cleanPath, $fragment)) {
return false;
}
}
$basePath = realpath(base_path());
$fullPath = base_path($cleanPath);
$realPath = realpath($fullPath);
if ($realPath === false || ! is_file($realPath)) {
return false;
}
$baseWithSlash = rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
return str_starts_with($realPath, $baseWithSlash);
}
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Před jakým typem útoku chrání HTTP hlavička "X-Frame-Options: SAMEORIGIN" konfigurovaná v našem Caddyfile?
Proč je volání StudentProfile::where('transfer_code', $code)->first() v Laravelu imunní vůči SQL Injection?
K jaké HTTP chybě dojde, pokud uživatel odešle POST formulář bez platného CSRF tokenu v aplikaci chráněné middlewarem ValidateCsrfToken?
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Services; |
| 6 | |
| 7 | use App\Data\AnatomyFileNodeData; |
| 8 | use App\Enums\LifecyclePhase; |
| 9 | |
| 10 | final class AnatomyInspectorService |
| 11 | { |
| 12 | private const array ALLOWED_PREFIXES = [ |
| 13 | 'app/', |
| 14 | 'bootstrap/', |
| 15 | 'config/', |
| 16 | 'database/', |
| 17 | 'docker/', |
| 18 | 'public/index.php', |
| 19 | 'resources/', |
| 20 | 'routes/', |
| 21 | 'tests/', |
| 22 | 'composer.json', |
| 23 | 'package.json', |
| 24 | 'vite.config.js', |
| 25 | 'compose.yaml', |
| 26 | 'compose.prod.yaml', |
| 27 | 'deploy.sh', |
| 28 | 'AGENTS.md', |
| 29 | '.agents/memory/curriculum_registry.json', |
| 30 | ]; |
| 31 | |
| 32 | private const array FORBIDDEN_FRAGMENTS = [ |
| 33 | '.env', |
| 34 | '.git', |
| 35 | 'vendor/', |
| 36 | 'node_modules/', |
| 37 | 'storage/', |
| 38 | '..', |
| 39 | ]; |
| 40 | |
| 41 | /** |
| 42 | * Ověří, zda je relativní cesta v souladu s bezpečnostní politikou |
| 43 | * a zda cílový soubor reálně existuje v kořenovém adresáři aplikace. |
| 44 | */ |
| 45 | public function isAllowedPath(string $relativePath): bool |
| 46 | { |
| 47 | $cleanPath = ltrim(trim($relativePath), '/'); |
| 48 | |
| 49 | if ($cleanPath === '') { |
| 50 | return false; |
| 51 | } |
| 52 | |
| 53 | foreach (self::FORBIDDEN_FRAGMENTS as $fragment) { |
| 54 | if (str_contains($cleanPath, $fragment)) { |
| 55 | return false; |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | $hasAllowedPrefix = false; |
| 60 | foreach (self::ALLOWED_PREFIXES as $prefix) { |
| 61 | if (str_starts_with($cleanPath, $prefix)) { |
| 62 | $hasAllowedPrefix = true; |
| 63 | break; |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | if (! $hasAllowedPrefix) { |
| 68 | return false; |
| 69 | } |
| 70 | |
| 71 | $basePath = realpath(base_path()); |
| 72 | if ($basePath === false) { |
| 73 | return false; |
| 74 | } |
| 75 | |
| 76 | $fullPath = base_path($cleanPath); |
| 77 | $realPath = realpath($fullPath); |
| 78 | |
| 79 | if ($realPath === false || ! is_file($realPath)) { |
| 80 | return false; |
| 81 | } |
| 82 | |
| 83 | $baseWithSlash = rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; |
| 84 | |
| 85 | return str_starts_with($realPath, $baseWithSlash); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Prozkoumá soubor a vrátí jeho DTO uzel s obsahem a metadaty. |
| 90 | */ |
| 91 | public function inspectFile(string $relativePath): AnatomyFileNodeData |
| 92 | { |
| 93 | $cleanPath = ltrim(trim($relativePath), '/'); |
| 94 | |
| 95 | if (! $this->isAllowedPath($cleanPath)) { |
| 96 | return new AnatomyFileNodeData( |
| 97 | path: $cleanPath, |
| 98 | category: $this->determineCategory($cleanPath), |
| 99 | description: $this->determineDescription($cleanPath), |
| 100 | exists: false, |
| 101 | lineCount: 0, |
| 102 | sizeBytes: 0, |
| 103 | content: '// Přístup k souboru odepřen bezpečnostní politikou AnatomyInspectorService nebo soubor neexistuje.', |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | $fullPath = base_path($cleanPath); |
| 108 | $content = (string) file_get_contents($fullPath); |
| 109 | $lineCount = $content === '' ? 0 : substr_count($content, "\n") + (str_ends_with($content, "\n") ? 0 : 1); |
| 110 | $sizeBytes = strlen($content); |
| 111 | |
| 112 | return new AnatomyFileNodeData( |
| 113 | path: $cleanPath, |
| 114 | category: $this->determineCategory($cleanPath), |
| 115 | description: $this->determineDescription($cleanPath), |
| 116 | exists: true, |
| 117 | lineCount: $lineCount, |
| 118 | sizeBytes: $sizeBytes, |
| 119 | content: $content, |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Prozkoumá kolekci relativních cest. |
| 125 | * |
| 126 | * @param array<int, string> $relativePaths |
| 127 | * @return array<int, AnatomyFileNodeData> |
| 128 | */ |
| 129 | public function inspectMany(array $relativePaths): array |
| 130 | { |
| 131 | $nodes = []; |
| 132 | foreach ($relativePaths as $path) { |
| 133 | $nodes[] = $this->inspectFile($path); |
| 134 | } |
| 135 | |
| 136 | return $nodes; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Vrátí kurátorský výběr nejdůležitějších exponátů živé anatomie projektu. |
| 141 | * |
| 142 | * @return array<int, AnatomyFileNodeData> |
| 143 | */ |
| 144 | public function getCuratedProjectCatalog(): array |
| 145 | { |
| 146 | $catalogPaths = [ |
| 147 | 'public/index.php', |
| 148 | 'bootstrap/app.php', |
| 149 | 'routes/web.php', |
| 150 | 'app/Http/Middleware/EnsureStudentProfile.php', |
| 151 | 'app/Http/Controllers/CourseController.php', |
| 152 | 'app/Http/Controllers/AnatomyController.php', |
| 153 | 'app/Services/AnatomyInspectorService.php', |
| 154 | 'app/Services/StudentProgressService.php', |
| 155 | 'app/Models/CourseModule.php', |
| 156 | 'app/Models/CourseLesson.php', |
| 157 | 'app/Models/QuizQuestion.php', |
| 158 | 'app/Models/StudentProfile.php', |
| 159 | 'app/Data/AnatomyFileNodeData.php', |
| 160 | 'app/Data/LessonContentData.php', |
| 161 | 'app/Enums/LifecyclePhase.php', |
| 162 | 'app/Enums/ModuleDifficulty.php', |
| 163 | 'app/Livewire/LessonQuiz.php', |
| 164 | 'app/Livewire/AnatomyExplorer.php', |
| 165 | 'app/Console/Commands/VerifyCurriculumCommand.php', |
| 166 | 'database/migrations/0001_01_01_000010_create_course_tables.php', |
| 167 | 'resources/views/layouts/app.blade.php', |
| 168 | 'docker/Dockerfile', |
| 169 | 'docker/Caddyfile', |
| 170 | 'docker/entrypoint.sh', |
| 171 | 'compose.yaml', |
| 172 | 'compose.prod.yaml', |
| 173 | 'deploy.sh', |
| 174 | 'composer.json', |
| 175 | 'AGENTS.md', |
| 176 | '.agents/memory/curriculum_registry.json', |
| 177 | ]; |
| 178 | |
| 179 | return $this->inspectMany($catalogPaths); |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * Určí architektonickou kategorii souboru. |
| 184 | */ |
| 185 | public function determineCategory(string $path): string |
| 186 | { |
| 187 | if (str_starts_with($path, 'docker/') || in_array($path, ['compose.yaml', 'compose.prod.yaml', 'deploy.sh'], true)) { |
| 188 | return 'DevOps & Docker'; |
| 189 | } |
| 190 | |
| 191 | if (str_starts_with($path, 'app/Http/Controllers/')) { |
| 192 | return 'Controller'; |
| 193 | } |
| 194 | |
| 195 | if (str_starts_with($path, 'app/Http/Middleware/')) { |
| 196 | return 'Middleware'; |
| 197 | } |
| 198 | |
| 199 | if (str_starts_with($path, 'app/Services/')) { |
| 200 | return 'Service Layer'; |
| 201 | } |
| 202 | |
| 203 | if (str_starts_with($path, 'app/Models/')) { |
| 204 | return 'Eloquent Model'; |
| 205 | } |
| 206 | |
| 207 | if (str_starts_with($path, 'app/Data/')) { |
| 208 | return 'Data Transfer Object (DTO)'; |
| 209 | } |
| 210 | |
| 211 | if (str_starts_with($path, 'app/Enums/')) { |
| 212 | return 'Enum'; |
| 213 | } |
| 214 | |
| 215 | if (str_starts_with($path, 'app/Livewire/')) { |
| 216 | return 'Livewire Component'; |
| 217 | } |
| 218 | |
| 219 | if (str_starts_with($path, 'app/Console/')) { |
| 220 | return 'Artisan Command'; |
| 221 | } |
| 222 | |
| 223 | if (str_starts_with($path, 'database/migrations/')) { |
| 224 | return 'Database Migration'; |
| 225 | } |
| 226 | |
| 227 | if (str_starts_with($path, 'database/seeders/')) { |
| 228 | return 'Database Seeder'; |
| 229 | } |
| 230 | |
| 231 | if (str_starts_with($path, 'resources/views/')) { |
| 232 | return 'Blade View'; |
| 233 | } |
| 234 | |
| 235 | if (str_starts_with($path, 'routes/')) { |
| 236 | return 'HTTP Routing'; |
| 237 | } |
| 238 | |
| 239 | if (str_starts_with($path, 'bootstrap/')) { |
| 240 | return 'Bootstrap & Pipeline'; |
| 241 | } |
| 242 | |
| 243 | if (str_starts_with($path, 'config/')) { |
| 244 | return 'Configuration'; |
| 245 | } |
| 246 | |
| 247 | if (str_starts_with($path, 'public/')) { |
| 248 | return 'HTTP Entry Point'; |
| 249 | } |
| 250 | |
| 251 | if (str_starts_with($path, 'tests/')) { |
| 252 | return 'Pest PHP Tests'; |
| 253 | } |
| 254 | |
| 255 | if (str_starts_with($path, 'AGENTS.md') || str_starts_with($path, '.agents/')) { |
| 256 | return 'Agentic Architecture & SSOT'; |
| 257 | } |
| 258 | |
| 259 | if (in_array($path, ['composer.json', 'package.json', 'vite.config.js'], true)) { |
| 260 | return 'Project Manifest & Config'; |
| 261 | } |
| 262 | |
| 263 | return 'Source Code'; |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Poskytne český popis architektonické role souboru v naší aplikaci. |
| 268 | */ |
| 269 | public function determineDescription(string $path): string |
| 270 | { |
| 271 | return match ($path) { |
| 272 | 'public/index.php' => 'Vstupní bod HTTP požadavku pro FrankenPHP webserver; inicializuje Composer autoloader a načítá bootstrap aplikace.', |
| 273 | 'bootstrap/app.php' => 'Nové jádro Laravel 12 pro registraci webových i konzolových rout, middleware pipeline a zachytávání výjimek bez Http Kernelu.', |
| 274 | 'routes/web.php' => 'Definice webových tras, Route Model Binding pro moduly a lekce, mapování na Controllery a API status endpoint.', |
| 275 | 'app/Http/Middleware/EnsureStudentProfile.php' => 'Middleware zajišťující anonymní UUID profil studenta v cookie s trvalým sledováním pokroku kurzem.', |
| 276 | 'app/Http/Controllers/CourseController.php' => 'Resource controller obsluhující přehled modulů, detail lekce, markdown render a přepínání profilu studenta.', |
| 277 | 'app/Http/Controllers/AnatomyController.php' => 'Controller interaktivní anatomie aplikace nabízející kurátorský katalog klíčových komponent a živý náhled kódu.', |
| 278 | 'app/Services/AnatomyInspectorService.php' => 'Bezpečná introspekční služba aplikace čtoucí reálný zdrojový kód s ochranou proti path traversal a whitelistem cest.', |
| 279 | 'app/Services/StudentProgressService.php' => 'Doménová služba pro správu anonymních profilů studentů, postupu lekcemi, výsledků kvízů a přenosových kódů.', |
| 280 | 'app/Models/CourseModule.php' => 'Eloquent model tématického modulu kurzu s vazbou hasMany na lekce a přetypováním na enum ModuleDifficulty.', |
| 281 | 'app/Models/CourseLesson.php' => 'Eloquent model lekce kurzu s obsahem v Markdownu, přetypováním JSON polí a enumem LifecyclePhase.', |
| 282 | 'app/Models/QuizQuestion.php' => 'Eloquent model otázky kvízu s JSON možnostmi, správnou volbou a vysvětlením pro interaktivní prověření znalostí.', |
| 283 | 'app/Models/StudentProfile.php' => 'Model anonymního studenta s UUID tokenem a přenosovým kódem pro synchronizaci postupu napříč zařízeními.', |
| 284 | 'app/Data/AnatomyFileNodeData.php' => 'Neměnné PHP 8.4 readonly DTO reprezentující analyzovaný soubor anatomie aplikace (řádky, bajty, obsah, popis).', |
| 285 | 'app/Data/LessonContentData.php' => 'Readonly DTO s kompletními daty lekce pro bezpečné předávání mezi modely, controllery a šablonami.', |
| 286 | 'app/Enums/LifecyclePhase.php' => 'PHP 8.4 string enum definující 11 kroků životního cyklu požadavku v Laravelu 12 s pořadím a popisky.', |
| 287 | 'app/Enums/ModuleDifficulty.php' => 'Enum určující obtížnost modulu (Začátečník, Pokročilý, Architekt) s Tailwind CSS badge třídami.', |
| 288 | 'app/Livewire/LessonQuiz.php' => 'Reaktivní Livewire 3 komponenta pro interaktivní testování znalostí s okamžitým vyhodnocením a záznamem skóre.', |
| 289 | 'app/Livewire/AnatomyExplorer.php' => 'Interaktivní průzkumník anatomických souborů s filtrováním fází a živým čtením přes AnatomyInspectorService.', |
| 290 | 'app/Console/Commands/VerifyCurriculumCommand.php' => 'Artisan příkaz pro zero-hallucination verifikaci registry osnovy proti fyzickým souborům a databázi.', |
| 291 | 'database/migrations/0001_01_01_000010_create_course_tables.php' => 'Migrační schéma pro moduly, lekce, kvízové otázky a anonymní profily studentů v PostgreSQL 18.', |
| 292 | 'resources/views/layouts/app.blade.php' => 'Hlavní Blade layout aplikace s tmavým tématem, navigací, synchronizací profilu a Alpine.js reaktivitou.', |
| 293 | 'docker/Dockerfile' => 'Multi-stage Dockerfile založený na dunglas/frankenphp:1-php8.4 s dev a prod cíli a instalací pgsql/opcache rozšíření.', |
| 294 | 'docker/Caddyfile' => 'Konfigurace Caddy webserveru a FrankenPHP worker módu pro bleskovou obsluhu požadavků bez opakovaného bootu.', |
| 295 | 'docker/entrypoint.sh' => 'Startovací skript kontejneru zajišťující práva storage, env soubory a spuštění Caddy/Octane.', |
| 296 | 'compose.yaml' => 'Docker Compose konfigurace pro lokální vývoj s FrankenPHP a PostgreSQL 18 alpine.', |
| 297 | 'compose.prod.yaml' => 'Produkční Docker Compose pro nasazení na Hetzner serveru s připojením na sdílenou síť db a caddy.', |
| 298 | 'deploy.sh' => 'Produkční nasazovací skript pro Hetzner server s migracemi, cacheováním a zero-downtime reloadem.', |
| 299 | 'composer.json' => 'Projektový manifest Composeru definující PHP 8.4, Laravel 12 závislosti a PSR-4 autoloading.', |
| 300 | 'AGENTS.md' => 'Závazná ústava projektu pro AI agenty s pravidly nulové halucinace a bezpečnosti Hetzneru.', |
| 301 | '.agents/memory/curriculum_registry.json' => 'Jediný zdroj pravdy (SSOT) pro osnovu 10 modulů a všech lekcí celého kurzu.', |
| 302 | default => 'Soubor aplikační architektury v modulu ' . $this->determineCategory($path) . '.', |
| 303 | }; |
| 304 | } |
| 305 | } |
| 306 | |