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.
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.
Location: src/Repository/NoteRepository.php — findBySearchQuery()
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();
}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.
#[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');
}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();
}Location: templates/note/show.html.twig
{# The |raw filter disables Twig's automatic output escaping #}
<div class="note-content">{{ note.content|raw }}</div>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.
#[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');
}Remove the |raw filter — Twig escapes output by default:
<div class="note-content">{{ note.content }}</div>Location: src/Service/DnsLookupService.php — lookup()
public function lookup(string $domain): string
{
// User input passed directly to shell_exec()
return (string) shell_exec("host $domain");
}Submitting example.com; cat /etc/passwd as the domain produces:
host example.com; cat /etc/passwdThe shell executes both commands. The attacker sees DNS results followed by the contents of /etc/passwd.
#[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');
}Use escapeshellarg() to sanitize the input:
public function lookup(string $domain): string
{
return (string) shell_exec('host ' . escapeshellarg($domain));
}Location: src/Controller/NoteController.php — delete()
#[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');
}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).
#[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');
}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');
}vendor/bin/phpunitAll security-related tests will FAIL, demonstrating that the vulnerabilities exist. The basic functionality tests (page loads, note creation) will PASS.
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.