# Security TODO List - EPOLaw Platform

**Audit Date**: October 11, 2025
**Files Audited**: `auth_routes.py`, `case_routes.py`
**Total Issues**: 18 (5 Critical, 7 High, 3 Medium, 3 Low)

**Status Legend**:
- ⬜ Not Started
- 🔄 In Progress
- ✅ Completed
- ⏭️ Deferred

---

## 🔴 CRITICAL PRIORITY (Fix Immediately)

### 1. Missing Rate Limiting on Authentication Routes ⬜
**Severity**: CRITICAL
**Files**: `auth_routes.py:84`, `auth_routes.py:271`
**Vulnerability**: Brute Force Attack, Account Enumeration, DoS

**Description**: Login and password reset routes have no rate limiting despite configuration existing.

**Impact**:
- Attackers can perform unlimited brute force password attempts
- Account enumeration via password reset endpoint
- Denial of service through repeated requests

**Implementation**:
```python
# In auth_routes.py, add these imports at the top
from app import limiter
from config.security_config import LOGIN_RATE_LIMIT

# Update the login route
@auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit(LOGIN_RATE_LIMIT)  # Add this line
def login():
    # ... existing code

# Update password reset route
@auth_bp.route('/reset-password-request', methods=['GET', 'POST'])
@limiter.limit("5 per hour")  # Add this line
def reset_password_request():
    # ... existing code

# Also add to registration if publicly accessible
@auth_bp.route('/register', methods=['GET', 'POST'])
@limiter.limit("10 per hour")  # Add this line
def register():
    # ... existing code
```

**Testing**:
```bash
# Test rate limiting works
for i in {1..15}; do curl -X POST http://localhost:5000/login -d "email=test@test.com&password=wrong"; done
# Should see rate limit error after 10 attempts
```

**Estimated Time**: 15 minutes
**Dependencies**: None

---

### 2. Open Redirect Vulnerability ⬜
**Severity**: CRITICAL
**Files**: `auth_routes.py:124-126`
**Vulnerability**: Open Redirect, Phishing

**Description**: The `next` parameter after login is not validated, allowing redirects to external sites.

**Impact**:
- Phishing attacks: `/login?next=https://evil.com/fake-dashboard`
- Users trust the legitimate login flow but land on attacker site

**Implementation**:
```python
# Add this helper function to auth_routes.py (after imports)
from urllib.parse import urlparse

def is_safe_redirect(url):
    """Check if URL is safe for redirect (relative or same host)"""
    if not url:
        return False

    # Only allow relative URLs
    parsed = urlparse(url)

    # Reject absolute URLs (with scheme or netloc)
    if parsed.scheme or parsed.netloc:
        return False

    # Must start with / and not //
    if not url.startswith('/') or url.startswith('//'):
        return False

    return True

# Update login route (around line 124)
# OLD CODE:
# next_url = request.args.get('next')
# if next_url:
#     return redirect(next_url)

# NEW CODE:
next_url = request.args.get('next')
if next_url and is_safe_redirect(next_url):
    return redirect(next_url)
elif user.is_admin():
    return redirect(url_for('admin.dashboard'))
else:
    return redirect(url_for('dashboard'))
```

**Testing**:
```bash
# Test malicious redirects are blocked
curl -I "http://localhost:5000/login?next=https://evil.com"
curl -I "http://localhost:5000/login?next=//evil.com"
curl -I "http://localhost:5000/login?next=javascript:alert(1)"

# Test valid redirects work
curl -I "http://localhost:5000/login?next=/dashboard"
curl -I "http://localhost:5000/login?next=/cases/list"
```

**Estimated Time**: 20 minutes
**Dependencies**: None

---

### 3. Path Traversal in File Downloads ⬜
**Severity**: CRITICAL
**Files**: `case_routes.py:1030`, `case_routes.py:1084`
**Vulnerability**: Arbitrary File Read

**Description**: File paths from database are used directly without validating they're within the upload directory.

**Impact**:
- If attacker compromises database or finds SQLi, they can read:
  - `/etc/passwd`
  - `/var/www/lawbot/config/database_config.py` (credentials)
  - Other users' documents
  - Application source code

**Implementation**:
```python
# Add this helper function to case_routes.py (after imports)
def validate_file_path(file_path):
    """Ensure file path is within upload directory (prevent path traversal)"""
    if not file_path:
        return False

    upload_dir = os.path.abspath(current_app.config['UPLOAD_FOLDER'])
    requested_path = os.path.abspath(file_path)

    # Check if path starts with upload directory
    if not requested_path.startswith(upload_dir):
        current_app.logger.warning(f"Path traversal attempt: {file_path}")
        return False

    return True

# Update view_document route (around line 1030)
# Add after line 1029 (before checking if file exists):
if not validate_file_path(document.file_path):
    current_app.logger.error(f"Invalid file path for document {document_id}: {document.file_path}")
    abort(403)

# Check if file exists
if not document.file_path or not os.path.exists(document.file_path):
    flash('Document file not found', 'danger')
    return redirect(url_for('cases.view_documents', case_id=case_id))

# Update download_document route (around line 1084)
# Add after line 1082 (before checking if file exists):
if not validate_file_path(document.file_path):
    current_app.logger.error(f"Invalid file path for document {document_id}: {document.file_path}")
    abort(403)

# Check if file exists
if not document.file_path or not os.path.exists(document.file_path):
    flash('Document file not found', 'danger')
    return redirect(url_for('cases.view_documents', case_id=case_id))

# Also update download_service_proof route (around line 580)
# Add after line 578:
if service.proof_of_service_path:
    if not validate_file_path(service.proof_of_service_path):
        abort(403)
```

