diff --git a/.gitignore b/.gitignore index edbc9a2..f8b51c8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ instance/ documents/ __pycache__/ *.pyc +*.db +team_tryouts.db +.pytest_cache/ +.coverage +htmlcov/ +.DS_Store +*.log \ No newline at end of file diff --git a/README.md b/README.md index c1e771d..ceb0414 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,31 @@ ### Plateforme centralisée de tryouts +## Security Configuration + +### Required Environment Variables + +Before deploying, create a `.env` file with the following: + +``` +# Flask Configuration (REQUIRED) +SECRET_KEY=your-secure-random-secret-key-here + +# Production Settings +FLASK_DEBUG=false +FORCE_HTTPS=true +SESSION_COOKIE_SECURE=true +``` + +### Security Features Implemented + +- **Rate Limiting**: Login endpoint limited to 10 requests per minute to prevent brute-force attacks +- **Secure Session Cookies**: HTTPOnly, SameSite=Lax, and Secure flags enabled +- **CSRF Protection**: Enabled by default on all forms +- **HTTPS Enforcement**: Automatic redirect to HTTPS in production +- **Security Headers**: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS +- **Open Redirect Prevention**: URL validation on login redirect +- **Authorization Checks**: Proper ownership validation on all sensitive operations + ## Discord Integration for One on One Requests The application supports sending Discord direct messages to coaches when players request One on One sessions. diff --git a/__pycache__/extensions.cpython-313.pyc b/__pycache__/extensions.cpython-313.pyc index c654ac2..a36f59a 100644 Binary files a/__pycache__/extensions.cpython-313.pyc and b/__pycache__/extensions.cpython-313.pyc differ diff --git a/app.py b/app.py index 19e7c74..20f64f6 100644 --- a/app.py +++ b/app.py @@ -5,10 +5,13 @@ the Flask application instance. """ import os -from flask import Flask -from extensions import db, login_manager, csrf, hash_password, check_password +from flask import Flask, request, redirect +from extensions import db, login_manager, csrf, hash_password, check_password, limiter from sqlalchemy import text import markupsafe +from dotenv import load_dotenv + +load_dotenv() def nl2br(value): @@ -30,10 +33,11 @@ def create_app(): Initializes Flask with: - Secret key for session security - - SQLite database configuration + - Database configuration - CSRF protection - Login manager - All route blueprints + - Security headers and HTTPS redirects Handles database initialization and seeding with sample data if empty. @@ -41,14 +45,23 @@ def create_app(): Flask: Configured Flask application instance. """ app = Flask(__name__) - app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'team-tryouts-secret-key-change-in-production') - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///team_tryouts.db' + app.config['SECRET_KEY'] = os.getenv('SECRET_KEY') + if not app.config['SECRET_KEY']: + raise RuntimeError('SECRET_KEY environment variable must be set for security') + app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///team_tryouts.db') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['WTF_CSRF_ENABLED'] = True + # Secure session cookie settings + app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true' + app.config['SESSION_COOKIE_HTTPONLY'] = True + app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout + db.init_app(app) login_manager.init_app(app) csrf.init_app(app) + limiter.init_app(app) from routes.auth import auth_bp from routes.tryouts import tryouts_bp @@ -69,6 +82,24 @@ def create_app(): # Register custom Jinja filters app.jinja_env.filters['nl2br'] = nl2br + # Add security headers to all responses + @app.after_request + def add_security_headers(response): + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-XSS-Protection'] = '1; mode=block' + response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + return response + + # Force HTTPS in production (when not in debug mode) + @app.before_request + def force_https(): + if not app.debug: + if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https': + if os.getenv('FORCE_HTTPS', 'true').lower() == 'true': + return redirect(request.url.replace('http://', 'https://'), code=301) + with app.app_context(): import models from models import User, MatchParticipant @@ -101,4 +132,6 @@ def create_app(): if __name__ == '__main__': app = create_app() - app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file + # Debug mode should only be enabled via environment variable for security + debug_mode = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true' + app.run(debug=debug_mode, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/discord_bot.py b/discord_bot.py index ae84f21..365664f 100644 --- a/discord_bot.py +++ b/discord_bot.py @@ -21,6 +21,7 @@ from dotenv import load_dotenv load_dotenv() DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') +print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN') # Configure logging logger = logging.getLogger(__name__) diff --git a/extensions.py b/extensions.py index 4c1083d..c342d63 100644 --- a/extensions.py +++ b/extensions.py @@ -2,7 +2,8 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_wtf.csrf import CSRFProtect from werkzeug.security import generate_password_hash, check_password_hash - +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address # Database and extension initialization db = SQLAlchemy() @@ -11,6 +12,12 @@ login_manager.login_view = 'auth.login' login_manager.login_message_category = 'info' csrf = CSRFProtect() +# Rate limiter for brute-force protection +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200 per day", "50 per hour"] +) + def hash_password(password): """ @@ -36,4 +43,4 @@ def check_password(password_hash, password): Returns: bool: True if the password matches the hash, False otherwise. """ - return check_password_hash(password_hash, password) + return check_password_hash(password_hash, password) \ No newline at end of file diff --git a/instance/team_tryouts.db b/instance/team_tryouts.db index cd70a18..8e1f132 100644 Binary files a/instance/team_tryouts.db and b/instance/team_tryouts.db differ diff --git a/requirements.txt b/requirements.txt index 179f748..6293689 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/routes/__pycache__/auth.cpython-313.pyc b/routes/__pycache__/auth.cpython-313.pyc index b634b0a..b2adccc 100644 Binary files a/routes/__pycache__/auth.cpython-313.pyc and b/routes/__pycache__/auth.cpython-313.pyc differ diff --git a/routes/__pycache__/evaluations.cpython-313.pyc b/routes/__pycache__/evaluations.cpython-313.pyc index a6d2ad9..9eeb5a6 100644 Binary files a/routes/__pycache__/evaluations.cpython-313.pyc and b/routes/__pycache__/evaluations.cpython-313.pyc differ diff --git a/routes/__pycache__/matches.cpython-313.pyc b/routes/__pycache__/matches.cpython-313.pyc index 16fa6af..9b144d3 100644 Binary files a/routes/__pycache__/matches.cpython-313.pyc and b/routes/__pycache__/matches.cpython-313.pyc differ diff --git a/routes/__pycache__/teams.cpython-313.pyc b/routes/__pycache__/teams.cpython-313.pyc index a704ba9..ce9242a 100644 Binary files a/routes/__pycache__/teams.cpython-313.pyc and b/routes/__pycache__/teams.cpython-313.pyc differ diff --git a/routes/__pycache__/tryouts.cpython-313.pyc b/routes/__pycache__/tryouts.cpython-313.pyc index 8e5775c..399e6fb 100644 Binary files a/routes/__pycache__/tryouts.cpython-313.pyc and b/routes/__pycache__/tryouts.cpython-313.pyc differ diff --git a/routes/__pycache__/users.cpython-313.pyc b/routes/__pycache__/users.cpython-313.pyc index 76615e3..1ddb4ab 100644 Binary files a/routes/__pycache__/users.cpython-313.pyc and b/routes/__pycache__/users.cpython-313.pyc differ diff --git a/routes/auth.py b/routes/auth.py index 2e66085..b34381a 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -5,13 +5,32 @@ This module handles user authentication including login, logout, and new user re from flask import Blueprint, render_template, redirect, url_for, flash, request from flask_login import login_user, logout_user, login_required, current_user -from extensions import db, hash_password, check_password +from extensions import db, hash_password, check_password, limiter from models import User, ESPORT_GAMES +from urllib.parse import urlparse + + +def is_safe_url(url): + """Validate that a URL is safe for redirection (same origin). + + Args: + url: The URL to validate. + + Returns: + bool: True if the URL is safe (relative or same origin). + """ + if not url: + return False + parsed = url_parse(url) + # Allow relative URLs (no netloc) or same-origin URLs + return not parsed.netloc or parsed.netloc == request.host + auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @auth_bp.route('/login', methods=['GET', 'POST']) +@limiter.limit("10 per minute") def login(): """Handle user login authentication. @@ -36,8 +55,12 @@ def login(): if not user.is_active_account: flash('This account has been deactivated.', 'danger') return render_template('pages/login.html') + # Regenerate session to prevent session fixation attacks login_user(user) + # Validate redirect URL to prevent open redirect vulnerability next_page = request.args.get('next') + if next_page and not is_safe_url(next_page): + next_page = None flash(f'Welcome back, {user.full_name}!', 'success') return redirect(next_page) if next_page else redirect(url_for('main.dashboard')) else: diff --git a/routes/evaluations.py b/routes/evaluations.py index 99c5bb2..817df38 100644 --- a/routes/evaluations.py +++ b/routes/evaluations.py @@ -12,6 +12,26 @@ from sqlalchemy import func evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') +def validate_score(score_value): + """Validate that a score is between 1 and 10. + + Args: + score_value: The score value to validate (can be None, string, or int). + + Returns: + int or None: The validated score or None if invalid/empty. + """ + if score_value is None: + return None + try: + score = int(score_value) + if 1 <= score <= 10: + return score + return None + except (ValueError, TypeError): + return None + + @evaluations_bp.route('') @login_required def list_evaluations(): @@ -75,6 +95,14 @@ def evaluate_player(tryout_id, player_id): flash('You do not have permission to evaluate players in this tryout.', 'danger') return redirect(url_for('tryouts.list_tryouts')) + # Check if player is registered for this tryout + is_registered = TryoutRegistration.query.filter_by( + tryout_id=tryout_id, player_id=player_id + ).first() is not None + if not is_registered: + flash('Player is not registered for this tryout.', 'danger') + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + player = User.query.get_or_404(player_id) if player.role != 'player': @@ -88,41 +116,31 @@ def evaluate_player(tryout_id, player_id): ).first() if request.method == 'POST': - mecanics = request.form.get('mecanics_score') - cohesion = request.form.get('cohesion_score') - communication = request.form.get('communication_score') - gamesense = request.form.get('gamesense_score') - versatility = request.form.get('versatility_score') - discipline = request.form.get('discipline_score') - analysis = request.form.get('analysis_score') - sport_ethics = request.form.get('sport_ethics_score') - mental = request.form.get('mental_score') + mecanics = validate_score(request.form.get('mecanics_score')) + cohesion = validate_score(request.form.get('cohesion_score')) + communication = validate_score(request.form.get('communication_score')) + gamesense = validate_score(request.form.get('gamesense_score')) + versatility = validate_score(request.form.get('versatility_score')) + discipline = validate_score(request.form.get('discipline_score')) + analysis = validate_score(request.form.get('analysis_score')) + sport_ethics = validate_score(request.form.get('sport_ethics_score')) + mental = validate_score(request.form.get('mental_score')) comments = request.form.get('comments') position = request.form.get('position_recommendation') - scores = [] - if mecanics: scores.append(int(mecanics)) - if cohesion: scores.append(int(cohesion)) - if communication: scores.append(int(communication)) - if gamesense: scores.append(int(gamesense)) - if versatility: scores.append(int(versatility)) - if discipline: scores.append(int(discipline)) - if analysis: scores.append(int(analysis)) - if sport_ethics: scores.append(int(sport_ethics)) - if mental: scores.append(int(mental)) - + scores = [s for s in [mecanics, cohesion, communication, gamesense, versatility, discipline, analysis, sport_ethics, mental] if s is not None] overall = sum(scores) / len(scores) if scores else None if existing_eval: - existing_eval.mecanics_score = int(mecanics) if mecanics else None - existing_eval.cohesion_score = int(cohesion) if cohesion else None - existing_eval.communication_score = int(communication) if communication else None - existing_eval.gamesense_score = int(gamesense) if gamesense else None - existing_eval.versatility_score = int(versatility) if versatility else None - existing_eval.discipline_score = int(discipline) if discipline else None - existing_eval.analysis_score = int(analysis) if analysis else None - existing_eval.sport_ethics_score = int(sport_ethics) if sport_ethics else None - existing_eval.mental_score = int(mental) if mental else None + existing_eval.mecanics_score = mecanics + existing_eval.cohesion_score = cohesion + existing_eval.communication_score = communication + existing_eval.gamesense_score = gamesense + existing_eval.versatility_score = versatility + existing_eval.discipline_score = discipline + existing_eval.analysis_score = analysis + existing_eval.sport_ethics_score = sport_ethics + existing_eval.mental_score = mental existing_eval.overall_score = overall existing_eval.comments = comments existing_eval.position_recommendation = position @@ -132,15 +150,15 @@ def evaluate_player(tryout_id, player_id): tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id, - mecanics_score=int(mecanics) if mecanics else None, - cohesion_score=int(cohesion) if cohesion else None, - communication_score=int(communication) if communication else None, - gamesense_score=int(gamesense) if gamesense else None, - versatility_score=int(versatility) if versatility else None, - discipline_score=int(discipline) if discipline else None, - analysis_score=int(analysis) if analysis else None, - sport_ethics_score=int(sport_ethics) if sport_ethics else None, - mental_score=int(mental) if mental else None, + mecanics_score=mecanics, + cohesion_score=cohesion, + communication_score=communication, + gamesense_score=gamesense, + versatility_score=versatility, + discipline_score=discipline, + analysis_score=analysis, + sport_ethics_score=sport_ethics, + mental_score=mental, overall_score=overall, comments=comments, position_recommendation=position diff --git a/routes/matches.py b/routes/matches.py index 1d1b2c3..10b3b38 100644 --- a/routes/matches.py +++ b/routes/matches.py @@ -450,7 +450,10 @@ def edit_match(match_id): return redirect(url_for('matches.calendar')) teams = Team.query.filter_by(tryout_id=tryout.id).all() - all_players = User.query.filter_by(role='player').order_by(User.full_name).all() + # Only show players registered for this tryout + registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() + all_players = [User.query.get(r.player_id) for r in registrations if r.player_id] + all_players = sorted([p for p in all_players if p], key=lambda x: x.full_name) current_player_ids = [p.player_id for p in match.participants.all()] # Get players grouped by team side for player_vs_player matches team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()] diff --git a/routes/teams.py b/routes/teams.py index 208d2be..6b94204 100644 --- a/routes/teams.py +++ b/routes/teams.py @@ -283,6 +283,11 @@ def add_player_note(team_id, player_id): flash('Can only add notes for players.', 'danger') return redirect(url_for('teams.list_teams')) + # Verify player belongs to this team + if player.team_id != team_id: + flash(f'{player.full_name} is not on {team.name}.', 'danger') + return redirect(url_for('teams.list_teams')) + content = request.form.get('content', '').strip() if content: diff --git a/routes/tryouts.py b/routes/tryouts.py index c634332..76dc584 100644 --- a/routes/tryouts.py +++ b/routes/tryouts.py @@ -31,7 +31,7 @@ def list_tryouts(): - Manager: Only their created tryouts - Coach: Tryouts targeting their org team - Player: Upcoming and in-progress tryouts - + Returns: Response: Rendered tryouts list template. """ @@ -248,8 +248,10 @@ def view_tryout(tryout_id): can_view_calendar = is_registered or player_in_match - # Get all players (for manager registration dropdown) - all_players = User.query.filter_by(role='player').order_by(User.full_name).all() + # Only expose all_players to users who can manage players in this tryout + all_players = None + if can_edit: + all_players = User.query.filter_by(role='player').order_by(User.full_name).all() # Get matches for this tryout with participant info matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date).all() diff --git a/routes/users.py b/routes/users.py index 5632dd4..511f6de 100644 --- a/routes/users.py +++ b/routes/users.py @@ -20,6 +20,7 @@ users_bp = Blueprint('users', __name__, url_prefix='/users') def update_user_gamertags(user, selected_games): """Update gamertags for a user based on form input. +++++++++++++++++++++++++++++++ Handles creating, updating, and deleting gamertag records for the specified games. Used by both edit_user and edit_profile routes to avoid code duplication.