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 b41725e..0000000 Binary files a/__pycache__/app.cpython-310.pyc and /dev/null differ diff --git a/__pycache__/config.cpython-310.pyc b/__pycache__/config.cpython-310.pyc deleted file mode 100644 index b17c63a..0000000 Binary files a/__pycache__/config.cpython-310.pyc and /dev/null differ 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