From f631f61bade1b6e231a2e422b43ab2afc8e48e52 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Sat, 27 Dec 2025 17:50:30 +0100 Subject: [PATCH] Security hardening --- .gitignore | 13 +- __pycache__/app.cpython-310.pyc | Bin 12684 -> 0 bytes __pycache__/config.cpython-310.pyc | Bin 1061 -> 0 bytes app.py | 74 ++++++- config.py | 11 + prompts/003-security-csrf-rate-limiting.md | 243 +++++++++++++++++++++ requirements.txt | 1 + templates/page1_email.html | 1 + templates/page2_personal.html | 1 + templates/page3_motivation.html | 1 + templates/page4_upload.html | 3 + tests/conftest.py | 1 + 12 files changed, 347 insertions(+), 2 deletions(-) delete mode 100644 __pycache__/app.cpython-310.pyc delete mode 100644 __pycache__/config.cpython-310.pyc create mode 100644 prompts/003-security-csrf-rate-limiting.md diff --git a/.gitignore b/.gitignore index 77242b1..2721ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ .env applications/ .coverage -__pycache__ \ No newline at end of file + +# Python cache files +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ \ No newline at end of file diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc deleted file mode 100644 index b41725ed819adfe606750a4ae5fbc4aeef168703..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12684 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 U=F3^lH!!Hu1Tc|>Yud8@2i2w*TL1t6 diff --git a/app.py b/app.py index 4c32b07..4a7966f 100644 --- a/app.py +++ b/app.py @@ -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//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//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//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//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') diff --git a/config.py b/config.py index e7c91d8..4727547 100644 --- a/config.py +++ b/config.py @@ -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 diff --git a/prompts/003-security-csrf-rate-limiting.md b/prompts/003-security-csrf-rate-limiting.md new file mode 100644 index 0000000..6e98fb3 --- /dev/null +++ b/prompts/003-security-csrf-rate-limiting.md @@ -0,0 +1,243 @@ + +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. + + + +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 + + + + + +**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 + + + +**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//submit-personal + - /apply//submit-motivation + - /apply//upload-file + - /apply//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) + + + +- 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) + + + + + + +**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 `
` tag: + ```html + + ``` + +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 + + + +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. + + + +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 + + + +- 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 + diff --git a/requirements.txt b/requirements.txt index 809c928..52dff13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/templates/page1_email.html b/templates/page1_email.html index 15b304a..cb72a5e 100644 --- a/templates/page1_email.html +++ b/templates/page1_email.html @@ -12,6 +12,7 @@ +
diff --git a/templates/page2_personal.html b/templates/page2_personal.html index feb3186..b283a77 100644 --- a/templates/page2_personal.html +++ b/templates/page2_personal.html @@ -11,6 +11,7 @@
+

Persönliche Informationen

diff --git a/templates/page3_motivation.html b/templates/page3_motivation.html index 9d1635a..4758653 100644 --- a/templates/page3_motivation.html +++ b/templates/page3_motivation.html @@ -12,6 +12,7 @@
+
diff --git a/templates/page4_upload.html b/templates/page4_upload.html index 1664f0b..4b50026 100644 --- a/templates/page4_upload.html +++ b/templates/page4_upload.html @@ -24,6 +24,7 @@ {{ file.original_name }} ({{ "%.2f"|format(file.size / 1024 / 1024) }} MB) + @@ -36,6 +37,7 @@

Dokument hochladen

+
+
diff --git a/tests/conftest.py b/tests/conftest.py index 2ed1e1d..3cd8ad7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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