**Testing**:
```python
# Create a test script: test_path_traversal.py
from case_routes import validate_file_path
from app import app

with app.app_context():
    # Should fail
    assert not validate_file_path('/etc/passwd')
    assert not validate_file_path('../../../etc/passwd')
    assert not validate_file_path('/home/user/file.txt')

    # Should pass
    assert validate_file_path('/var/www/lawbot/uploads/test.pdf')

    print("✅ All path traversal tests passed")
```

**Estimated Time**: 30 minutes
**Dependencies**: None

---

### 4. Missing File Size Validation ⬜
**Severity**: CRITICAL
**Files**: `case_routes.py:283-408`
**Vulnerability**: Denial of Service

**Description**: No file size enforcement in upload handler, despite documentation claiming 100MB limit.

**Impact**:
- Disk space exhaustion
- Memory exhaustion during processing
- Network bandwidth abuse
- Application crashes

**Implementation**:
```python
# Add to config/app_config.py or at top of app.py
MAX_FILE_SIZE = 100 * 1024 * 1024  # 100MB in bytes
MAX_FILE_SIZE_MB = 100  # For display in error messages

# Update Flask config in app.py (around line 40-60)
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE  # Flask built-in protection

# Update add_document route in case_routes.py (around line 303)
# Add after getting the file:
file = request.files['file']
if file.filename == '':
    return jsonify({'error': 'No file selected'}), 400

# NEW CODE - Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)  # Reset to beginning

if file_size > MAX_FILE_SIZE:
    return jsonify({
        'error': f'File too large. Maximum size is {MAX_FILE_SIZE_MB}MB. Your file is {file_size / (1024*1024):.1f}MB'
    }), 400

if file_size == 0:
    return jsonify({'error': 'File is empty'}), 400

# Validate file type
allowed_extensions = {'pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png', 'tiff', 'tif', 'bmp'}
# ... rest of existing code

# Also update add_service route (around line 490) for proof of service uploads
# Add same file size check after line 491
```

**Also Update**: `law_library_routes.py` if it has document upload functionality.

**Testing**:
```bash
# Create a large test file
dd if=/dev/zero of=/tmp/large_file.pdf bs=1M count=101

# Try to upload (should fail)
curl -X POST http://localhost:5000/cases/1/add_document \
  -F "file=@/tmp/large_file.pdf" \
  -F "document_type=other" \
  -H "Cookie: session=..."

# Should return 400 with size error
```

**Estimated Time**: 25 minutes
**Dependencies**: Update `MAX_FILE_SIZE` in config

---

### 5. Information Disclosure in Password Reset ⬜
**Severity**: CRITICAL
**Files**: `auth_routes.py:308-314`
**Vulnerability**: Information Disclosure, Account Takeover

**Description**: Password reset URLs are exposed in flash messages and console logs when email fails.

**Impact**:
- Anyone viewing the page can see reset token
- Tokens logged in systemd journal (publicly readable)
- Account takeover if attacker accesses logs

**Implementation**:
```python
# Update reset_password_request route (around line 301)
# OLD CODE:
# try:
#     from email_config import send_password_reset_email
#     if send_password_reset_email(user.email, reset_url):
#         flash('If your email is registered, you will receive a password reset link', 'info')
#     else:
#         # Email failed, show the link in flash message for testing
#         flash(f'Email sending failed. Reset link: {reset_url}', 'warning')
#         print(f"Password reset URL for {user.email}: {reset_url}")
# except Exception as e:
#     # Fallback: show the link
#     flash(f'Email system error. Reset link: {reset_url}', 'warning')
#     print(f"Password reset URL for {user.email}: {reset_url}")
#     print(f"Email error: {str(e)}")

# NEW CODE:
try:
    from email_config import send_password_reset_email
    email_sent = send_password_reset_email(user.email, reset_url)

    if not email_sent:
        # Log error securely (only user ID, not token)
        current_app.logger.error(f"Password reset email failed for user {user.id}")

        # For development/testing: output to console only if in DEBUG mode
        if current_app.debug:
            print(f"DEV MODE - Password reset URL: {reset_url}")

except Exception as e:
    # Log error securely
    current_app.logger.error(f"Password reset email error for user {user.id}: {str(e)}")

    # For development/testing only
    if current_app.debug:
        print(f"DEV MODE - Password reset URL: {reset_url}")

# Always show the same message (don't reveal email sending status)
flash('If your email is registered, you will receive a password reset link', 'info')

# Log the activity
log_activity(user.id, 'password_reset_request', f"Password reset requested for: {user.email}")

return redirect(url_for('auth.login'))
```

