Security Testing
Security testing identifies vulnerabilities before attackers exploit them. In this lesson, we'll explore vulnerability scanning, penetration testing basics, OWASP testing guidelines, dependency audits, and practical security testing techniques to protect your applications.
Why Security Testing Matters
Security testing helps you:
- Identify vulnerabilities before production deployment
- Protect user data and prevent breaches
- Comply with security standards (PCI-DSS, GDPR, HIPAA)
- Prevent financial losses from security incidents
- Maintain customer trust and brand reputation
- Detect outdated dependencies with known vulnerabilities
- Validate security controls and authentication mechanisms
Important: Security testing is not a one-time activity. Regular testing, continuous monitoring, and staying updated with emerging threats are essential for maintaining application security.
OWASP Top 10 Vulnerabilities
The OWASP Top 10 represents the most critical web application security risks:
- A01: Broken Access Control - Unauthorized access to resources
- A02: Cryptographic Failures - Exposure of sensitive data
- A03: Injection - SQL, command, LDAP injection attacks
- A04: Insecure Design - Fundamental flaws in architecture
- A05: Security Misconfiguration - Default configs, verbose errors
- A06: Vulnerable Components - Outdated libraries with known CVEs
- A07: Authentication Failures - Weak password policies, session issues
- A08: Software & Data Integrity - Insecure CI/CD pipelines
- A09: Logging & Monitoring Failures - Insufficient audit trails
- A10: Server-Side Request Forgery (SSRF) - Fetching remote resources
Testing for SQL Injection
SQL injection is one of the most dangerous vulnerabilities. Test your application for proper input sanitization:
<?php
namespace Tests\Security;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SqlInjectionTest extends TestCase
{
use RefreshDatabase;
public function test_login_prevents_sql_injection()
{
User::factory()->create([
'email' => 'admin@example.com',
'password' => bcrypt('password123'),
]);
// Attempt SQL injection
$response = $this->post('/login', [
'email' => "admin@example.com' OR '1'='1",
'password' => "anything' OR '1'='1",
]);
// Should NOT authenticate
$response->assertRedirect('/login');
$response->assertSessionHasErrors();
$this->assertGuest();
}
public function test_search_prevents_sql_injection()
{
// Create test data
Product::factory()->count(10)->create();
// Attempt SQL injection in search
$maliciousQueries = [
"' OR 1=1--",
"' UNION SELECT * FROM users--",
"'; DROP TABLE products;--",
"1' AND 1=(SELECT COUNT(*) FROM users)--",
];
foreach ($maliciousQueries as $query) {
$response = $this->getJson("/api/search?q=" . urlencode($query));
// Should return safe results or empty
$response->assertOk();
// Should NOT expose database structure
$this->assertStringNotContainsString('users', $response->content());
$this->assertStringNotContainsString('DROP TABLE', $response->content());
}
}
public function test_parameterized_queries_are_used()
{
// Verify that raw queries are not used
$searchTerm = "test' OR '1'='1";
DB::enableQueryLog();
Product::where('name', 'like', "%{$searchTerm}%")->get();
$queries = DB::getQueryLog();
// Ensure query uses parameter binding
$this->assertNotEmpty($queries);
$this->assertStringContainsString('?', $queries[0]['query']);
$this->assertContains($searchTerm, $queries[0]['bindings']);
}
}
Testing for Cross-Site Scripting (XSS)
XSS vulnerabilities allow attackers to inject malicious scripts into web pages:
<?php
namespace Tests\Security;
use Tests\TestCase;
class XssProtectionTest extends TestCase
{
public function test_user_input_is_escaped_in_views()
{
$user = User::factory()->create([
'name' => '<script>alert("XSS")</script>',
]);
$response = $this->actingAs($user)->get('/profile');
$content = $response->content();
// Script tags should be escaped
$this->assertStringNotContainsString('<script>', $content);
$this->assertStringContainsString('<script>', $content);
}
public function test_comment_submission_escapes_html()
{
$post = Post::factory()->create();
$maliciousComment = '<img src=x onerror="alert(1)">';
$response = $this->post("/posts/{$post->id}/comments", [
'body' => $maliciousComment,
]);
// Retrieve the comment
$comment = Comment::latest()->first();
// HTML should be escaped
$this->assertStringNotContainsString('<img', $comment->body);
$this->assertStringNotContainsString('onerror', $comment->body);
}
public function test_json_responses_prevent_xss()
{
$response = $this->getJson('/api/user?name=<script>alert(1)</script>');
$response->assertOk();
$response->assertHeader('Content-Type', 'application/json');
// JSON should escape special characters
$content = $response->content();
$this->assertStringNotContainsString('<script>', $content);
}
}
Testing for CSRF Protection
Cross-Site Request Forgery (CSRF) attacks trick users into performing unwanted actions:
<?php
namespace Tests\Security;
use Tests\TestCase;
class CsrfProtectionTest extends TestCase
{
public function test_post_requests_require_csrf_token()
{
$user = User::factory()->create();
// Attempt POST without CSRF token
$response = $this->actingAs($user)->post('/profile/update', [
'name' => 'New Name',
]);
// Should be rejected (419 status)
$response->assertStatus(419);
}
public function test_delete_requests_require_csrf_token()
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
// Attempt DELETE without CSRF token
$response = $this->actingAs($user)
->delete("/posts/{$post->id}");
$response->assertStatus(419);
$this->assertDatabaseHas('posts', ['id' => $post->id]);
}
public function test_api_routes_exempt_from_csrf()
{
// API routes should use token authentication instead
$response = $this->postJson('/api/data', [
'key' => 'value',
]);
// Should not return CSRF error
$this->assertNotEquals(419, $response->status());
}
}
Testing Authentication and Authorization
Verify that authentication and authorization controls work correctly:
<?php
namespace Tests\Security;
use Tests\TestCase;
class AuthorizationTest extends TestCase
{
public function test_guest_cannot_access_protected_routes()
{
$protectedRoutes = [
['/dashboard', 'get'],
['/profile', 'get'],
['/posts/create', 'get'],
['/admin', 'get'],
];
foreach ($protectedRoutes as [$route, $method]) {
$response = $this->$method($route);
$response->assertRedirect('/login');
}
}
public function test_users_cannot_access_other_users_data()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$privatePost = Post::factory()->create([
'user_id' => $user2->id,
'is_private' => true,
]);
// User1 should NOT access User2's private post
$response = $this->actingAs($user1)
->get("/posts/{$privatePost->id}");
$response->assertForbidden();
}
public function test_regular_user_cannot_access_admin_panel()
{
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)->get('/admin');
$response->assertForbidden();
}
public function test_password_reset_token_cannot_be_reused()
{
$user = User::factory()->create();
// Request password reset
$token = Password::createToken($user);
// Use token once
$this->post('/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
]);
// Try to reuse same token
$response = $this->post('/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'anotherpassword',
'password_confirmation' => 'anotherpassword',
]);
$response->assertSessionHasErrors();
}
}
Dependency Vulnerability Scanning
Regularly scan dependencies for known vulnerabilities using automated tools:
# Composer security audit (PHP)
composer audit
# Check for outdated packages
composer outdated
# NPM audit (JavaScript)
npm audit
# Fix vulnerabilities automatically
npm audit fix
# Yarn audit (JavaScript)
yarn audit
# Snyk CLI (Multi-language)
npm install -g snyk
snyk auth
snyk test
snyk monitor
# GitHub Dependabot
# Enable in repository settings → Security → Dependabot alerts
Testing for Sensitive Data Exposure
Ensure sensitive data is not leaked in responses or logs:
<?php
namespace Tests\Security;
use Tests\TestCase;
class SensitiveDataExposureTest extends TestCase
{
public function test_passwords_not_included_in_api_responses()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->getJson('/api/user');
$response->assertOk();
$response->assertJsonMissing(['password' => $user->password]);
// Ensure password field is not present at all
$data = $response->json();
$this->assertArrayNotHasKey('password', $data);
}
public function test_credit_card_numbers_are_masked()
{
$order = Order::factory()->create([
'card_last_four' => '1234',
]);
$response = $this->actingAs($order->user)
->getJson("/api/orders/{$order->id}");
$data = $response->json();
// Full card number should not be present
$this->assertArrayNotHasKey('card_number', $data);
// Only last 4 digits should be shown
$this->assertEquals('****1234', $data['card_display']);
}
public function test_error_messages_do_not_expose_internals()
{
// Trigger a database error
$response = $this->getJson('/api/invalid-endpoint');
$content = $response->content();
// Should not expose database structure
$this->assertStringNotContainsString('SQL', $content);
$this->assertStringNotContainsString('table', strtolower($content));
$this->assertStringNotContainsString('/var/www/', $content);
// Should show generic error message
$this->assertStringContainsString('Not Found', $content);
}
public function test_api_tokens_are_not_logged()
{
$user = User::factory()->create();
$token = $user->createToken('test')->plainTextToken;
Log::spy();
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/user');
// Ensure token is not logged
Log::shouldNotHaveReceived('info')
->with(Mockery::on(function ($message) use ($token) {
return strpos($message, $token) !== false;
}));
}
}
Best Practice: Never store sensitive data like passwords, credit cards, or API keys in plain text. Always use encryption, hashing (bcrypt, Argon2), and tokenization where appropriate.
Testing Rate Limiting
Rate limiting prevents brute force attacks and API abuse:
<?php
namespace Tests\Security;
use Tests\TestCase;
class RateLimitingTest extends TestCase
{
public function test_login_rate_limiting_prevents_brute_force()
{
$user = User::factory()->create();
// Attempt multiple failed logins
for ($i = 0; $i < 10; $i++) {
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
}
// Next attempt should be rate limited
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertStatus(429); // Too Many Requests
$response->assertSee('Too many login attempts');
}
public function test_api_rate_limiting()
{
$user = User::factory()->create();
// Make 60 requests (assuming 60/minute limit)
for ($i = 0; $i < 60; $i++) {
$response = $this->actingAs($user)->getJson('/api/data');
$response->assertOk();
}
// 61st request should be rate limited
$response = $this->actingAs($user)->getJson('/api/data');
$response->assertStatus(429);
// Should include retry-after header
$this->assertNotNull($response->headers->get('Retry-After'));
}
}
Testing for Security Misconfigurations
Verify that your application is properly configured:
<?php
namespace Tests\Security;
use Tests\TestCase;
class SecurityConfigurationTest extends TestCase
{
public function test_debug_mode_disabled_in_production()
{
if (app()->environment('production')) {
$this->assertFalse(config('app.debug'),
'Debug mode should be disabled in production'
);
}
}
public function test_https_enforced_in_production()
{
if (app()->environment('production')) {
$response = $this->get('/');
// Check if HTTPS is enforced
$this->assertTrue(
$response->isRedirect() || request()->secure(),
'HTTPS should be enforced in production'
);
}
}
public function test_security_headers_present()
{
$response = $this->get('/');
// Check for security headers
$response->assertHeader('X-Frame-Options', 'SAMEORIGIN');
$response->assertHeader('X-Content-Type-Options', 'nosniff');
$response->assertHeader('X-XSS-Protection', '1; mode=block');
// Content Security Policy
$this->assertNotNull($response->headers->get('Content-Security-Policy'));
}
public function test_sensitive_files_not_accessible()
{
$sensitiveFiles = [
'/.env',
'/phpinfo.php',
'/.git/config',
'/composer.json',
'/package.json',
];
foreach ($sensitiveFiles as $file) {
$response = $this->get($file);
$response->assertNotFound();
}
}
public function test_default_credentials_changed()
{
// Ensure no default admin accounts exist
$this->assertDatabaseMissing('users', [
'email' => 'admin@admin.com',
]);
$this->assertDatabaseMissing('users', [
'username' => 'admin',
'password' => bcrypt('admin'),
]);
}
}
Penetration Testing Basics
Manual penetration testing helps discover vulnerabilities automated tools might miss:
# Common penetration testing techniques:
# 1. Directory Traversal
https://example.com/download?file=../../etc/passwd
https://example.com/uploads/../../../config/database.php
# 2. Parameter Tampering
https://example.com/user/profile?id=123
# Try: ?id=124 (access other user's data)
# 3. Mass Assignment
POST /api/users/123
{
"name": "John",
"is_admin": true // Attempt privilege escalation
}
# 4. Insecure Direct Object References (IDOR)
GET /api/orders/5001 // User's own order
GET /api/orders/5002 // Try accessing other user's order
# 5. Command Injection
https://example.com/ping?host=google.com;cat /etc/passwd
https://example.com/backup?file=data.zip && rm -rf /
# 6. LDAP Injection
username: admin)(&(password=*))
password: anything
# 7. XML External Entity (XXE)
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<data>&xxe;</data>
Warning: Only perform penetration testing on applications you own or have explicit written permission to test. Unauthorized testing is illegal and unethical.
Automated Security Scanning Tools
Use automated tools to continuously scan for vulnerabilities:
# OWASP ZAP (Zed Attack Proxy)
# Open-source web app security scanner
docker run -t owasp/zap2docker-stable zap-baseline.py -t https://example.com
# Nikto - Web server scanner
nikto -h https://example.com
# Nmap - Network scanner
nmap -sV -sC example.com
# SQLMap - SQL injection testing
sqlmap -u "https://example.com/search?q=test" --batch --banner
# Burp Suite Community Edition
# Download from: https://portswigger.net/burp/communitydownload
# Wapiti - Web vulnerability scanner
wapiti -u https://example.com
# Arachni - Web application security scanner
arachni https://example.com --checks=*
# SonarQube - Code quality and security
# Integrates with CI/CD pipelines
docker run -d --name sonarqube -p 9000:9000 sonarqube:latest
Exercise 1: Create a comprehensive security test suite that checks for: (1) SQL injection in all form inputs, (2) XSS in user-generated content, (3) CSRF protection on state-changing operations, (4) proper authorization on API endpoints, and (5) sensitive data exposure in logs.
Exercise 2: Set up automated dependency scanning in your CI/CD pipeline using Snyk or GitHub Dependabot. Configure it to fail builds if high-severity vulnerabilities are detected.
Exercise 3: Perform a security audit of your application using OWASP ZAP. Document all findings with severity levels (critical, high, medium, low) and create a remediation plan with priorities.
Summary
In this lesson, we've covered essential security testing practices:
- Understanding OWASP Top 10 vulnerabilities and how to test for them
- Testing for SQL injection, XSS, and CSRF vulnerabilities
- Verifying authentication and authorization controls
- Scanning dependencies for known vulnerabilities
- Testing for sensitive data exposure in responses and logs
- Implementing and testing rate limiting to prevent abuse
- Verifying security configurations and headers
- Using automated security scanning tools
- Basic penetration testing techniques
Security is not a one-time effort but an ongoing process. Integrate security testing into your development workflow, stay informed about emerging threats, and regularly update your dependencies and security practices.