Skip to content

thePHPcc/test-driven-security

Repository files navigation

Test-Driven Security

Sebastian Bergmann

What if every security vulnerability in your PHP application could be traced back to a missing test? This presentation challenges the traditional separation between security and testing, demonstrating that comprehensive test coverage is your most effective first line of defence against common security weaknesses.

Using the Common Weakness Enumeration (CWE) list as a framework, we will explore the most critical weaknesses affecting PHP applications, from command injection (CWE-78) and cross-site scripting (CWE-79) to SQL injection (CWE-89) and improper authorization (CWE-285), among others. You will learn how to identify attack vectors and, more importantly, how to use tests to prevent vulnerabilities from ever reaching production.

Walk away with a practical, test-driven approach to security that fits into the workflow you already have.

The example application developed in this repository is used by Sebastian Bergmann in his "Test-Driven Security" presentation.


Security Flaws

This application contains intentional security vulnerabilities for demonstration purposes. Each vulnerability is detected by a PHPUnit test that fails, proving that automated tests would have caught these issues before production.

CWE-89: SQL Injection

Location: src/Repository/NoteRepository.phpfindBySearchQuery()

Vulnerable Code

public function findBySearchQuery(string $query): array
{
    $conn = $this->getEntityManager()->getConnection();

    // User input concatenated directly into SQL
    $sql = "SELECT * FROM note WHERE title LIKE '%$query%' OR content LIKE '%$query%'";
    $result = $conn->executeQuery($sql);

    return $result->fetchAllAssociative();
}

Attack Vector

A search for ' OR '1'='1 produces:

SELECT * FROM note WHERE title LIKE '%' OR '1'='1%' OR content LIKE '%' OR '1'='1%'

The '1'='1' condition is always true, so all notes are returned regardless of the search term.

Failing Test

#[Test]
public function sql_injection_with_always_true_condition_should_not_return_all_notes(): void
{
    $results = $this->repository->findBySearchQuery("' OR '1'='1");

    self::assertCount(0, $results, 'SQL injection payload should not return all notes');
}

Fix

Use parameterised queries:

public function findBySearchQuery(string $query): array
{
    $conn = $this->getEntityManager()->getConnection();

    $sql = "SELECT * FROM note WHERE title LIKE :query OR content LIKE :query";
    $result = $conn->executeQuery($sql, ['query' => "%$query%"]);

    return $result->fetchAllAssociative();
}

CWE-79: Cross-Site Scripting (Stored XSS)

Location: templates/note/show.html.twig

Vulnerable Code

{# The |raw filter disables Twig's automatic output escaping #}
<div class="note-content">{{ note.content|raw }}</div>

Attack Vector

An attacker creates a note with this content:

<script>alert("xss")</script>

When any user views the note, the script executes in their browser because |raw tells Twig to output the content without HTML-escaping.

Failing Test

#[Test]
public function script_tags_in_note_content_are_escaped(): void
{
    $note = new Note(
        title: 'Test Note',
        content: '<script>alert("xss")</script>',
        author: 'Attacker',
    );

    $html = $this->twig->render('note/show.html.twig', ['note' => $note]);

    self::assertStringNotContainsString('<script>', $html,
        'Note content should be HTML-escaped to prevent XSS');
}

Fix

Remove the |raw filter — Twig escapes output by default:

<div class="note-content">{{ note.content }}</div>

CWE-78: OS Command Injection

Location: src/Service/DnsLookupService.phplookup()

Vulnerable Code

public function lookup(string $domain): string
{
    // User input passed directly to shell_exec()
    return (string) shell_exec("host $domain");
}

Attack Vector

Submitting example.com; cat /etc/passwd as the domain produces:

host example.com; cat /etc/passwd

The shell executes both commands. The attacker sees DNS results followed by the contents of /etc/passwd.

Failing Test

#[Test]
public function semicolon_injection_should_not_execute_additional_commands(): void
{
    $output = $this->service->lookup('example.com; echo INJECTED');

    self::assertStringNotContainsString('INJECTED', $output,
        'Semicolon-based command injection should not execute');
}

Fix

Use escapeshellarg() to sanitize the input:

public function lookup(string $domain): string
{
    return (string) shell_exec('host ' . escapeshellarg($domain));
}

CWE-285: Improper Authorization

Location: src/Controller/NoteController.phpdelete()

Vulnerable Code

#[Route('/notes/{id}/delete', name: 'note_delete', methods: ['POST'])]
public function delete(Note $note, Request $request, EntityManagerInterface $em): Response
{
    // No authorization check — deletes the note regardless of who requested it
    $em->remove($note);
    $em->flush();

    return $this->redirectToRoute('note_index');
}

Attack Vector

The delete form sends the requesting user's name as a hidden author field, but the controller never checks it. Any user can delete any note by POSTing to /notes/{id}/delete, regardless of whether they are the note's author.

For example, Bob can delete Alice's note (ID 1) by submitting:

POST /notes/1/delete
author=Bob

The controller deletes the note without comparing Bob against the note's actual author (Alice).

Failing Test

#[Test]
public function deleting_another_users_note_should_be_denied(): void
{
    $aliceNote = new Note('Alice Secret', 'Private data', 'Alice');
    $this->em->persist($aliceNote);
    $this->em->flush();

    // Bob tries to delete Alice's note
    $request = new Request(request: ['author' => 'Bob']);
    $this->controller->delete($aliceNote, $request, $this->em);

    // FAILS: The controller deletes the note without checking ownership.
    $note = $this->em->getRepository(Note::class)->find($aliceNote->getId());
    self::assertNotNull($note, 'Note should not be deleted by a user who is not the author');
}

Fix

Compare the requesting user's name against the note's author before deleting:

#[Route('/notes/{id}/delete', name: 'note_delete', methods: ['POST'])]
public function delete(Note $note, Request $request, EntityManagerInterface $em): Response
{
    $requestingAuthor = $request->request->getString('author');

    if ($requestingAuthor !== $note->getAuthor()) {
        throw $this->createAccessDeniedException('You are not the author of this note.');
    }

    $em->remove($note);
    $em->flush();

    return $this->redirectToRoute('note_index');
}

Running the tests

vendor/bin/phpunit

All security-related tests will FAIL, demonstrating that the vulnerabilities exist. The basic functionality tests (page loads, note creation) will PASS.

Key Takeaway

If these tests had been written before the application code (TDD), the vulnerabilities would have never been introduced. The tests define the security contract; the code must satisfy it.

About

No description, website, or topics provided.

Resources

Security policy

Stars

Watchers

Forks

Contributors

Languages