**Additional Security**: Set up proper email monitoring
```python
# Add email health check endpoint (optional)
@admin_bp.route('/email-health-check')
@admin_required
def email_health_check():
    """Admin-only endpoint to verify email is working"""
    try:
        from email_config import send_test_email
        test_user = User.query.get(session['user_id'])
        send_test_email(test_user.email)
        flash('Test email sent. Check your inbox.', 'info')
    except Exception as e:
        flash(f'Email system error: {str(e)}', 'danger')
    return redirect(url_for('admin.settings'))
```

**Testing**:
```bash
# Verify tokens are not in logs
sudo journalctl -u epolaw | grep -i "reset"
# Should not see any tokens

# Verify production mode doesn't print tokens
tail -f /var/log/epolaw/error.log
# Request password reset and verify no tokens appear
```

**Estimated Time**: 20 minutes
**Dependencies**: Ensure email monitoring is set up

---

## 🟠 HIGH PRIORITY (Fix Within 1 Week)

### 6. No Session Regeneration After Login ⬜
**Severity**: HIGH
**Files**: `auth_routes.py:116-118`
**Vulnerability**: Session Fixation Attack

**Description**: Session ID is not regenerated after successful login, allowing session fixation attacks.

**Implementation**:
```python
# Update login route (around line 116)
# OLD CODE:
# session['user_id'] = user.id
# session['session_token'] = user_session.session_token

# NEW CODE:
# Clear old session and regenerate session ID (Flask does this automatically)
session.clear()

# Set permanent flag before storing data
session.permanent = remember

# Store user info in new session
session['user_id'] = user.id
session['session_token'] = user_session.session_token

# Log the activity
log_activity(user.id, 'login', f"User logged in: {user.email}")
```

**Estimated Time**: 10 minutes

---

### 7. No Password Strength Validation ⬜
**Severity**: HIGH
**Files**: `auth_routes.py:169-207`, `auth_routes.py:221-234`, `auth_routes.py:337-347`
**Vulnerability**: Weak Password Acceptance

**Description**: Password requirements defined in config but not enforced in registration/password reset.

**Implementation**:
```python
# Add to auth_routes.py (after imports)
from config.security_config import (
    MIN_PASSWORD_LENGTH,
    REQUIRE_UPPERCASE,
    REQUIRE_LOWERCASE,
    REQUIRE_NUMBERS,
    REQUIRE_SPECIAL_CHARS
)
import re

def validate_password_strength(password):
    """Validate password meets security requirements"""
    if len(password) < MIN_PASSWORD_LENGTH:
        return False, f'Password must be at least {MIN_PASSWORD_LENGTH} characters'

    if REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
        return False, 'Password must contain at least one uppercase letter'

    if REQUIRE_LOWERCASE and not re.search(r'[a-z]', password):
        return False, 'Password must contain at least one lowercase letter'

    if REQUIRE_NUMBERS and not re.search(r'\d', password):
        return False, 'Password must contain at least one number'

    if REQUIRE_SPECIAL_CHARS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
        return False, 'Password must contain at least one special character'

    return True, 'Password is valid'

# Update register route (around line 169)
# Add after line 183 (after checking passwords match):
if password != confirm_password:
    flash('Passwords do not match', 'danger')
    return render_template('auth/register.html')

# NEW CODE:
valid, message = validate_password_strength(password)
if not valid:
    flash(message, 'danger')
    return render_template('auth/register.html')

# Check if email already exists (no more username check)
# ... rest of code

# Update accept_invitation route (around line 232)
# Add after line 234 (after checking passwords match):
if password != confirm_password:
    flash('Passwords do not match', 'danger')
    return render_template('auth/accept_invitation.html', invitation=invitation)

# NEW CODE:
valid, message = validate_password_strength(password)
if not valid:
    flash(message, 'danger')
    return render_template('auth/accept_invitation.html', invitation=invitation)

# Use email as username - check if user already exists by email
# ... rest of code

# Update reset_password route (around line 345)
# Add after line 347 (after checking passwords match):
if password != confirm_password:
    flash('Passwords do not match', 'danger')
    return render_template('auth/reset_password.html', token=token)

# NEW CODE:
valid, message = validate_password_strength(password)
if not valid:
    flash(message, 'danger')
    return render_template('auth/reset_password.html', token=token)

# Update the user's password
# ... rest of code
```

**Update Templates**: Add password requirements to UI
```html
<!-- Add to templates/auth/register.html and similar -->
<div class="form-text text-muted">
    Password requirements:
    <ul>
        <li>At least {{ config.MIN_PASSWORD_LENGTH or 8 }} characters</li>
        <li>At least one uppercase letter</li>
        <li>At least one lowercase letter</li>
        <li>At least one number</li>
    </ul>
</div>
```

**Estimated Time**: 40 minutes

---

