# Flask Security Audit Guide

> Flask Framework Security Audit Module
> Applicable to: Flask, Flask-RESTful, Flask-Login, Flask-JWT-Extended

---

## Identification Patterns

```python
# Flask project identification
from flask import Flask, request, render_template
app = Flask(__name__)

# Common directory structure
├── app/
│   ├── __init__.py      # create_app factory
│   ├── routes/          # Route blueprints
│   ├── models/          # SQLAlchemy models
│   ├── templates/       # Jinja2 templates
│   └── static/          # Static assets
├── config.py            # Configuration
├── requirements.txt
└── run.py / wsgi.py
```

---

## Critical Vulnerabilities

### 1. Debug Mode RCE

```python
# Dangerous: Debug mode enabled in production
if __name__ == '__main__':
    app.run(debug=True)  # Werkzeug debugger PIN can be calculated

# Dangerous: Configuration file
DEBUG = True
FLASK_DEBUG = 1

# PIN calculation factors (can be used for brute-force):
# - username (running user)
# - modname (usually flask.app)
# - getattr(app, '__name__', app.__class__.__name__)
# - getattr(mod, '__file__', None) → app.py path
# - uuid.getnode() → MAC address
# - get_machine_id() → /etc/machine-id or /proc/sys/kernel/random/boot_id

# Detection
grep -rn "debug\s*=\s*True" --include="*.py"
grep -rn "FLASK_DEBUG\|DEBUG\s*=" --include="*.py" --include="*.env"
```

### 2. SECRET_KEY Leakage

```python
# Dangerous: Hardcoded key
app.secret_key = 'super_secret_key'
app.config['SECRET_KEY'] = 'hardcoded'

# Dangerous: Weak key
SECRET_KEY = 'development'
SECRET_KEY = os.urandom(16)  # Changes on every restart, sessions invalidated

# Impact:
# - Session forgery
# - Flask-Login token forgery
# - CSRF token forgery
# - itsdangerous signature forgery

# Safe: Read from environment variable
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY')
if not app.config['SECRET_KEY']:
    raise ValueError("SECRET_KEY not set")

# Detection
grep -rn "secret_key\s*=\|SECRET_KEY\s*=" --include="*.py"
```

### 3. SSTI (Server-Side Template Injection)

```python
# Dangerous: render_template_string with user input
from flask import render_template_string

@app.route('/hello')
def hello():
    name = request.args.get('name', 'World')
    template = f'<h1>Hello {name}!</h1>'
    return render_template_string(template)

# payload: {{config.items()}}
# payload: {{''.__class__.__mro__[1].__subclasses__()}}
# RCE payload:
# {{''.__class__.__mro__[1].__subclasses__()[X].__init__.__globals__['os'].popen('id').read()}}

# Dangerous: User-controlled template name
@app.route('/page/<template>')
def page(template):
    return render_template(template)  # May load arbitrary templates

# Safe: Fixed template, pass variables
@app.route('/hello')
def hello():
    name = request.args.get('name', 'World')
    return render_template('hello.html', name=name)

# SSTI detection commands
grep -rn "render_template_string" --include="*.py"
grep -rn "render_template.*request\." --include="*.py"
grep -rn "Template\s*\(" --include="*.py"
```

### 4. Pickle Session Deserialization

```python
# Dangerous: Using pickle for session serialization
# Flask defaults to itsdangerous-signed JSON
# But some extensions may use pickle

# Flask-Session configuration
SESSION_TYPE = 'filesystem'  # File storage
SESSION_SERIALIZER = PickleSerializer  # Dangerous!

# If SECRET_KEY leaked + pickle session = RCE

# Safe configuration
SESSION_TYPE = 'redis'
SESSION_SERIALIZER = 'json'

# Detection
grep -rn "SESSION_SERIALIZER\|PickleSerializer\|pickle" --include="*.py"
```

---

## High Vulnerabilities

### 5. SQL Injection

```python
# Dangerous: Raw SQL concatenation
from flask_sqlalchemy import SQLAlchemy

@app.route('/user/<id>')
def get_user(id):
    sql = f"SELECT * FROM users WHERE id = {id}"
    result = db.engine.execute(sql)
    return jsonify(result.fetchone())

# Dangerous: text() concatenation
from sqlalchemy import text
db.session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))

# Safe: Parameterized
db.session.execute(
    text("SELECT * FROM users WHERE name = :name"),
    {"name": name}
)

# Safe: ORM
User.query.filter_by(id=id).first()
User.query.filter(User.name == name).all()

# Detection
grep -rn "execute\s*\(.*f[\"']" --include="*.py"
grep -rn "execute\s*\(.*\+" --include="*.py"
grep -rn "text\s*\(.*f[\"']" --include="*.py"
```

