Fáze požadavku: 5. Middleware Pipeline
Modul 8 · Lekce 1

Vícevrstvá obrana: CSRF, SQL Injection a ochrana proti Path Traversal

💡 Shrnutí lekce

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:

  1. Zákaz fragmentů: Pole FORBIDDEN_FRAGMENTS okamžitě odmítne jakýkoliv výskyt .., .env, .git nebo vendor/.
  2. Whitelist povolených adresářů: Soubor musí začínat bezpečným prefixem (app/, docker/, routes/ atd.).
  3. Kanonizaci přes realpath(): Funkce realpath() přeloží všechny relativní skoky a symlinky na absolutní cestu na disku. Teprve po ověření, že výsledná cesta začíná řetězcem realpath(base_path()) . DIRECTORY_SEPARATOR, služba povolí soubor otevřít.
💻 Klíčová konstrukce z praxe
// 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.

Otázka 1 z 3

Před jakým typem útoku chrání HTTP hlavička "X-Frame-Options: SAMEORIGIN" konfigurovaná v našem Caddyfile?

Otázka 2 z 3

Proč je volání StudentProfile::where('transfer_code', $code)->first() v Laravelu imunní vůči SQL Injection?

Otázka 3 z 3

K jaké HTTP chybě dojde, pokud uživatel odešle POST formulář bez platného CSRF tokenu v aplikaci chráněné middlewarem ValidateCsrfToken?

Zodpovězeno: 0 / 3
🔬 Živá anatomie v této aplikaci
2 soubory
Na co se v kódu zaměřit: Porovnej ochranu v Caddyfile (řádky 11–16: X-Content-Type-Options nosniff, X-Frame-Options SAMEORIGIN, skrytí Server hlavičky) s aplikační obranou v AnatomyInspectorService.php (FORBIDDEN_FRAGMENTS a verifikace realpath proti base_path).
cesta: app/Services/AnatomyInspectorService.php
305 řádků · 12.5 KB
Bezpečná introspekční služba aplikace čtoucí reálný zdrojový kód s ochranou proti path traversal a whitelistem cest.
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
cesta: docker/Caddyfile
20 řádků · 0.3 KB
Konfigurace Caddy webserveru a FrankenPHP worker módu pro bleskovou obsluhu požadavků bez opakovaného bootu.
1 {
2 frankenphp
3 order php_server before file_server
4 }
5
6 {$SERVER_NAME::80}
7
8 root * {$APP_ROOT:/app}/public
9 encode zstd br gzip
10
11 header {
12 X-Content-Type-Options nosniff
13 X-Frame-Options SAMEORIGIN
14 Referrer-Policy strict-origin-when-cross-origin
15 -Server
16 }
17
18 php_server {
19 env HTTPS on
20 }
21
Zdroj: skutečné soubory této aplikace Plný průzkumník anatomie →