### 8. No Email Format Validation ⬜
**Severity**: HIGH
**Files**: `auth_routes.py:87`, `auth_routes.py:170`, `auth_routes.py:274`
**Vulnerability**: Data Integrity, Potential Injection

**Description**: Email addresses accepted without format validation.

**Implementation**:
```python
# Add to auth_routes.py (after imports)
import re

def is_valid_email(email):
    """Validate email format"""
    if not email or len(email) > 254:
        return False

    # RFC 5322 compliant regex (simplified)
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'

    if not re.match(pattern, email):
        return False

    # Additional checks
    if '..' in email:  # Consecutive dots
        return False

    if email.startswith('.') or email.endswith('.'):
        return False

    return True

# Update login route (around line 87)
email = request.form.get('email')
password = request.form.get('password')
remember = 'remember' in request.form

# NEW CODE:
if not is_valid_email(email):
    flash('Invalid email format', 'danger')
    return render_template('login.html')

user = User.query.filter_by(email=email).first()
# ... rest of code

# Update register route (around line 170)
email = request.form.get('email')
password = request.form.get('password')
# ...

# NEW CODE:
if not is_valid_email(email):
    flash('Invalid email format', 'danger')
    return render_template('auth/register.html')

# Validation - NO MORE USERNAME FIELD REQUIRED
# ... rest of code

# Update reset_password_request route (around line 274)
email = request.form.get('email')

# NEW CODE:
if not is_valid_email(email):
    # Still show generic message for security
    flash('If your email is registered, you will receive a password reset link', 'info')
    return redirect(url_for('auth.login'))

user = User.query.filter_by(email=email).first()
# ... rest of code
```

**Estimated Time**: 20 minutes

---

### 9. Missing File Content Validation ⬜
**Severity**: HIGH
**Files**: `case_routes.py:310-315`
**Vulnerability**: Malicious File Upload

**Description**: Only file extension is checked, not file content (magic bytes).

**Implementation**:
```bash
# First, install python-magic library
/var/www/lawbot/venv/bin/pip install python-magic
/var/www/lawbot/venv/bin/pip freeze > /var/www/lawbot/config/requirements.txt
```

```python
# Add to case_routes.py imports
import magic

# Add helper function after imports
def validate_file_content(file, declared_extension):
    """Validate file content matches declared extension using magic bytes"""

    # Allowed MIME types mapped to extensions
    allowed_mimes = {
        'pdf': ['application/pdf'],
        'doc': ['application/msword'],
        'docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
        'txt': ['text/plain', 'text/x-python', 'application/x-empty'],
        'jpg': ['image/jpeg'],
        'jpeg': ['image/jpeg'],
        'png': ['image/png'],
        'tiff': ['image/tiff'],
        'tif': ['image/tiff'],
        'bmp': ['image/bmp', 'image/x-ms-bmp']
    }

    if declared_extension not in allowed_mimes:
        return False, f"Extension {declared_extension} not allowed"

    try:
        # Read first 2KB for magic byte detection
        mime = magic.Magic(mime=True)
        file_mime = mime.from_buffer(file.read(2048))
        file.seek(0)  # Reset file pointer

        expected_mimes = allowed_mimes[declared_extension]

        if file_mime not in expected_mimes:
            return False, f"File content ({file_mime}) doesn't match extension (.{declared_extension})"

        return True, "Valid"

    except Exception as e:
        current_app.logger.error(f"File validation error: {e}")
        return False, "Unable to validate file content"

# Update add_document route (around line 314)
# Replace existing validation with:

# Validate file type
allowed_extensions = {'pdf', 'doc', 'docx', 'txt', 'jpg', 'jpeg', 'png', 'tiff', 'tif', 'bmp'}
file_extension = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else ''

if file_extension not in allowed_extensions:
    return jsonify({'error': f'File type .{file_extension} not allowed. Supported types: {", ".join(allowed_extensions)}'}), 400

# NEW CODE: Validate file content matches extension
is_valid, validation_message = validate_file_content(file, file_extension)
if not is_valid:
    current_app.logger.warning(f"File validation failed for upload: {validation_message}")
    return jsonify({'error': 'File content validation failed. File may be corrupted or has wrong extension.'}), 400

# Ensure upload directory exists
# ... rest of code continues
```

**Update Law Library** if it has uploads: Apply same validation to `law_library_routes.py`

**Testing**:
```bash
# Test with malicious file
cp /bin/bash /tmp/malware.pdf
curl -X POST http://localhost:5000/cases/1/add_document -F "file=@/tmp/malware.pdf"
# Should reject as ELF binary

# Test with valid file
curl -X POST http://localhost:5000/cases/1/add_document -F "file=@real_document.pdf"
# Should accept
```

**Estimated Time**: 45 minutes
**Dependencies**: Install python-magic library

---

### 10. Race Condition in Case Limit Check ⬜
**Severity**: HIGH
**Files**: `case_routes.py:182-196`
**Vulnerability**: TOCTOU (Time-of-Check-Time-of-Use)

**Description**: Case count check and creation not atomic, allowing limit bypass via concurrent requests.