### 6. Command Injection

```python
# Dangerous: os.system
@app.route('/ping')
def ping():
    host = request.args.get('host')
    os.system(f'ping -c 4 {host}')
    return 'Done'
# payload: ?host=127.0.0.1;id

# Dangerous: subprocess shell=True
import subprocess
subprocess.run(f'echo {user_input}', shell=True)

# Safe: Argument list
subprocess.run(['ping', '-c', '4', host], shell=False)

# Safer: Avoid command execution
import socket
socket.create_connection((host, 80), timeout=5)
```

### 7. Arbitrary File Read/Write

```python
# Dangerous: Path concatenation
@app.route('/download')
def download():
    filename = request.args.get('file')
    return send_file(f'/uploads/{filename}')
# payload: ?file=../../../etc/passwd

# Dangerous: Unvalidated file upload
@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    file.save(f'/uploads/{file.filename}')  # Dangerous!

# Safe: Validation and sanitization
from werkzeug.utils import secure_filename
import os

UPLOAD_FOLDER = '/var/www/uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    if file and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        file.save(os.path.join(UPLOAD_FOLDER, filename))
        return 'Uploaded'
    return 'Invalid file', 400

# Safe download
@app.route('/download')
def download():
    filename = request.args.get('file')
    safe_name = secure_filename(filename)
    full_path = os.path.join(UPLOAD_FOLDER, safe_name)

    # Validate path is within allowed directory
    if not full_path.startswith(UPLOAD_FOLDER):
        abort(403)

    return send_from_directory(UPLOAD_FOLDER, safe_name)
```

### 8. SSRF

```python
# Dangerous: Unvalidated URL
import requests

@app.route('/fetch')
def fetch():
    url = request.args.get('url')
    resp = requests.get(url)
    return resp.text
# payload: ?url=http://169.254.169.254/latest/meta-data/

# Safe: URL whitelist
from urllib.parse import urlparse

ALLOWED_HOSTS = ['api.example.com']

@app.route('/fetch')
def fetch():
    url = request.args.get('url')
    parsed = urlparse(url)

    if parsed.scheme not in ['http', 'https']:
        abort(400)

    if parsed.hostname not in ALLOWED_HOSTS:
        abort(403)

    resp = requests.get(url, timeout=5)
    return resp.text
```

---

## Medium Vulnerabilities

### 9. XSS

```python
# Dangerous: Markup/safe markup
from flask import Markup

@app.route('/comment')
def comment():
    text = request.args.get('text')
    return Markup(f'<p>{text}</p>')  # Not escaped!

# Dangerous: |safe filter
{{ user_input|safe }}

# Dangerous: Returning HTML directly
@app.route('/hello')
def hello():
    name = request.args.get('name')
    return f'<h1>Hello {name}</h1>'  # Not through template engine

# Safe: Use template auto-escaping
return render_template('hello.html', name=name)
# Template: <h1>Hello {{ name }}</h1>  # Auto-escaped

# Detection
grep -rn "Markup\s*\(" --include="*.py"
grep -rn "|safe" --include="*.html"
grep -rn "return.*f['\"].*<" --include="*.py"
```

### 10. Missing CSRF Protection

```python
# Dangerous: No CSRF protection
@app.route('/transfer', methods=['POST'])
def transfer():
    amount = request.form['amount']
    to_account = request.form['to']
    # Execute transfer...

# Safe: Flask-WTF CSRF
from flask_wtf.csrf import CSRFProtect

csrf = CSRFProtect(app)

# Add to form
{{ csrf_token() }}
# Or
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

# AJAX request
headers: {
    'X-CSRFToken': '{{ csrf_token() }}'
}

# Detection
grep -rn "methods.*POST\|methods.*PUT\|methods.*DELETE" --include="*.py"
# Check if CSRF protection is present
```

### 11. Open Redirect

