Hardening my Django Portfolio

Updated: 3 weeks, 1 day ago

In this post, I'll walk through the security architecture of my Django portfolio (erikwalther.eu), covering everything from the reverse proxy and application logic to system-level hardening and brute-force protection.

Overview

My stack is a classic, robust Linux combination:

Component Technology
Reverse Proxy Caddy
App Server Gunicorn
Framework Django
Database PostgreSQL
OS Fedora Server
Init System systemd
Brute-Force Protection django-axes

The goal was to build a personal portfolio that is resilient against common web attacks (XSS, CSRF, SQLi) and brute-force attempts, without relying on external IDS/IPS tools like Fail2Ban.

Web Server: Caddy, CSP & Request Filtering

Caddy acts as the reverse proxy, handling TLS termination and serving static files. Its most critical roles are enforcing a strict Content Security Policy and filtering out malicious requests before they ever reach Django.

The CSP Strategy

Content-Security-Policy "default-src 'self'; 
    img-src 'self' data: https://erikwalther.eu; 
    style-src 'self'; 
    script-src 'self'; 
    connect-src 'self'; 
    font-src 'self'; 
    frame-ancestors 'none'; 
    base-uri 'self'; 
    form-action 'self';"

Why this works:

  • script-src 'self': This is the killer feature. It blocks all inline scripts and external JavaScript. Even if an attacker manages to inject a <script> tag into my database, the browser will refuse to execute it.
  • frame-ancestors 'none': Completely disables clickjacking by preventing the site from being embedded in an iframe.
  • form-action 'self': Ensures that any form submissions (like login forms) can only go to my own domain, mitigating CSRF attacks at the protocol level.

Header Hardening

Beyond CSP, I strip identifying headers and enforce strict transport security:

header {
    -Server
    -X-Powered-By
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "same-origin"
    Permissions-Policy "geolocation=(), microphone=(), camera=()"
}

Removing Server and X-Powered-By obscures the tech stack, making automated scanning slightly less effective. The Permissions-Policy disables access to hardware features irrelevant to a static portfolio, ensuring a little bit of privacy for the visitor.

PHP Request Blocking

Automated scanners constantly probe for WordPress, Joomla, and other PHP-based CMS files. Since my site runs Django, every .php request is guaranteed to be malicious. I block them at the Caddy level so they never reach Django:

@php_files path *.php
handle @php_files {
    respond 404
}

This eliminates 99% of automated scanning noise. Bots requesting /wp-admin/install.php or /xmlrpc.php get an instant 404 from Caddy, consuming zero application resources.

Proxy Headers

Caddy forwards the real client IP to Gunicorn so that Django and django-axes can see who is actually making requests:

reverse_proxy unix//run/gunicorn/gunicorn.sock {
    header_up Host {host}
    header_up X-Forwarded-For {remote_host}
    header_up X-Forwarded-Proto {scheme}
    header_up X-Forwarded-Host {host}
}

Without X-Forwarded-For, Django would see no IP because of the Unix socket connection, making IP-based security measures useless.

Application Layer: Django, nh3 & Middleware

While the web server handles transport security, the application layer must sanitize user input. My portfolio allows me to write project pages in Markdown, which is then converted to HTML. This is a common vector for XSS if not handled correctly.

The Markdown Pipeline

In models.py, I override the save() method of the ProjectPage model to handle conversion and sanitization:

import markdown
import nh3

def save(self, *args, kwargs):
    # 1. Convert Markdown to HTML
    md = markdown.markdown(
        self.content_markdown,
        extensions=['fenced_code', 'codehilite', 'toc', 'tables', 'nl2br']
    )

    # 2. Sanitize with nh3
    allowed_tags = {
        'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'a', 'img', 'hr',
        'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'div'
    }
    allowed_attrs = {
        '*': {'class', 'id'},
        'a': {'href', 'title', 'target'},
        'img': {'src', 'alt', 'title'}
    }

    # Strip disallowed tags and attributes
    self.content_html = nh3.clean(md, tags=allowed_tags, attributes=allowed_attrs)

    super().save(*args, kwargs)

Why this matters: Even with a strict CSP, it's best practice to sanitize data at the source. nh3 strips any tags not in the allowed_tags list (like <script>, <iframe>, or onerror handlers). This provides a second line of defense: if the CSP ever fails or is misconfigured, the malicious payload is already removed from the database.

Custom Middleware for IP Resolution

Running Django behind a reverse proxy introduces a subtle problem: request.META['REMOTE_ADDR'] contains no IP when using a Unix socket. I wrote a middleware class to solve this.

TrustProxyMiddleware extracts the real IP from X-Forwarded-For and overwrites REMOTE_ADDR before any other middleware runs:

class TrustProxyMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if forwarded_for:
            request.META['REMOTE_ADDR'] = forwarded_for.split(',')[0].strip()

        real_ip = request.META.get('HTTP_X_REAL_IP')
        if not forwarded_for and real_ip:
            request.META['REMOTE_ADDR'] = real_ip.strip()

        return self.get_response(request)

This is critical for django-axes to function correctly otherwise axes sees no IP address and cannot group failed login attempts, effectively disabling brute-force protection.

Django Security Settings

The settings.py reinforces this with:

  • SECURE_SSL_REDIRECT = True: Forces HTTPS.
  • SECURE_HSTS_SECONDS = 31536000: Enforces HTTPS for a year (HSTS).
  • CSRF_COOKIE_SECURE = True: Protects CSRF tokens.
  • SECRET_KEY loaded via python-decouple: Ensures secrets aren't in the codebase.
  • Brute-Force Protection: django-axes

Instead of relying on an external tool like Fail2Ban that parses log files and manipulates firewall rules, I use django-axes, a Django-native solution that operates directly within the application layer.

Configuration

# settings.py

INSTALLED_APPS = [
    # ...
    'axes',
    'portfolio',
]

AUTHENTICATION_BACKENDS = [
    'axes.backends.AxesStandaloneBackend',
    'django.contrib.auth.backends.ModelBackend',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'erikwalther.middleware.TrustProxyMiddleware',
    'axes.middleware.AxesMiddleware',
    # ...
]

# Axes settings
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 24
AXES_LOCK_OUT_BY = ["username", "ip_address"]
AXES_RESET_ON_SUCCESS = True
AXES_HANDLER = 'axes.handlers.database.AxesDatabaseHandler'
AXES_LOCKOUT_TEMPLATE = '403.html'
AXES_VERBOSE = True

Key design decisions:

  • AXES_LOCK_OUT_BY = ["username", "ip_address"]: Locks the specific combination of username and IP. An attacker brute-forcing admin from IP 1.2.3.4 gets blocked, but I can still log in as admin from my own IP. This prevents accidental self-lockout while still stopping credential stuffing.
  • AXES_LOCKOUT_TEMPLATE = '403.html': Instead of the default plain-text lockout message, axes renders my custom 403 template that matches the site's design. The template can display the cooldown time via the cooloff_time context variable.
  • Middleware order matters: TrustProxyMiddleware must run before AxesMiddleware so that axes sees the real client IP.

How It Works

  1. An attacker posts to /admin/login/ with guessed credentials.
  2. Django's authentication backend rejects the login.
  3. AxesMiddleware intercepts the failure and records it in the axes_accessattempt database table, keyed by username + IP address.
  4. After 5 failures within the cooldown window, axes returns a 403 Forbidden for all subsequent login attempts from that user+IP combination.
  5. After AXES_COOLOFF_TIME (1 day), the lockout expires automatically.
  6. A successful login resets the failure counter (AXES_RESET_ON_SUCCESS = True).

Because the lockout state lives in PostgreSQL, it survives Gunicorn restarts and redeploys.

Process Isolation: Gunicorn & systemd

The application server and the operating system provide the final containment layer.

Gunicorn Configuration

Gunicorn binds to a Unix socket, not a TCP port, meaning it's not directly accessible from the network.

bind = "unix:/run/gunicorn/gunicorn.sock"
limit_request_line = 4094
limit_request_fields = 100
limit_request_field_size = 8190

The limit_request_* settings prevent buffer overflow attacks by rejecting oversized HTTP headers.

systemd Hardening

The gunicorn.service unit file applies aggressive sandboxing:

[Service]
User=user
Group=group
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/path/to/project /path/to/log /path/to/tmp
SELinuxContext=system_u:system_r:httpd_t:s0
  • ProtectSystem=strict: The entire filesystem is read-only except for explicitly defined paths.
  • PrivateTmp=true: The process gets its own isolated /tmp.
  • NoNewPrivileges=true: The process cannot escalate privileges, even if exploited.
  • SELinux: Adds Mandatory Access Control (MAC), restricting what the process can do even if it breaks out of the systemd sandbox.

Conclusion

Security is a layered effort. No single tool makes a site "secure."

  • Caddy handles transport security, blocks malicious payloads via CSP, and drops PHP requests at the edge.
  • Django & nh3 sanitize data at the application level.
  • Custom middleware resolves real client IPs.
  • Django-axes blocks brute-force attacks natively within Django, with database-backed persistence and custom lockout pages.
  • Gunicorn limits request sizes and isolates the process.
  • SELinux and systemd restrict filesystem and privilege access.

By combining these layers, I've created a portfolio that is resilient against the vast majority of automated and manual attacks.