**Implementation**:
```python
# Update new_case route (around line 182)
# OLD CODE:
# active_cases_count = Case.query.filter_by(
#     company_id=user.company_id,
#     status='active'
# ).count()
#
# max_cases = subscription.plan.features.get('max_cases')
#
# if max_cases and active_cases_count >= max_cases:
#     if subscription.plan.name == 'free':
#         message = f'Case limit reached...'
#     flash(message, 'warning')
#     return redirect(url_for('subscription.view_plans'))

# NEW CODE with transaction locking:
from sqlalchemy import func

try:
    # Start transaction with row locking
    # Lock all active cases for this company during the check
    active_cases_count = db.session.query(func.count(Case.id))\
        .filter_by(company_id=user.company_id, status='active')\
        .with_for_update()\
        .scalar()

    max_cases = subscription.plan.features.get('max_cases')

    if max_cases and active_cases_count >= max_cases:
        db.session.rollback()

        if subscription.plan.name == 'free':
            message = f'Case limit reached. Free accounts can have up to {max_cases} active cases. Please upgrade to Pro for more cases.'
        else:  # Pro plan
            message = f'Case limit reached. Pro accounts can have up to {max_cases} active cases. Please upgrade to Enterprise for unlimited cases or close/archive existing cases.'

        flash(message, 'warning')
        return redirect(url_for('subscription.view_plans'))

    # Create case (still within transaction)
    case = Case(
        case_number=request.form.get('case_number'),
        case_name=request.form.get('case_name'),
        # ... rest of fields
    )

    db.session.add(case)
    db.session.commit()

    # Log activity
    log_case_activity(
        case.id,
        user_id,
        'case_created',
        f'{user.username} created case {case.case_name}'
    )

    flash('Case created successfully', 'success')
    return redirect(url_for('cases.view_case', case_id=case.id))

except Exception as e:
    db.session.rollback()
    current_app.logger.error(f"Error creating case: {e}")
    flash('Error creating case. Please try again.', 'danger')
    return redirect(url_for('cases.list_cases'))
```

**Note**: MySQL with InnoDB engine required for proper locking support (should already be configured).

**Estimated Time**: 30 minutes
**Testing**: Requires concurrent request testing (load testing tool)

---

### 11. No Input Length Limits ⬜
**Severity**: HIGH
**Files**: `case_routes.py:427`, `case_routes.py:333`
**Vulnerability**: Denial of Service

**Description**: User input fields have no length validation before database insertion.

**Implementation**:
```python
# Add constants at top of case_routes.py (after imports)
MAX_NOTE_LENGTH = 10000
MAX_DESCRIPTION_LENGTH = 5000
MAX_CASE_NAME_LENGTH = 200
MAX_CLIENT_NAME_LENGTH = 200
MAX_FILENAME_LENGTH = 255

# Create validation helper
def validate_input_length(field_name, value, max_length):
    """Validate input length"""
    if value and len(value) > max_length:
        return False, f'{field_name} is too long. Maximum {max_length} characters allowed (current: {len(value)})'
    return True, 'Valid'

# Update add_note route (around line 427)
note_text = request.form.get('note_text', '').strip()
note_type = request.form.get('note_type', 'general')

# NEW CODE:
if not note_text:
    flash('Note text is required', 'warning')
    return redirect(url_for('cases.view_case', case_id=case_id))

valid, message = validate_input_length('Note', note_text, MAX_NOTE_LENGTH)
if not valid:
    flash(message, 'warning')
    return redirect(url_for('cases.view_case', case_id=case_id))

note = CaseNote(
    case_id=case_id,
    user_id=user_id,
    note_text=note_text,
    note_type=note_type
)
# ... rest of code

# Update add_document route (around line 333)
# Add after line 333:
description = request.form.get('description', '').strip()

# NEW CODE:
if description:
    valid, message = validate_input_length('Description', description, MAX_DESCRIPTION_LENGTH)
    if not valid:
        return jsonify({'error': message}), 400

# Extract text content for search indexing
# ... rest of code

# Update new_case route (around line 199)
# Add validation for all text fields before creating case:

case_name = request.form.get('case_name', '').strip()
case_number = request.form.get('case_number', '').strip()
client_name = request.form.get('client_name', '').strip()
description = request.form.get('description', '').strip()

# NEW CODE:
validations = [
    ('Case name', case_name, MAX_CASE_NAME_LENGTH),
    ('Case number', case_number, 100),
    ('Client name', client_name, MAX_CLIENT_NAME_LENGTH),
    ('Description', description, MAX_DESCRIPTION_LENGTH)
]

for field_name, value, max_len in validations:
    if value:
        valid, message = validate_input_length(field_name, value, max_len)
        if not valid:
            flash(message, 'warning')
            return redirect(url_for('cases.new_case'))

# Create case
case = Case(
    case_number=case_number,
    case_name=case_name,
    # ... rest of code
)
```

**Update Templates**: Add maxlength attributes to form inputs
```html
<!-- In case forms -->
<input type="text" name="case_name" maxlength="200" required>
<textarea name="description" maxlength="5000"></textarea>
<textarea name="note_text" maxlength="10000" required></textarea>
```

