> your AI agent picks dependencies from memory; give it dated facts — try starlog.dev ↗ vet your agent's deps ↗ vibe-coding is fine. vibe-importing isn’t. — try starlog.dev ↗ vibe-importing isn’t fine ↗ your agent has never seen your private packages — try starlog.dev ↗ facts for private packages ↗ a linter for the dependencies your AI agent picks — try starlog.dev ↗ a linter for agent deps ↗

Back to Articles

Security Regression Testing in Django: Testing Your Way Out of Vulnerabilities

[ View on GitHub ]

Security Regression Testing in Django: Testing Your Way Out of Vulnerabilities

Hook

Your security team fixed that broken authentication bug last month. Want to bet it's still fixed today? Security bugs have an annoying habit of reappearing after seemingly innocent code changes—unless you have tests watching your back.

Context

Security vulnerabilities follow a frustrating lifecycle in most codebases. A penetration test finds an XSS vulnerability, a developer fixes it, code review approves it, and everyone moves on. Six months later, a refactoring or new feature inadvertently removes the sanitization logic, and the same vulnerability returns. Rinse and repeat.

The problem is that security requirements are rarely encoded as automated tests. We test that our login flow works, but do we test that it actually requires authentication? We verify our forms submit successfully, but do we confirm they reject CSRF attacks? The lavalamp-/security-unit-testing repository demonstrates a different approach: treating security controls as first-class testable requirements. By encoding security properties as unit tests that run with every commit, you catch regressions before they reach production. This Django-based project shows how to systematically test authentication enforcement, security headers, CSRF protection, and common OWASP vulnerabilities through automated test suites that scale with your application.

Technical Insight

Test Execution

Requestor Pattern

defines endpoints

declares methods & auth rules

discovers all requestors

generates tests for each endpoint

executes: auth, headers, CSRF, XSS, SQLi

validates responses

Django Views

Requestor Classes

Test Introspection Engine

Dynamic Test Generator

Security Test Suite

Test Results & Reports

System architecture — auto-generated

The core architectural pattern here is the 'requestor' system—a convention where each Django view has an associated requestor class that explicitly declares all HTTP methods that endpoint supports. This creates a contract that the test suite can introspect to dynamically generate security tests for every endpoint in your application.

Here's how a requestor class looks:

class ArtworkDetailRequestor(BaseRequestor):
    route_name = 'artwork_detail'
    supported_methods = ['GET', 'POST']
    authentication_required = True
    
    def get_route_kwargs(self):
        return {'pk': self.artwork.id}

The test suite uses introspection to discover all requestor classes and automatically generates security tests. For instance, the authentication test iterates through all requestors marked with authentication_required=True and verifies that accessing those endpoints without credentials returns a 401 or 302 redirect:

def test_authentication_required(self):
    for requestor_class in self.get_requestor_classes():
        requestor = requestor_class()
        if not requestor.authentication_required:
            continue
            
        for method in requestor.supported_methods:
            response = self.make_request(method, requestor, authenticated=False)
            self.assertIn(
                response.status_code,
                [401, 302],
                f"{requestor.route_name} allows {method} without authentication"
            )

This pattern scales beautifully. Add a new view? Create a requestor class, and you automatically get authentication tests, CSRF tests, security header tests, and more. The tests fail if you forget to add authentication decorators or misconfigure CSRF exemptions.

The project uses git tags (v0.1 through v0.22) to create a progressive tutorial. Each tag represents a specific vulnerability state. For example, tag v0.8 shows SQL injection vulnerabilities in the search functionality, while v0.9 shows the fix with parameterized queries. This makes it perfect for workshops where you can check out a vulnerable state, write a failing test that exposes the issue, then implement the fix until tests pass.

The security header tests are particularly elegant. Rather than manually checking headers in every view test, a single test suite validates that all responses include appropriate headers:

def test_security_headers_present(self):
    required_headers = [
        'X-Content-Type-Options',
        'X-Frame-Options',
        'Content-Security-Policy',
    ]
    
    for requestor_class in self.get_requestor_classes():
        requestor = requestor_class()
        response = self.make_request('GET', requestor, authenticated=True)
        
        for header in required_headers:
            self.assertIn(
                header,
                response,
                f"{requestor.route_name} missing {header} header"
            )

The repository also demonstrates testing for specific vulnerability classes. The XSS tests verify that user input is properly escaped in templates, SQL injection tests check that queries use parameterized statements, and open redirect tests ensure that redirect targets are validated against a whitelist. Each test serves as both validation and documentation of what security properties your application guarantees.

What makes this approach powerful is the shift in mindset. Instead of hoping security controls remain in place, you're actively asserting their presence with every test run. When someone accidentally removes authentication from an endpoint or disables CSRF protection to 'fix' a form issue, CI fails loudly. Security becomes part of your contract with the codebase, not a checklist item from six months ago.

Gotcha

This pattern works beautifully—if you commit to its conventions. The requestor abstraction is the linchpin of the entire system. Every view needs a corresponding requestor class, and that requestor must accurately declare supported methods, authentication requirements, and other security properties. Miss a requestor, and you've got an untested endpoint. Declare the wrong supported methods, and your tests validate the wrong behavior.

The approach also suffers from the fundamental limitation of unit testing security: you're only testing what you think to test. These tests catch regressions in known security controls, but they won't discover novel vulnerabilities or business logic flaws. A perfectly authenticated endpoint can still have an insecure direct object reference bug if your tests don't verify authorization (authentication vs. authorization—the tests here focus primarily on the former). You'll still need security reviews, threat modeling, and ideally some dynamic scanning or penetration testing to catch what your unit tests miss. This is a regression prevention system, not a vulnerability discovery system.

Verdict

Use if: You're building a Django application where security is critical, your team understands the security properties you're testing for, and you're willing to adopt the requestor convention across your codebase. This is especially valuable for regulated industries (healthcare, finance) where proving security controls remain in place is as important as implementing them initially. It's also excellent for teams learning security testing—the progressive git tag tutorial makes this a fantastic workshop or onboarding resource. Skip if: You need a plug-and-play security solution (this requires thoughtful integration and convention adoption), you're working in a non-Django framework (the patterns are educational but the code is Django-specific), or you're expecting this to replace comprehensive security testing. Use this as one layer in a defense-in-depth strategy, not as your entire security program. For quick security wins without code changes, start with tools like Bandit for static analysis or Django's built-in security middleware before committing to a test-driven security approach.