0

PHP security: top 5 vulnerabilities and mitigations

Intermediate5 min read·eng-09-010
interviewsecurity

Concept

Top 5 PHP security vulnerabilities every senior developer must know, with their root causes and mitigations.

1. SQL Injection: User input is interpolated directly into SQL queries. Attacker crafts input that modifies the query structure. Mitigation: always use prepared statements / parameterized queries. Eloquent and the query builder do this by default. NEVER: "SELECT * FROM users WHERE id = {$_GET['id']}".

2. XSS (Cross-Site Scripting): Attacker injects malicious JavaScript into a page, which runs in another user's browser. Two types: Stored (in DB, shown to all visitors) and Reflected (in response from crafted URL). Mitigation: escape output with htmlspecialchars() (ENT_QUOTES). Blade automatically escapes {{ $var }}. NEVER use {!! $var !!} with user input.

3. CSRF (Cross-Site Request Forgery): A malicious site tricks an authenticated user's browser into sending a request to your application (e.g., form submit, <img src="https://bank.com/transfer?amount=1000">). Mitigation: CSRF tokens — a unique secret embedded in every form, verified on submit. Laravel's VerifyCsrfToken middleware handles this.

4. Mass Assignment: When you blindly pass user input to Model::create($request->all()), a user can set any column including is_admin = true. Mitigation: Laravel's $fillable (whitelist allowed columns) or $guarded (blacklist). Never use $guarded = [] in production.

5. Insecure Direct Object Reference (IDOR): User 42 accesses /orders/99 — which belongs to user 7. No authorization check means they can view/modify other users' data. Mitigation: always scope queries to the authenticated user: $order = auth()->user()->orders()->findOrFail($id).

Bonus: Path Traversal: User provides ../../etc/passwd as a filename. Mitigation: validate filenames, use basename() / realpath() + check the path is within the allowed directory.

Code Example

php
<?php
// ============================================================
// 1. SQL INJECTION — prevention
// ============================================================
// ❌ VULNERABLE
$id    = $_GET['id']; // attacker sends: 1 OR 1=1
$users = DB::select("SELECT * FROM users WHERE id = {$id}"); // full table leak!

// ✅ SAFE — parameterized query
$users = DB::select('SELECT * FROM users WHERE id = ?', [$id]);
$user  = User::where('id', $id)->first(); // Eloquent always parameterizes

// ============================================================
// 2. XSS — output escaping
// ============================================================
$name = '<script>alert("XSS")</script>';

// ❌ VULNERABLE
echo $name; // executes the script in browser

// ✅ SAFE
echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8'); // &lt;script&gt;...

// Blade: {{ $name }} is auto-escaped — safe
// Blade: {!! $name !!} is RAW — only use for trusted HTML!

// ============================================================
// 3. CSRF — Laravel handles this automatically
// ============================================================
// In forms: @csrf (Blade) adds hidden token field
// VerifyCsrfToken middleware checks it on POST/PUT/PATCH/DELETE
// Exclude specific routes in the middleware's $except array
// API routes (stateless) are excluded from CSRF — use Sanctum token auth instead

// ============================================================
// 4. MASS ASSIGNMENT
// ============================================================
// ❌ VULNERABLE
User::create($request->all()); // user can send: is_admin=true

// ✅ SAFE — whitelist with $fillable
class User extends Model
{
    protected $fillable = ['name', 'email', 'password']; // ONLY these accepted
    // is_admin is NOT in $fillable → ignored even if sent
}

// Or use only() to explicitly select what you want
User::create($request->only(['name', 'email', 'password']));

// ============================================================
// 5. IDOR — always scope to authenticated user
// ============================================================
// ❌ VULNERABLE
public function show(int $id): JsonResponse
{
    $order = Order::findOrFail($id); // anyone can access any order!
    return response()->json($order);
}

// ✅ SAFE — scope to current user
public function show(int $id): JsonResponse
{
    $order = auth()->user()->orders()->findOrFail($id); // 404 if not their order
    return response()->json($order);
}

// ✅ ALSO SAFE — authorization policy
public function show(Order $order): JsonResponse
{
    $this->authorize('view', $order); // throws 403 if user doesn't own it
    return response()->json($order);
}

// ============================================================
// BONUS: Path traversal
// ============================================================
// ❌ VULNERABLE
$file = $_GET['file']; // attacker sends: ../../etc/passwd
readfile('/uploads/' . $file);

// ✅ SAFE
$file     = basename($_GET['file']); // strips directory components
$fullPath = realpath('/uploads/' . $file);
if ($fullPath === false || !str_starts_with($fullPath, '/uploads/')) {
    abort(403, 'Invalid path');
}
readfile($fullPath);