**Estimated Time**: 45 minutes

---

### 12. Timing Attack on Password Reset ⬜
**Severity**: HIGH
**Files**: `auth_routes.py:273-323`
**Vulnerability**: User Enumeration

**Description**: Response timing differs for existing vs non-existing users.

**Implementation**:
```python
# Update reset_password_request route (around line 273)
import time
import random

@auth_bp.route('/reset-password-request', methods=['GET', 'POST'])
def reset_password_request():
    if request.method == 'POST':
        email = request.form.get('email')

        # NEW CODE: Add validation (from issue #8)
        if not is_valid_email(email):
            flash('If your email is registered, you will receive a password reset link', 'info')
            return redirect(url_for('auth.login'))

        # Start timing baseline
        start_time = time.time()

        user = User.query.filter_by(email=email).first()

        if user:
            # Check if there's already a valid reset token for this user
            existing_reset = PasswordReset.query.filter_by(
                user_id=user.id,
                used=False
            ).filter(PasswordReset.expires_at > datetime.utcnow()).first()

            if existing_reset:
                # Delete the existing token
                db_session.delete(existing_reset)
                db_session.commit()

            # Create a new password reset token
            reset_token = PasswordReset.create_token(user.id)
            db_session.add(reset_token)
            db_session.commit()

            # Send email with reset link
            reset_url = url_for(
                'auth.reset_password',
                token=reset_token.token,
                _external=True
            )

            # Try to send email (updated in issue #5)
            try:
                from email_config import send_password_reset_email
                email_sent = send_password_reset_email(user.email, reset_url)

                if not email_sent:
                    current_app.logger.error(f"Password reset email failed for user {user.id}")
                    if current_app.debug:
                        print(f"DEV MODE - Password reset URL: {reset_url}")

            except Exception as e:
                current_app.logger.error(f"Password reset email error for user {user.id}: {str(e)}")
                if current_app.debug:
                    print(f"DEV MODE - Password reset URL: {reset_url}")

            # Log the activity
            log_activity(user.id, 'password_reset_request', f"Password reset requested for: {user.email}")

        # NEW CODE: Normalize timing
        # Calculate elapsed time
        elapsed = time.time() - start_time

        # Target response time: 0.5-1.0 seconds
        target_time = 0.5 + random.random() * 0.5

        # Sleep for remaining time if we finished too quickly
        if elapsed < target_time:
            time.sleep(target_time - elapsed)

        # Always show the same message (don't reveal if user exists)
        flash('If your email is registered, you will receive a password reset link', 'info')
        return redirect(url_for('auth.login'))

    return render_template('auth/reset_password_request.html')
```

**Alternative Approach**: Use constant-time comparison
```python
# More sophisticated version using constant-time operations
import hmac

def constant_time_process(email):
    """Process email with constant timing regardless of user existence"""
    user = User.query.filter_by(email=email).first()

    # Always generate a token (use dummy if user doesn't exist)
    if user:
        token = PasswordReset.create_token(user.id)
    else:
        # Generate dummy token (same format, not saved)
        token = PasswordReset(
            token=str(uuid.uuid4()),
            user_id=0,
            expires_at=datetime.utcnow() + timedelta(hours=1)
        )

    # Always attempt email send operation
    # ...

    return user is not None
```

**Estimated Time**: 25 minutes

---

## 🟡 MEDIUM PRIORITY (Fix Within 1 Month)

### 13. Inactive User in Team Operations ⬜
**Severity**: MEDIUM
**Files**: `case_routes.py:1236-1254`

**Implementation**:
```python
# Update add_team_member route (around line 1246)
member = User.query.get(member_id)
if not member:
    flash('User not found', 'danger')
    return redirect(url_for('cases.view_case', case_id=case_id))

# NEW CODE:
if not member.active:
    flash('Cannot add inactive user to case team', 'warning')
    return redirect(url_for('cases.view_case', case_id=case_id))

# Check if user is in the same company
if member.company_id != case.company_id:
    flash('Can only add team members from the same company', 'danger')
    return redirect(url_for('cases.view_case', case_id=case_id))
```

**Estimated Time**: 10 minutes

---

### 14. Information Disclosure via Error Responses ⬜
**Severity**: MEDIUM
**Files**: `case_routes.py:959`, multiple locations

**Implementation**:
```python
# Update document_status route (around line 959)
# OLD CODE:
# case = Case.query.get_or_404(case_id)
# if not user.can_view_case(case):
#     return jsonify({'error': 'Access denied'}), 403

# NEW CODE (consistent 404 for both not found and access denied):
case = Case.query.get(case_id)
if not case or not user.can_view_case(case):
    return jsonify({'error': 'Case not found'}), 404

document = CaseDocument.query.filter_by(id=document_id, case_id=case_id).first()
if not document:
    return jsonify({'error': 'Document not found'}), 404

# Apply same pattern to other routes:
# - analyze_case_document (line 982)
# - summarize_case_document (line 1000)
# - view_document (line 1018)
# - download_document (line 1072)
```