```python
# Dangerous: Unvalidated redirect URL
from flask import redirect

@app.route('/login')
def login():
    next_url = request.args.get('next')
    # After successful login
    return redirect(next_url)
# payload: ?next=http://evil.com

# Safe: Verify it is a relative path or same domain
from urllib.parse import urlparse, urljoin

def is_safe_url(target):
    ref_url = urlparse(request.host_url)
    test_url = urlparse(urljoin(request.host_url, target))
    return test_url.scheme in ('http', 'https') and \
           ref_url.netloc == test_url.netloc

@app.route('/login')
def login():
    next_url = request.args.get('next', '/')
    if not is_safe_url(next_url):
        next_url = '/'
    return redirect(next_url)
```

### 12. Insecure Session Configuration

```python
# Dangerous configuration
SESSION_COOKIE_SECURE = False    # HTTP transmission
SESSION_COOKIE_HTTPONLY = False  # JS accessible
SESSION_COOKIE_SAMESITE = None   # No SameSite

# Safe configuration
app.config.update(
    SESSION_COOKIE_SECURE=True,      # HTTPS only
    SESSION_COOKIE_HTTPONLY=True,    # JS inaccessible
    SESSION_COOKIE_SAMESITE='Lax',   # CSRF protection
    PERMANENT_SESSION_LIFETIME=3600, # 1 hour expiration
)
```

---

## Flask Extension Security

### Flask-Login

```python
# Dangerous: remember_me with no expiration
login_user(user, remember=True)  # Default 365 days

# Safe: Set expiration time
app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=7)
app.config['REMEMBER_COOKIE_SECURE'] = True
app.config['REMEMBER_COOKIE_HTTPONLY'] = True

# Dangerous: user_loader does not validate status
@login_manager.user_loader
def load_user(user_id):
    return User.query.get(user_id)  # May return disabled user

# Safe: Validate user status
@login_manager.user_loader
def load_user(user_id):
    user = User.query.get(user_id)
    if user and user.is_active:
        return user
    return None
```

### Flask-JWT-Extended

```python
# Dangerous configuration
JWT_SECRET_KEY = 'weak_secret'
JWT_ACCESS_TOKEN_EXPIRES = False  # Never expires

# Safe configuration
app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET')
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=1)
app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(days=30)
app.config['JWT_COOKIE_SECURE'] = True
app.config['JWT_COOKIE_CSRF_PROTECT'] = True
```

### Flask-CORS

```python
# Dangerous: Allow all origins
from flask_cors import CORS
CORS(app)  # Allows all by default

# More dangerous: Allow credentials
CORS(app, supports_credentials=True, origins='*')

# Safe: Specify allowed origins
CORS(app,
     origins=['https://trusted.example.com'],
     supports_credentials=True,
     methods=['GET', 'POST'],
     allow_headers=['Content-Type', 'Authorization'])
```

---

## Detection Commands

```bash
# Debug mode
grep -rn "debug\s*=\s*True" --include="*.py"
grep -rn "FLASK_DEBUG" --include="*.env" --include="*.py"

# SECRET_KEY
grep -rn "secret_key\s*=\|SECRET_KEY\s*=" --include="*.py"

# SSTI
grep -rn "render_template_string\|Markup\s*\(" --include="*.py"

# SQL Injection
grep -rn "execute\s*\(" --include="*.py" | grep -E "f['\"]|\+"
grep -rn "text\s*\(" --include="*.py" | grep -E "f['\"]|\+"

# Command Execution
grep -rn "os\.system\|subprocess\.\|popen" --include="*.py"

# File Operations
grep -rn "send_file\|send_from_directory\|open\s*\(" --include="*.py"

# Comprehensive scan
bandit -r . -ll
```

---

## Audit Checklist

```
[ ] Check if DEBUG mode is disabled in production
[ ] Check if SECRET_KEY is hardcoded or weak
[ ] Search for render_template_string usage
[ ] Check if SQL queries are parameterized
[ ] Check command execution functions
[ ] Check if file operations have path traversal
[ ] Check if HTTP requests have SSRF
[ ] Check if CSRF protection is enabled
[ ] Check Session configuration
[ ] Check CORS configuration
[ ] Check Flask extension configuration
[ ] Run bandit static analysis
```

---

## Minimum PoC Examples
```bash
# Jinja2 SSTI
curl "http://localhost:5000/hello?name={{7*7}}"

# DEBUG mode check
curl -I http://localhost:5000/ | grep "Werkzeug"

# SSRF
curl "http://localhost:5000/fetch?url=http://169.254.169.254/latest/meta-data/"
```

---
**Last Updated**: 2026-01-23
**Version**: 1.0.0
