Security hardening
This commit is contained in:
+12
-1
@@ -1,4 +1,15 @@
|
||||
.env
|
||||
applications/
|
||||
.coverage
|
||||
__pycache__
|
||||
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,21 +2,63 @@ import os
|
||||
import uuid
|
||||
import yaml
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, session
|
||||
from flask_mail import Mail, Message
|
||||
from flask_wtf.csrf import CSRFProtect, CSRFError
|
||||
from werkzeug.utils import secure_filename
|
||||
from config import Config
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
mail = Mail(app)
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
# Ensure applications folder exists
|
||||
Path(app.config['APPLICATIONS_FOLDER']).mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# Rate limiting helper
|
||||
def check_rate_limit():
|
||||
"""
|
||||
Check if user is submitting forms too quickly.
|
||||
Returns (allowed: bool, wait_seconds: int)
|
||||
"""
|
||||
now = datetime.now()
|
||||
last_submit_str = session.get('last_submission_time')
|
||||
|
||||
if last_submit_str:
|
||||
try:
|
||||
last_submit = datetime.fromisoformat(last_submit_str)
|
||||
elapsed = (now - last_submit).total_seconds()
|
||||
|
||||
if elapsed < app.config['RATE_LIMIT_SECONDS']:
|
||||
wait_seconds = int(app.config['RATE_LIMIT_SECONDS'] - elapsed) + 1
|
||||
return False, wait_seconds
|
||||
except (ValueError, TypeError):
|
||||
# Invalid timestamp, allow submission
|
||||
pass
|
||||
|
||||
# Update last submission time
|
||||
session['last_submission_time'] = now.isoformat()
|
||||
return True, 0
|
||||
|
||||
|
||||
# Error handlers
|
||||
@app.errorhandler(CSRFError)
|
||||
def handle_csrf_error(e):
|
||||
"""Handle CSRF validation errors."""
|
||||
flash('Sicherheitsfehler: Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', 'error')
|
||||
return redirect(url_for('page1_email')), 400
|
||||
|
||||
|
||||
@app.errorhandler(429)
|
||||
def handle_rate_limit_error(e):
|
||||
"""Handle rate limit errors."""
|
||||
return str(e), 429
|
||||
|
||||
|
||||
def get_application_path(session_id):
|
||||
"""Get the path to an application folder"""
|
||||
return os.path.join(app.config['APPLICATIONS_FOLDER'], session_id)
|
||||
@@ -137,6 +179,12 @@ def page1_email():
|
||||
@app.route('/apply/submit-email', methods=['POST'])
|
||||
def submit_email():
|
||||
"""Process email submission and create session"""
|
||||
# Check rate limit
|
||||
allowed, wait_seconds = check_rate_limit()
|
||||
if not allowed:
|
||||
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
|
||||
return redirect(url_for('page1_email')), 429
|
||||
|
||||
email = request.form.get('email', '').strip()
|
||||
job_name = request.form.get('job_name', 'Offene Position')
|
||||
|
||||
@@ -193,6 +241,12 @@ def page2_personal(session_id):
|
||||
@app.route('/apply/<session_id>/submit-personal', methods=['POST'])
|
||||
def submit_personal(session_id):
|
||||
"""Process personal information submission"""
|
||||
# Check rate limit
|
||||
allowed, wait_seconds = check_rate_limit()
|
||||
if not allowed:
|
||||
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
|
||||
return redirect(url_for('page2_personal', session_id=session_id)), 429
|
||||
|
||||
app_data = load_application_data(session_id)
|
||||
if not app_data:
|
||||
flash('Bewerbung nicht gefunden.', 'error')
|
||||
@@ -276,6 +330,12 @@ def page3_motivation(session_id):
|
||||
@app.route('/apply/<session_id>/submit-motivation', methods=['POST'])
|
||||
def submit_motivation(session_id):
|
||||
"""Process motivation questions submission"""
|
||||
# Check rate limit
|
||||
allowed, wait_seconds = check_rate_limit()
|
||||
if not allowed:
|
||||
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
|
||||
return redirect(url_for('page3_motivation', session_id=session_id)), 429
|
||||
|
||||
app_data = load_application_data(session_id)
|
||||
if not app_data:
|
||||
flash('Bewerbung nicht gefunden.', 'error')
|
||||
@@ -340,6 +400,12 @@ def page4_upload(session_id):
|
||||
@app.route('/apply/<session_id>/upload-file', methods=['POST'])
|
||||
def upload_file(session_id):
|
||||
"""Handle file upload"""
|
||||
# Check rate limit
|
||||
allowed, wait_seconds = check_rate_limit()
|
||||
if not allowed:
|
||||
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
|
||||
return redirect(url_for('page4_upload', session_id=session_id)), 429
|
||||
|
||||
app_data = load_application_data(session_id)
|
||||
if not app_data:
|
||||
flash('Bewerbung nicht gefunden.', 'error')
|
||||
@@ -437,6 +503,12 @@ def remove_file(session_id, file_index):
|
||||
@app.route('/apply/<session_id>/submit-application', methods=['POST'])
|
||||
def submit_application(session_id):
|
||||
"""Submit final application"""
|
||||
# Check rate limit
|
||||
allowed, wait_seconds = check_rate_limit()
|
||||
if not allowed:
|
||||
flash(f'Bitte warten Sie noch {wait_seconds} Sekunden vor der nächsten Eingabe.', 'error')
|
||||
return redirect(url_for('page4_upload', session_id=session_id)), 429
|
||||
|
||||
app_data = load_application_data(session_id)
|
||||
if not app_data:
|
||||
flash('Bewerbung nicht gefunden.', 'error')
|
||||
|
||||
@@ -35,3 +35,14 @@ class Config:
|
||||
# Birth year validation
|
||||
MIN_BIRTH_YEAR = 1940
|
||||
MAX_BIRTH_YEAR = 2010
|
||||
|
||||
# Security settings
|
||||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_TIME_LIMIT = None # CSRF tokens don't expire (user can take time filling forms)
|
||||
WTF_CSRF_SSL_STRICT = False # Set to True in production with HTTPS
|
||||
SESSION_COOKIE_SECURE = False # Set to True in production (HTTPS only)
|
||||
SESSION_COOKIE_HTTPONLY = True # Prevent JavaScript access to session cookie
|
||||
SESSION_COOKIE_SAMESITE = 'Lax' # CSRF protection
|
||||
|
||||
# Rate limiting settings
|
||||
RATE_LIMIT_SECONDS = 5 # Minimum seconds between form submissions
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<objective>
|
||||
Implement comprehensive security hardening for the Flask job application system by adding CSRF (Cross-Site Request Forgery) protection to all forms and implementing rate limiting to prevent abuse of form submissions.
|
||||
|
||||
This security enhancement protects against CSRF attacks where malicious sites trick users into submitting forms, and prevents automated abuse or DoS attacks through aggressive form submission. The rate limiting ensures a minimum 5-second delay between form submissions to prevent spam and abuse.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
The Flask job application system currently has 4 forms across the workflow:
|
||||
- Page 1: Email submission form
|
||||
- Page 2: Personal information form
|
||||
- Page 3: Motivation questions form
|
||||
- Page 4: File upload and final submission forms
|
||||
|
||||
Tech stack: Flask 3.0.0, Python 3, existing test suite with pytest
|
||||
|
||||
Current security gaps:
|
||||
- No CSRF protection on forms (vulnerable to CSRF attacks)
|
||||
- No rate limiting (vulnerable to automated submission abuse)
|
||||
- Forms can be submitted repeatedly without delay
|
||||
|
||||
Examine these files to understand the current implementation:
|
||||
@app.py - All routes and form handlers
|
||||
@templates/*.html - All form templates
|
||||
@config.py - Configuration settings
|
||||
@tests/test_routes.py - Existing route tests that will need updating
|
||||
</context>
|
||||
|
||||
<requirements>
|
||||
|
||||
<csrf_protection>
|
||||
**CSRF Token Implementation**
|
||||
|
||||
1. **Install and Configure Flask-WTF**:
|
||||
- Add Flask-WTF to requirements.txt (provides CSRF protection)
|
||||
- Configure CSRF protection in app.py
|
||||
- Set secure CSRF configuration (token timeout, secure cookies)
|
||||
|
||||
2. **Add CSRF Tokens to All Forms**:
|
||||
- Update all HTML templates to include CSRF tokens
|
||||
- Add `{{ csrf_token() }}` hidden input to every form:
|
||||
- templates/page1_email.html
|
||||
- templates/page2_personal.html
|
||||
- templates/page3_motivation.html
|
||||
- templates/page4_upload.html (both upload form and submit form)
|
||||
|
||||
3. **Validate CSRF Tokens**:
|
||||
- All POST routes must automatically validate CSRF tokens
|
||||
- Return 400 Bad Request with clear error message on CSRF failure
|
||||
- Ensure CSRF validation doesn't break the resume functionality
|
||||
|
||||
4. **CSRF Error Handling**:
|
||||
- Add custom error handler for CSRF validation failures
|
||||
- Display user-friendly German error messages
|
||||
- Allow users to refresh and retry after CSRF errors
|
||||
</csrf_protection>
|
||||
|
||||
<rate_limiting>
|
||||
**Rate Limiting Implementation**
|
||||
|
||||
1. **Session-Based Rate Limiting**:
|
||||
- Track last form submission timestamp in Flask session
|
||||
- Enforce minimum 5-second delay between ANY form submissions
|
||||
- Apply to all form POST routes:
|
||||
- /apply/submit-email
|
||||
- /apply/<session_id>/submit-personal
|
||||
- /apply/<session_id>/submit-motivation
|
||||
- /apply/<session_id>/upload-file
|
||||
- /apply/<session_id>/submit-application
|
||||
|
||||
2. **Rate Limiting Logic**:
|
||||
- Before processing any form submission, check session['last_submission_time']
|
||||
- Calculate time elapsed since last submission
|
||||
- If < 5 seconds, reject with HTTP 429 (Too Many Requests)
|
||||
- If >= 5 seconds or first submission, allow and update timestamp
|
||||
- Store timestamp in session for persistence across requests
|
||||
|
||||
3. **User-Friendly Rate Limit Messages**:
|
||||
- Display remaining wait time in German
|
||||
- Example: "Bitte warten Sie noch 3 Sekunden vor der nächsten Eingabe."
|
||||
- Use flash messages to communicate rate limit errors
|
||||
- Ensure message is clear and helps user understand the delay
|
||||
|
||||
4. **Rate Limit Exemptions**:
|
||||
- GET requests are not rate limited (viewing pages)
|
||||
- Resume links (loading existing applications) are not rate limited
|
||||
- Only POST form submissions are rate limited
|
||||
|
||||
5. **Configuration**:
|
||||
- Add RATE_LIMIT_SECONDS = 5 to config.py
|
||||
- Make it configurable for different environments (dev, test, prod)
|
||||
</rate_limiting>
|
||||
|
||||
<security_best_practices>
|
||||
- Use secure session cookies (HttpOnly, Secure flags)
|
||||
- Set proper SameSite attribute for cookies (Lax or Strict)
|
||||
- Ensure CSRF tokens are cryptographically secure
|
||||
- Rate limiting is per-session (prevents abuse from single source)
|
||||
- Clear, informative error messages in German
|
||||
- Maintain accessibility (forms remain usable with screen readers)
|
||||
</security_best_practices>
|
||||
|
||||
</requirements>
|
||||
|
||||
<implementation>
|
||||
|
||||
**Implementation Steps**
|
||||
|
||||
1. **Update Dependencies**:
|
||||
- Add Flask-WTF==1.2.1 to requirements.txt
|
||||
- Flask-WTF provides CSRFProtect extension
|
||||
|
||||
2. **Configure CSRF Protection in app.py**:
|
||||
```python
|
||||
from flask_wtf.csrf import CSRFProtect, CSRFError
|
||||
|
||||
csrf = CSRFProtect(app)
|
||||
app.config['WTF_CSRF_TIME_LIMIT'] = None # Or set to reasonable limit
|
||||
app.config['WTF_CSRF_SSL_STRICT'] = True
|
||||
```
|
||||
|
||||
3. **Add CSRF Tokens to Templates**:
|
||||
- Add after opening `<form>` tag:
|
||||
```html
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
```
|
||||
|
||||
4. **Implement Rate Limiting Decorator/Function**:
|
||||
Create a helper function to check rate limits:
|
||||
```python
|
||||
def check_rate_limit():
|
||||
"""Check if user is submitting forms too quickly."""
|
||||
from datetime import datetime
|
||||
from flask import session
|
||||
|
||||
now = datetime.now()
|
||||
last_submit = session.get('last_submission_time')
|
||||
|
||||
if last_submit:
|
||||
# Parse stored timestamp and check elapsed time
|
||||
# If < 5 seconds, return False and remaining wait time
|
||||
# Else return True
|
||||
|
||||
session['last_submission_time'] = now.isoformat()
|
||||
return True
|
||||
```
|
||||
|
||||
5. **Update All POST Routes**:
|
||||
- Add rate limit check at the beginning of each POST handler
|
||||
- Return 429 error with flash message if rate limited
|
||||
- CSRF validation happens automatically via Flask-WTF
|
||||
|
||||
6. **Add Error Handlers**:
|
||||
```python
|
||||
@app.errorhandler(CSRFError)
|
||||
def handle_csrf_error(e):
|
||||
flash('Sicherheitsfehler: Bitte laden Sie die Seite neu und versuchen Sie es erneut.', 'error')
|
||||
return redirect(url_for('page1_email'))
|
||||
```
|
||||
|
||||
7. **Update Configuration**:
|
||||
- Add rate limiting settings to config.py
|
||||
- Ensure session configuration is secure
|
||||
|
||||
**What to Avoid and Why**:
|
||||
- Don't disable CSRF for any POST routes - all forms need protection to prevent CSRF attacks
|
||||
- Don't use IP-based rate limiting alone - users behind NAT/proxies share IPs; session-based is more accurate per-user
|
||||
- Don't make rate limit too aggressive (< 5 seconds) - disrupts legitimate users trying to correct mistakes
|
||||
- Don't forget to update tests - CSRF protection will break existing tests that don't include tokens
|
||||
- Don't store sensitive data in rate limit error messages - only show wait time, not internal state
|
||||
</implementation>
|
||||
|
||||
<output>
|
||||
Modify the following files:
|
||||
|
||||
1. `./requirements.txt` - Add Flask-WTF dependency
|
||||
2. `./app.py` - Add CSRF protection, rate limiting logic, error handlers
|
||||
3. `./config.py` - Add rate limiting and CSRF configuration
|
||||
4. `./templates/page1_email.html` - Add CSRF token
|
||||
5. `./templates/page2_personal.html` - Add CSRF token
|
||||
6. `./templates/page3_motivation.html` - Add CSRF token
|
||||
7. `./templates/page4_upload.html` - Add CSRF tokens (2 forms)
|
||||
8. `./tests/test_routes.py` - Update tests to include CSRF tokens
|
||||
9. `./tests/conftest.py` - Update test configuration to handle CSRF in tests
|
||||
|
||||
All modifications should maintain existing functionality while adding security layers.
|
||||
</output>
|
||||
|
||||
<verification>
|
||||
Before declaring complete, verify your implementation:
|
||||
|
||||
1. **Test CSRF Protection**:
|
||||
```bash
|
||||
# Try submitting a form without CSRF token - should fail
|
||||
curl -X POST http://localhost:5000/apply/submit-email -d "email=test@example.com"
|
||||
```
|
||||
Expected: 400 Bad Request or CSRF error
|
||||
|
||||
2. **Test Rate Limiting**:
|
||||
- Submit a form successfully
|
||||
- Immediately try to submit another form
|
||||
- Should see rate limit error with wait time
|
||||
- Wait 5 seconds and submit again - should succeed
|
||||
|
||||
3. **Run Test Suite**:
|
||||
```bash
|
||||
pytest -v tests/test_routes.py
|
||||
```
|
||||
All tests should pass with CSRF tokens included
|
||||
|
||||
4. **Manual Testing**:
|
||||
- Start the application
|
||||
- Complete a full application workflow
|
||||
- Verify CSRF tokens are present in all forms (view page source)
|
||||
- Try rapid form submission - should see rate limit message
|
||||
- Verify error messages are in German
|
||||
|
||||
5. **Security Verification**:
|
||||
- Check that cookies have Secure and HttpOnly flags
|
||||
- Verify CSRF tokens are different for each request
|
||||
- Confirm rate limiting persists across different forms
|
||||
|
||||
6. **Accessibility Check**:
|
||||
- Forms still work with keyboard navigation
|
||||
- Screen readers can still use forms
|
||||
- Error messages are announced properly
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Flask-WTF installed and configured correctly
|
||||
- CSRF tokens present in all 5 forms (view page source)
|
||||
- All POST routes validate CSRF tokens automatically
|
||||
- CSRF error handler displays German error message
|
||||
- Rate limiting active on all form submissions
|
||||
- Minimum 5-second delay enforced between submissions
|
||||
- Rate limit error shows remaining wait time in German
|
||||
- Session-based rate limiting works correctly
|
||||
- All existing tests updated and passing
|
||||
- Manual workflow test completes successfully
|
||||
- Security best practices implemented (secure cookies, etc.)
|
||||
- Error handling is user-friendly and in German
|
||||
- No functionality broken by security additions
|
||||
- Application remains accessible and usable
|
||||
</success_criteria>
|
||||
@@ -1,6 +1,7 @@
|
||||
Flask==3.0.0
|
||||
PyYAML==6.0.1
|
||||
Flask-Mail==0.9.1
|
||||
Flask-WTF==1.2.1
|
||||
python-dotenv==1.0.0
|
||||
Werkzeug==3.0.1
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_email') }}" class="application-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<input type="hidden" name="job_name" value="{{ job_name }}">
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_personal', session_id=session_id) }}" class="application-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Persönliche Informationen</h3>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_motivation', session_id=session_id) }}" class="application-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="current_job">Bitte beschreiben Sie Ihre aktuelle berufliche Situation.</label>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<span class="file-name">{{ file.original_name }}</span>
|
||||
<span class="file-size">({{ "%.2f"|format(file.size / 1024 / 1024) }} MB)</span>
|
||||
<form method="POST" action="{{ url_for('remove_file', session_id=session_id, file_index=loop.index0) }}" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button type="submit" class="btn btn-danger btn-small">Entfernen</button>
|
||||
</form>
|
||||
</li>
|
||||
@@ -36,6 +37,7 @@
|
||||
<div class="upload-section">
|
||||
<h3>Dokument hochladen</h3>
|
||||
<form method="POST" action="{{ url_for('upload_file', session_id=session_id) }}" enctype="multipart/form-data" class="upload-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-group">
|
||||
<label for="file">Datei auswählen</label>
|
||||
<input
|
||||
@@ -64,6 +66,7 @@
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('submit_application', session_id=session_id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary btn-large">Bewerbung absenden</button>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ def app():
|
||||
flask_app.config['APPLICATIONS_FOLDER'] = temp_dir
|
||||
flask_app.config['WTF_CSRF_ENABLED'] = False # Disable CSRF for testing
|
||||
flask_app.config['MAIL_SUPPRESS_SEND'] = True # Don't actually send emails
|
||||
flask_app.config['RATE_LIMIT_SECONDS'] = 0 # Disable rate limiting for testing
|
||||
|
||||
yield flask_app
|
||||
|
||||
|
||||
Reference in New Issue
Block a user