**Estimated Time**: 30 minutes

---

### 15. Missing Company Validation for Lead Attorney ⬜
**Severity**: MEDIUM
**Files**: `case_routes.py:614-616`

**Implementation**:
```python
# Update edit_case route (around line 614)
# OLD CODE:
# new_lead_attorney = request.form.get('lead_attorney_id')
# if new_lead_attorney and (user.is_admin() or user.is_company_admin() or case.created_by_id == user_id):
#     case.lead_attorney_id = int(new_lead_attorney)

# NEW CODE:
new_lead_attorney_id = request.form.get('lead_attorney_id')
if new_lead_attorney_id and (user.is_admin() or user.is_company_admin() or case.created_by_id == user_id):
    new_attorney = User.query.get(int(new_lead_attorney_id))

    if not new_attorney:
        flash('Invalid lead attorney selected', 'danger')
        return redirect(url_for('cases.edit_case', case_id=case_id))

    # NEW: Validate attorney is in same company (unless system admin)
    if not user.is_admin() and new_attorney.company_id != case.company_id:
        flash('Lead attorney must be from the same company', 'danger')
        return redirect(url_for('cases.edit_case', case_id=case_id))

    # NEW: Validate attorney is active
    if not new_attorney.active:
        flash('Cannot assign inactive user as lead attorney', 'warning')
        return redirect(url_for('cases.edit_case', case_id=case_id))

    case.lead_attorney_id = new_attorney.id
```

**Estimated Time**: 15 minutes

---

## 🟢 LOW PRIORITY (Fix When Convenient)

### 16. Duplicate Password Check ⬜
**Severity**: LOW
**Files**: `auth_routes.py:94`, `auth_routes.py:105`

**Implementation**:
```python
# Update login route (around line 94)
# OLD CODE:
# if user and user.check_password(password):
#     # Check if email is verified (if required)
#     if not user.email_verified and hasattr(user, 'email_verified'):
#         flash('Please verify your email address before logging in...', 'warning')
#         return render_template('login.html')
#
#     # Check if account is active
#     if not user.active:
#         flash('Your account has been deactivated...', 'danger')
#         return render_template('login.html')
#
# if user and user.check_password(password) and user.active:

# NEW CODE (consolidated):
if user and user.check_password(password):
    # Check if email is verified (if required)
    if not user.email_verified and hasattr(user, 'email_verified'):
        flash('Please verify your email address before logging in. Check your inbox for the verification link.', 'warning')
        return render_template('login.html')

    # Check if account is active
    if not user.active:
        flash('Your account has been deactivated. Please contact support.', 'danger')
        return render_template('login.html')

    # Update last login time
    user.last_login = datetime.utcnow()

    # ... rest of login code
else:
    flash('Invalid email or password', 'danger')

return render_template('login.html')
```

**Estimated Time**: 10 minutes

---

### 17. Sensitive Data in Console Logs ⬜
**Severity**: LOW
**Files**: `case_routes.py:883`, `case_routes.py:399`, multiple locations

**Implementation**:
```python
# Replace all `print()` statements with proper logging

# Find all print statements
grep -n "print(f" case_routes.py auth_routes.py

# Replace with secure logging:
# print(f"Error deleting file {document.file_path}: {e}")
# becomes:
current_app.logger.error(f"Error deleting file for document {document.id}: {e}")

# print(f"Deleted file: {document.file_path}")
# becomes:
current_app.logger.info(f"Deleted file for document {document.id}")

# General pattern:
# - Don't log full file paths
# - Don't log user emails
# - Do log user IDs and document IDs
# - Use appropriate log levels (debug, info, warning, error)
```

**Estimated Time**: 30 minutes

---

### 18. No CSRF Token Expiry ⬜
**Severity**: LOW
**Files**: `config/security_config.py:25`

**Implementation**:
```python
# Update security_config.py
# OLD:
# WTF_CSRF_TIME_LIMIT = None  # No time limit for CSRF tokens

# NEW:
WTF_CSRF_TIME_LIMIT = 3600  # 1 hour expiry (in seconds)

# Alternative for better UX:
# WTF_CSRF_TIME_LIMIT = 7200  # 2 hours

# Restart service after change:
# systemctl restart epolaw
```

**Note**: May cause issues for users with forms open for long periods. Monitor and adjust as needed.

**Estimated Time**: 5 minutes

---

## 📊 ADDITIONAL SECURITY IMPROVEMENTS

### A. Dependency Vulnerability Scanning ⬜
**Priority**: HIGH

**Implementation**:
```bash
# Install pip-audit
/var/www/lawbot/venv/bin/pip install pip-audit

# Run vulnerability scan
/var/www/lawbot/venv/bin/pip-audit

# Check outdated packages
/var/www/lawbot/venv/bin/pip list --outdated

# Update critical security patches
/var/www/lawbot/venv/bin/pip install --upgrade flask werkzeug sqlalchemy

# Update requirements
/var/www/lawbot/venv/bin/pip freeze > /var/www/lawbot/config/requirements.txt
```

**Set up automated scanning** (optional):
```bash
# Add to crontab (weekly scan)
0 2 * * 0 /var/www/lawbot/venv/bin/pip-audit --format json > /var/log/epolaw/security_scan.json
```

---

### B. Security Headers ⬜
**Priority**: MEDIUM

Check if security headers are properly configured in Apache or app.py:
```python
# Add to app.py (after creating app)
@app.after_request
def add_security_headers(response):
    """Add security headers to all responses"""
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'SAMEORIGIN'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
    return response
```

---

### C. Logging & Monitoring ⬜
**Priority**: MEDIUM

Set up security event logging:
```python
# Create security_logger.py
import logging
from logging.handlers import RotatingFileHandler

def setup_security_logger(app):
    """Set up dedicated security event logging"""
    security_logger = logging.getLogger('security')
    security_logger.setLevel(logging.INFO)

    handler = RotatingFileHandler(
        '/var/log/epolaw/security.log',
        maxBytes=10*1024*1024,  # 10MB
        backupCount=10
    )

    formatter = logging.Formatter(
        '%(asctime)s [%(levelname)s] %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )

    handler.setFormatter(formatter)
    security_logger.addHandler(handler)

    return security_logger

# Log security events:
# - Failed login attempts
# - Rate limit violations
# - Path traversal attempts
# - Invalid file uploads
# - Authorization failures
```

---

### D. Backup Before Making Changes ⬜
**Priority**: CRITICAL (Do First!)

```bash
# Create backup before implementing security fixes
BACKUP_DIR="/var/www/lawbot/backups/backup_$(date +%Y%m%d_%H%M%S)_before_security_fixes"
mkdir -p $BACKUP_DIR

# Copy all Python files
cp /var/www/lawbot/*.py $BACKUP_DIR/

# Copy templates and config
cp -r /var/www/lawbot/templates $BACKUP_DIR/
cp -r /var/www/lawbot/config $BACKUP_DIR/

# Backup database
mysqldump --all-databases --single-transaction > $BACKUP_DIR/database_backup.sql

# Create archive
cd /var/www/lawbot/backups
tar -czf $(basename $BACKUP_DIR).tar.gz $(basename $BACKUP_DIR)/

echo "✅ Backup created: $(basename $BACKUP_DIR).tar.gz"
```

---

## 📋 IMPLEMENTATION CHECKLIST

**Before Starting**:
- [ ] Create full system backup
- [ ] Review all TODO items with team
- [ ] Set up testing environment
- [ ] Plan maintenance window if needed

**During Implementation**:
- [ ] Implement issues one at a time
- [ ] Test each fix individually
- [ ] Restart service after each change: `systemctl restart epolaw`
- [ ] Monitor logs: `journalctl -u epolaw -f`
- [ ] Check for errors: `tail -f /var/log/epolaw/error.log`

**After Implementation**:
- [ ] Run full regression testing
- [ ] Update documentation
- [ ] Run dependency vulnerability scan
- [ ] Update SECURITY_TODO.md with completion dates
- [ ] Create new backup with fixes applied

---

## 🎯 RECOMMENDED IMPLEMENTATION ORDER

**Phase 1 (Day 1)** - Critical Quick Wins:
1. Issue #1: Rate limiting (15 min)
2. Issue #2: Open redirect (20 min)
3. Issue #5: Password reset disclosure (20 min)
4. Issue #6: Session regeneration (10 min)
5. Backup after changes

**Phase 2 (Day 2)** - Critical File Security:
1. Issue #3: Path traversal (30 min)
2. Issue #4: File size validation (25 min)
3. Issue #9: File content validation (45 min)
4. Backup after changes

**Phase 3 (Day 3)** - Authentication Hardening:
1. Issue #7: Password strength (40 min)
2. Issue #8: Email validation (20 min)
3. Issue #12: Timing attacks (25 min)
4. Backup after changes

**Phase 4 (Day 4)** - High Priority Remaining:
1. Issue #10: Race conditions (30 min)
2. Issue #11: Input length limits (45 min)
3. Backup after changes

**Phase 5 (Day 5)** - Medium/Low Priority:
1. Issues #13-18: All remaining items
2. Dependency updates
3. Security headers
4. Final testing and documentation

---

## 📞 SUPPORT & RESOURCES

**Testing**:
- Use `curl` for API endpoint testing
- Use browser developer tools for UI testing
- Check logs after each change

**Rollback Plan**:
```bash
# If issues arise, restore from backup
cd /var/www/lawbot/backups
tar -xzf backup_YYYYMMDD_HHMMSS.tar.gz
cp -r backup_YYYYMMDD_HHMMSS/*.py /var/www/lawbot/
systemctl restart epolaw
```

**Reference Documentation**:
- Flask-Limiter: https://flask-limiter.readthedocs.io/
- Flask-WTF CSRF: https://flask-wtf.readthedocs.io/en/stable/csrf.html
- OWASP Top 10: https://owasp.org/www-project-top-ten/

---

**Last Updated**: October 11, 2025
**Next Review**: After all critical issues resolved
**Document Owner**: Development Team
