From 20158a9e7a2f06b529683067490467ac9e4d6348 Mon Sep 17 00:00:00 2001 From: GGThed Date: Sat, 8 Aug 2026 15:09:13 -0400 Subject: [PATCH] fix(security): verifier les fichiers televerses et retirer un intent Discord SEC-021 -- rien ne validait le contrat signe upload_signed_contract se contentait d un nom de fichier non vide. ALLOWED_SIGNED_EXTENSIONS etait declaree juste a cote et jamais lue. Le fichier atterrissait sur le disque sous un nom que download_signed_contract sert ensuite : ce qu un joueur televerse est ce qu un gerant ouvre. Le nom seul ne suffisait pas non plus cote upload_contract, qui verifiait `.pdf` en fin de chaine -- payload.pdf ne dit rien des octets. pdf_upload_error() couvre les deux routes : extension dans la liste, puis signature %PDF- en tete de flux. Le flux est rembobine, l appelant enregistre toujours le fichier entier. OPS-014 -- intent Discord privilegie inutile Le bot demandait GUILD_MEMBERS et ne s en servait pas : rien n enumere ni ne recherche de membre de serveur, les personnes sont jointes par le discord_user_id enregistre sur leur compte. Retire. message_content reste : on_raw_reaction_add lit le texte de la reponse d un coach pour consigner un motif de refus. CI-003 et CI-005 sont deja appliques (permissions: contents: read, checkout@v4, exclusions de deploiement). L epinglage par SHA des actions n est pas fait : ce sont des actions GitHub de premiere partie, et l epingler sans Dependabot echange une exposition contre une autre. 11 tests, dont deux verifient que signer un contrat marche toujours et qu un autre joueur ne peut pas le faire. Co-Authored-By: Claude Opus 5 --- app/discord_bot.py | 11 +- app/routes/users.py | 59 +++++-- app/translations/en/LC_MESSAGES/messages.mo | Bin 43614 -> 43726 bytes app/translations/en/LC_MESSAGES/messages.po | 163 +++++++++---------- app/translations/fr/LC_MESSAGES/messages.mo | Bin 47792 -> 47910 bytes app/translations/fr/LC_MESSAGES/messages.po | 167 ++++++++++---------- tests/test_contract_uploads.py | 146 +++++++++++++++++ 7 files changed, 365 insertions(+), 181 deletions(-) create mode 100644 tests/test_contract_uploads.py diff --git a/app/discord_bot.py b/app/discord_bot.py index 24e74b9..0e227b0 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -42,14 +42,21 @@ class TeamTryoutsBot(commands.Bot): """ def __init__(self, flask_app=None): + # Only what the bot actually reads. `members` — the privileged + # GUILD_MEMBERS intent — was requested and never used: nothing here + # lists or looks up guild members, the bot reaches people through + # the discord_user_id stored on their account (OPS-014). + # + # `message_content` stays: on_raw_reaction_add reads the text of a + # coach's reply to record a refusal note. intents = Intents.default() intents.message_content = True intents.dm_messages = True intents.dm_reactions = True intents.reactions = True intents.guilds = True - intents.members = True - + + super().__init__(command_prefix='!', intents=intents) self.flask_app = flask_app self.pending_requests = {} # Maps message_id to {type, id} for reaction handling diff --git a/app/routes/users.py b/app/routes/users.py index 2f051d9..5f36f46 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -35,9 +35,43 @@ import requests ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} ALLOWED_SIGNED_EXTENSIONS = {'pdf'} +#: Every PDF starts with this. Checking the name alone accepted a file +#: called anything.pdf holding anything at all. +PDF_SIGNATURE = b'%PDF-' + users_bp = Blueprint('users', __name__, url_prefix='/users') +def pdf_upload_error(file, allowed_extensions): + """Why this upload is not an acceptable PDF, or None if it is. + + upload_signed_contract checked nothing beyond a non-empty filename — + ALLOWED_SIGNED_EXTENSIONS was declared and never read — so a player + could put an arbitrary file on the server under a name the application + later hands back for download (SEC-021). + + Args: + file: The uploaded FileStorage, or None. + allowed_extensions: Extensions to accept, lowercase and without dot. + + Returns: + str | None: A message to flash, or None when the file is acceptable. + """ + if file is None or not file.filename: + return _('No file selected.') + + stem, dot, extension = file.filename.rpartition('.') + if not (stem and dot) or extension.lower() not in allowed_extensions: + return _('Only PDF files are allowed for contracts.') + + head = file.stream.read(len(PDF_SIGNATURE)) + file.stream.seek(0) + if head != PDF_SIGNATURE: + return _('That file is not a PDF, whatever its name says.') + + return None + + # --------------------------------------------------------------------------- # Form helpers shared by the schema-validated routes # --------------------------------------------------------------------------- @@ -660,16 +694,10 @@ def upload_contract(): flash(_('You do not have permission to upload a contract for this player.'), 'danger') return redirect(url_for('users.upload_contract')) - if 'contract_file' not in request.files: - flash(_('No file selected.'), 'danger') - return redirect(url_for('users.upload_contract')) - - file = request.files['contract_file'] - if file.filename == '': - flash(_('No file selected.'), 'danger') - return redirect(url_for('users.upload_contract')) - if not file.filename.lower().endswith('.pdf'): - flash(_('Only PDF files are allowed for contracts.'), 'danger') + file = request.files.get('contract_file') + error = pdf_upload_error(file, ALLOWED_CONTRACT_EXTENSIONS) + if error: + flash(error, 'danger') return redirect(url_for('users.upload_contract')) upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés') @@ -717,13 +745,10 @@ def upload_signed_contract(contract_id): flash(_('Only the player can upload their signed contract.'), 'danger') return redirect(url_for('users.list_contracts')) - if 'signed_file' not in request.files: - flash(_('No file selected.'), 'danger') - return redirect(url_for('users.list_contracts')) - - file = request.files['signed_file'] - if file.filename == '': - flash(_('No file selected.'), 'danger') + file = request.files.get('signed_file') + error = pdf_upload_error(file, ALLOWED_SIGNED_EXTENSIONS) + if error: + flash(error, 'danger') return redirect(url_for('users.list_contracts')) signed_filename = f"signed_{contract.stored_filename}" diff --git a/app/translations/en/LC_MESSAGES/messages.mo b/app/translations/en/LC_MESSAGES/messages.mo index 4b02bbf225f4da6b446e2015d41f3dcf25aec829..7555c4e3ab01413520ea2781510e6838b9150cbb 100644 GIT binary patch delta 10245 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 delta 10147 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 diff --git a/app/translations/en/LC_MESSAGES/messages.po b/app/translations/en/LC_MESSAGES/messages.po index 81612f3..fb448dd 100644 --- a/app/translations/en/LC_MESSAGES/messages.po +++ b/app/translations/en/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-08 14:58-0400\n" +"POT-Creation-Date: 2026-08-08 15:07-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -100,22 +100,22 @@ msgstr "Points must be 2000 characters or less." msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Day must be 0 (Monday) to 6 (Sunday)." -#: app/routes/auth.py:169 app/routes/auth.py:297 app/routes/users.py:49 -#: app/routes/users.py:653 +#: app/routes/auth.py:186 app/routes/auth.py:314 app/routes/users.py:83 +#: app/routes/users.py:687 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s: %(msg)s" -#: app/routes/auth.py:187 +#: app/routes/auth.py:204 msgid "This account has been deactivated." msgstr "This account has been deactivated." -#: app/routes/auth.py:225 +#: app/routes/auth.py:242 #, python-format msgid "Welcome back, %(username)s!" msgstr "Welcome back, %(username)s!" -#: app/routes/auth.py:246 +#: app/routes/auth.py:263 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." @@ -123,27 +123,27 @@ msgstr "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." -#: app/routes/auth.py:278 +#: app/routes/auth.py:295 msgid "Incorrect CAPTCHA answer. Please try again." msgstr "Incorrect CAPTCHA answer. Please try again." -#: app/routes/auth.py:320 app/routes/users.py:336 +#: app/routes/auth.py:337 app/routes/users.py:370 msgid "Username already exists." msgstr "Username already exists." -#: app/routes/auth.py:332 app/routes/users.py:340 +#: app/routes/auth.py:349 app/routes/users.py:374 msgid "Email already registered." msgstr "Email already registered." -#: app/routes/auth.py:378 +#: app/routes/auth.py:395 msgid "Your account has been created! You can now log in." msgstr "Your account has been created! You can now log in." -#: app/routes/auth.py:404 +#: app/routes/auth.py:421 msgid "Discord OAuth2 is not configured." msgstr "Discord OAuth2 is not configured." -#: app/routes/auth.py:444 +#: app/routes/auth.py:461 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -151,27 +151,27 @@ msgstr "" "Discord authorization could not be verified. Please start the connection " "again from this page." -#: app/routes/auth.py:452 +#: app/routes/auth.py:469 msgid "Discord authorization failed. No code received." msgstr "Discord authorization failed. No code received." -#: app/routes/auth.py:476 +#: app/routes/auth.py:493 msgid "Failed to connect to Discord. Please try again." msgstr "Failed to connect to Discord. Please try again." -#: app/routes/auth.py:480 +#: app/routes/auth.py:497 msgid "Failed to obtain Discord access token." msgstr "Failed to obtain Discord access token." -#: app/routes/auth.py:495 +#: app/routes/auth.py:512 msgid "Failed to fetch Discord user profile." msgstr "Failed to fetch Discord user profile." -#: app/routes/auth.py:542 +#: app/routes/auth.py:559 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Discord account connected! Your profile has been pre-filled." -#: app/routes/auth.py:565 +#: app/routes/auth.py:587 msgid "You have been logged out." msgstr "You have been logged out." @@ -408,7 +408,7 @@ msgstr "You do not have permission to add notes to this team." msgid "Team notes added successfully!" msgstr "Team notes added successfully!" -#: app/routes/teams.py:444 app/routes/users.py:1241 app/routes/users.py:1283 +#: app/routes/teams.py:444 app/routes/users.py:1266 app/routes/users.py:1308 msgid "Can only add notes for players." msgstr "Can only add notes for players." @@ -526,23 +526,35 @@ msgstr "You do not have permission to delete this tryout." msgid "Tryout deleted successfully." msgstr "Tryout deleted successfully." -#: app/routes/users.py:119 +#: app/routes/users.py:61 +msgid "No file selected." +msgstr "No file selected." + +#: app/routes/users.py:65 +msgid "Only PDF files are allowed for contracts." +msgstr "Only PDF files are allowed for contracts." + +#: app/routes/users.py:70 +msgid "That file is not a PDF, whatever its name says." +msgstr "That file is not a PDF, whatever its name says." + +#: app/routes/users.py:153 msgid "Only the president can manage users." msgstr "Only the president can manage users." -#: app/routes/users.py:131 +#: app/routes/users.py:165 msgid "Only the president can edit users." msgstr "Only the president can edit users." -#: app/routes/users.py:168 +#: app/routes/users.py:202 msgid "Email already in use by another account." msgstr "Email already in use by another account." -#: app/routes/users.py:178 +#: app/routes/users.py:212 msgid "You cannot change your own role. Ask another president to do it." msgstr "You cannot change your own role. Ask another president to do it." -#: app/routes/users.py:189 +#: app/routes/users.py:223 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -550,181 +562,172 @@ msgstr "" "This is the last active president. Promote another account before " "changing this one." -#: app/routes/users.py:251 +#: app/routes/users.py:285 #, python-format msgid "User %(username)s updated successfully!" msgstr "User %(username)s updated successfully!" -#: app/routes/users.py:266 +#: app/routes/users.py:300 msgid "Only the president can delete users." msgstr "Only the president can delete users." -#: app/routes/users.py:270 +#: app/routes/users.py:304 msgid "You cannot delete your own account." msgstr "You cannot delete your own account." -#: app/routes/users.py:307 +#: app/routes/users.py:341 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "User %(deleted_username)s has been removed." -#: app/routes/users.py:316 +#: app/routes/users.py:350 msgid "Only the president can create users." msgstr "Only the president can create users." -#: app/routes/users.py:355 +#: app/routes/users.py:389 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "User %(full_name)s created as %(role)s!" -#: app/routes/users.py:412 +#: app/routes/users.py:446 msgid "Username already taken." msgstr "Username already taken." -#: app/routes/users.py:418 +#: app/routes/users.py:452 msgid "Email already in use." msgstr "Email already in use." -#: app/routes/users.py:443 +#: app/routes/users.py:477 msgid "Profile updated successfully!" msgstr "Profile updated successfully!" -#: app/routes/users.py:641 +#: app/routes/users.py:675 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Only presidents, managers, and coaches can upload contracts." -#: app/routes/users.py:660 +#: app/routes/users.py:694 msgid "You do not have permission to upload a contract for this player." msgstr "You do not have permission to upload a contract for this player." -#: app/routes/users.py:664 app/routes/users.py:669 app/routes/users.py:721 -#: app/routes/users.py:726 -msgid "No file selected." -msgstr "No file selected." - -#: app/routes/users.py:672 -msgid "Only PDF files are allowed for contracts." -msgstr "Only PDF files are allowed for contracts." - -#: app/routes/users.py:705 +#: app/routes/users.py:733 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contract uploaded successfully for %(username)s!" -#: app/routes/users.py:717 +#: app/routes/users.py:745 msgid "Only the player can upload their signed contract." msgstr "Only the player can upload their signed contract." -#: app/routes/users.py:737 +#: app/routes/users.py:762 msgid "Signed contract uploaded successfully!" msgstr "Signed contract uploaded successfully!" -#: app/routes/users.py:747 app/routes/users.py:758 +#: app/routes/users.py:772 app/routes/users.py:783 msgid "You do not have permission to download this contract." msgstr "You do not have permission to download this contract." -#: app/routes/users.py:761 +#: app/routes/users.py:786 msgid "No signed contract available." msgstr "No signed contract available." -#: app/routes/users.py:828 +#: app/routes/users.py:853 msgid "Only players can request One on One sessions." msgstr "Only players can request One on One sessions." -#: app/routes/users.py:841 +#: app/routes/users.py:866 msgid "You do not have a coach assigned to your team." msgstr "You do not have a coach assigned to your team." -#: app/routes/users.py:868 +#: app/routes/users.py:893 msgid "Cannot request One on One - no coach assigned." msgstr "Cannot request One on One - no coach assigned." -#: app/routes/users.py:876 +#: app/routes/users.py:901 msgid "Invalid date or time format." msgstr "Invalid date or time format." -#: app/routes/users.py:890 +#: app/routes/users.py:915 msgid "The requested time is not within the coach's availability." msgstr "The requested time is not within the coach's availability." -#: app/routes/users.py:913 +#: app/routes/users.py:938 msgid "Your One on One request has been submitted!" msgstr "Your One on One request has been submitted!" -#: app/routes/users.py:948 +#: app/routes/users.py:973 msgid "Only coaches can accept One on One requests." msgstr "Only coaches can accept One on One requests." -#: app/routes/users.py:954 app/routes/users.py:995 +#: app/routes/users.py:979 app/routes/users.py:1020 msgid "This request is not for you." msgstr "This request is not for you." -#: app/routes/users.py:958 app/routes/users.py:999 +#: app/routes/users.py:983 app/routes/users.py:1024 msgid "This request has already been processed." msgstr "This request has already been processed." -#: app/routes/users.py:980 +#: app/routes/users.py:1005 #, python-format msgid "One on One request from %(player)s has been approved!" msgstr "One on One request from %(player)s has been approved!" -#: app/routes/users.py:989 +#: app/routes/users.py:1014 msgid "Only coaches can reject One on One requests." msgstr "Only coaches can reject One on One requests." -#: app/routes/users.py:1026 +#: app/routes/users.py:1051 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "One on One request from %(player)s has been rejected." -#: app/routes/users.py:1039 +#: app/routes/users.py:1064 msgid "This page is for players only." msgstr "This page is for players only." -#: app/routes/users.py:1070 +#: app/routes/users.py:1095 msgid "Only coaches can manage availability." msgstr "Only coaches can manage availability." -#: app/routes/users.py:1132 +#: app/routes/users.py:1157 msgid "Only coaches can access the notes dashboard." msgstr "Only coaches can access the notes dashboard." -#: app/routes/users.py:1196 +#: app/routes/users.py:1221 msgid "Only coaches can manage team notes." msgstr "Only coaches can manage team notes." -#: app/routes/users.py:1202 +#: app/routes/users.py:1227 msgid "You are not assigned to a team." msgstr "You are not assigned to a team." -#: app/routes/users.py:1215 +#: app/routes/users.py:1240 msgid "Team notes saved successfully!" msgstr "Team notes saved successfully!" -#: app/routes/users.py:1229 +#: app/routes/users.py:1254 msgid "Only coaches can manage personal notes." msgstr "Only coaches can manage personal notes." -#: app/routes/users.py:1236 app/routes/users.py:1278 app/routes/users.py:1328 -#: app/routes/users.py:1379 +#: app/routes/users.py:1261 app/routes/users.py:1303 app/routes/users.py:1353 +#: app/routes/users.py:1404 msgid "Player and content are required." msgstr "Player and content are required." -#: app/routes/users.py:1245 app/routes/users.py:1287 app/routes/users.py:1332 -#: app/routes/users.py:1383 +#: app/routes/users.py:1270 app/routes/users.py:1312 app/routes/users.py:1357 +#: app/routes/users.py:1408 msgid "You can only write notes about players you work with." msgstr "You can only write notes about players you work with." -#: app/routes/users.py:1255 app/routes/users.py:1300 +#: app/routes/users.py:1280 app/routes/users.py:1325 #, python-format msgid "Note added for %(username)s." msgstr "Note added for %(username)s." -#: app/routes/users.py:1268 app/routes/users.py:1313 app/routes/users.py:1363 +#: app/routes/users.py:1293 app/routes/users.py:1338 app/routes/users.py:1388 msgid "Only coaches can add personal notes." msgstr "Only coaches can add personal notes." -#: app/routes/users.py:1343 app/routes/users.py:1394 +#: app/routes/users.py:1368 app/routes/users.py:1419 msgid "Note added successfully." msgstr "Note added successfully." @@ -845,7 +848,7 @@ msgstr "Try Again" msgid "Language" msgstr "Language" -#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:133 +#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:139 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Dashboard" @@ -904,19 +907,19 @@ msgstr "Contracts" msgid "My Profile" msgstr "My Profile" -#: app/templates/layouts/base.html:117 +#: app/templates/layouts/base.html:122 msgid "Logout" msgstr "Logout" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:143 msgid "Toggle dark mode" msgstr "Toggle dark mode" -#: app/templates/layouts/base.html:149 app/templates/layouts/base.html:168 +#: app/templates/layouts/base.html:155 app/templates/layouts/base.html:174 msgid "Dismiss" msgstr "Dismiss" -#: app/templates/layouts/base.html:178 +#: app/templates/layouts/base.html:184 msgid "Team Tryout Management System" msgstr "Team Tryout Management System" diff --git a/app/translations/fr/LC_MESSAGES/messages.mo b/app/translations/fr/LC_MESSAGES/messages.mo index a128182f8cf14eb56a8f0a7ef949b99f9f2f5369..b6f33c874c10381718f160a02a4365bbd4caecc2 100644 GIT binary patch delta 10248 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 diff --git a/app/translations/fr/LC_MESSAGES/messages.po b/app/translations/fr/LC_MESSAGES/messages.po index 509c34d..daa8c38 100644 --- a/app/translations/fr/LC_MESSAGES/messages.po +++ b/app/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: team-tryouts VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-08-08 14:58-0400\n" +"POT-Creation-Date: 2026-08-08 15:07-0400\n" "PO-Revision-Date: 2026-08-07 20:22-0400\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -102,50 +102,50 @@ msgstr "Les points ne doivent pas dépasser 2000 caractères." msgid "Day must be 0 (Monday) to 6 (Sunday)." msgstr "Le jour doit aller de 0 (lundi) à 6 (dimanche)." -#: app/routes/auth.py:169 app/routes/auth.py:297 app/routes/users.py:49 -#: app/routes/users.py:653 +#: app/routes/auth.py:186 app/routes/auth.py:314 app/routes/users.py:83 +#: app/routes/users.py:687 #, python-format msgid "%(field)s: %(msg)s" msgstr "%(field)s : %(msg)s" -#: app/routes/auth.py:187 +#: app/routes/auth.py:204 msgid "This account has been deactivated." msgstr "Ce compte a été désactivé." -#: app/routes/auth.py:225 +#: app/routes/auth.py:242 #, python-format msgid "Welcome back, %(username)s!" msgstr "Bon retour, %(username)s !" -#: app/routes/auth.py:246 +#: app/routes/auth.py:263 msgid "" "Login unsuccessful. Please check your username and password, or ask a " "president for help." msgstr "" -"Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, ou " -"demandez de l’aide à un président." +"Échec de la connexion. Vérifiez le nom d’utilisateur et le mot de passe, " +"ou demandez de l’aide à un président." -#: app/routes/auth.py:278 +#: app/routes/auth.py:295 msgid "Incorrect CAPTCHA answer. Please try again." msgstr "Réponse au CAPTCHA incorrecte. Veuillez réessayer." -#: app/routes/auth.py:320 app/routes/users.py:336 +#: app/routes/auth.py:337 app/routes/users.py:370 msgid "Username already exists." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/auth.py:332 app/routes/users.py:340 +#: app/routes/auth.py:349 app/routes/users.py:374 msgid "Email already registered." msgstr "Cette adresse courriel est déjà enregistrée." -#: app/routes/auth.py:378 +#: app/routes/auth.py:395 msgid "Your account has been created! You can now log in." msgstr "Votre compte a été créé. Vous pouvez maintenant vous connecter." -#: app/routes/auth.py:404 +#: app/routes/auth.py:421 msgid "Discord OAuth2 is not configured." msgstr "La connexion Discord n’est pas configurée." -#: app/routes/auth.py:444 +#: app/routes/auth.py:461 msgid "" "Discord authorization could not be verified. Please start the connection " "again from this page." @@ -153,27 +153,27 @@ msgstr "" "L’autorisation Discord n’a pas pu être vérifiée. Relancez la connexion " "depuis cette page." -#: app/routes/auth.py:452 +#: app/routes/auth.py:469 msgid "Discord authorization failed. No code received." msgstr "L’autorisation Discord a échoué : aucun code reçu." -#: app/routes/auth.py:476 +#: app/routes/auth.py:493 msgid "Failed to connect to Discord. Please try again." msgstr "Impossible de joindre Discord. Veuillez réessayer." -#: app/routes/auth.py:480 +#: app/routes/auth.py:497 msgid "Failed to obtain Discord access token." msgstr "Impossible d’obtenir le jeton d’accès Discord." -#: app/routes/auth.py:495 +#: app/routes/auth.py:512 msgid "Failed to fetch Discord user profile." msgstr "Impossible de récupérer le profil Discord." -#: app/routes/auth.py:542 +#: app/routes/auth.py:559 msgid "Discord account connected! Your profile has been pre-filled." msgstr "Compte Discord connecté. Votre profil a été pré-rempli." -#: app/routes/auth.py:565 +#: app/routes/auth.py:587 msgid "You have been logged out." msgstr "Vous avez été déconnecté." @@ -412,7 +412,7 @@ msgstr "Vous n’avez pas les droits pour ajouter des notes à cette équipe." msgid "Team notes added successfully!" msgstr "Notes d’équipe ajoutées." -#: app/routes/teams.py:444 app/routes/users.py:1241 app/routes/users.py:1283 +#: app/routes/teams.py:444 app/routes/users.py:1266 app/routes/users.py:1308 msgid "Can only add notes for players." msgstr "Il n’est possible d’ajouter des notes que pour des joueurs." @@ -530,25 +530,37 @@ msgstr "Vous n’avez pas les droits pour supprimer cette sélection." msgid "Tryout deleted successfully." msgstr "Sélection supprimée." -#: app/routes/users.py:119 +#: app/routes/users.py:61 +msgid "No file selected." +msgstr "Aucun fichier sélectionné." + +#: app/routes/users.py:65 +msgid "Only PDF files are allowed for contracts." +msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." + +#: app/routes/users.py:70 +msgid "That file is not a PDF, whatever its name says." +msgstr "Ce fichier n’est pas un PDF, quel que soit son nom." + +#: app/routes/users.py:153 msgid "Only the president can manage users." msgstr "Seul le président peut gérer les utilisateurs." -#: app/routes/users.py:131 +#: app/routes/users.py:165 msgid "Only the president can edit users." msgstr "Seul le président peut modifier des utilisateurs." -#: app/routes/users.py:168 +#: app/routes/users.py:202 msgid "Email already in use by another account." msgstr "Cette adresse courriel est déjà utilisée par un autre compte." -#: app/routes/users.py:178 +#: app/routes/users.py:212 msgid "You cannot change your own role. Ask another president to do it." msgstr "" "Vous ne pouvez pas modifier votre propre rôle. Demandez à un autre " "président de le faire." -#: app/routes/users.py:189 +#: app/routes/users.py:223 msgid "" "This is the last active president. Promote another account before " "changing this one." @@ -556,183 +568,174 @@ msgstr "" "C’est le dernier président actif. Promouvez un autre compte avant de " "modifier celui-ci." -#: app/routes/users.py:251 +#: app/routes/users.py:285 #, python-format msgid "User %(username)s updated successfully!" msgstr "Utilisateur %(username)s mis à jour." -#: app/routes/users.py:266 +#: app/routes/users.py:300 msgid "Only the president can delete users." msgstr "Seul le président peut supprimer des utilisateurs." -#: app/routes/users.py:270 +#: app/routes/users.py:304 msgid "You cannot delete your own account." msgstr "Vous ne pouvez pas supprimer votre propre compte." -#: app/routes/users.py:307 +#: app/routes/users.py:341 #, python-format msgid "User %(deleted_username)s has been removed." msgstr "L’utilisateur %(deleted_username)s a été supprimé." -#: app/routes/users.py:316 +#: app/routes/users.py:350 msgid "Only the president can create users." msgstr "Seul le président peut créer des utilisateurs." -#: app/routes/users.py:355 +#: app/routes/users.py:389 #, python-format msgid "User %(full_name)s created as %(role)s!" msgstr "Utilisateur %(full_name)s créé avec le rôle %(role)s." -#: app/routes/users.py:412 +#: app/routes/users.py:446 msgid "Username already taken." msgstr "Ce nom d’utilisateur est déjà pris." -#: app/routes/users.py:418 +#: app/routes/users.py:452 msgid "Email already in use." msgstr "Cette adresse courriel est déjà utilisée." -#: app/routes/users.py:443 +#: app/routes/users.py:477 msgid "Profile updated successfully!" msgstr "Profil mis à jour." -#: app/routes/users.py:641 +#: app/routes/users.py:675 msgid "Only presidents, managers, and coaches can upload contracts." msgstr "Seuls les présidents, gérants et coachs peuvent téléverser un contrat." -#: app/routes/users.py:660 +#: app/routes/users.py:694 msgid "You do not have permission to upload a contract for this player." msgstr "Vous n’avez pas les droits pour téléverser un contrat pour ce joueur." -#: app/routes/users.py:664 app/routes/users.py:669 app/routes/users.py:721 -#: app/routes/users.py:726 -msgid "No file selected." -msgstr "Aucun fichier sélectionné." - -#: app/routes/users.py:672 -msgid "Only PDF files are allowed for contracts." -msgstr "Seuls les fichiers PDF sont acceptés pour les contrats." - -#: app/routes/users.py:705 +#: app/routes/users.py:733 #, python-format msgid "Contract uploaded successfully for %(username)s!" msgstr "Contrat téléversé pour %(username)s." -#: app/routes/users.py:717 +#: app/routes/users.py:745 msgid "Only the player can upload their signed contract." msgstr "Seul le joueur peut téléverser son contrat signé." -#: app/routes/users.py:737 +#: app/routes/users.py:762 msgid "Signed contract uploaded successfully!" msgstr "Contrat signé téléversé." -#: app/routes/users.py:747 app/routes/users.py:758 +#: app/routes/users.py:772 app/routes/users.py:783 msgid "You do not have permission to download this contract." msgstr "Vous n’avez pas les droits pour télécharger ce contrat." -#: app/routes/users.py:761 +#: app/routes/users.py:786 msgid "No signed contract available." msgstr "Aucun contrat signé disponible." -#: app/routes/users.py:828 +#: app/routes/users.py:853 msgid "Only players can request One on One sessions." msgstr "Seuls les joueurs peuvent demander une rencontre individuelle." -#: app/routes/users.py:841 +#: app/routes/users.py:866 msgid "You do not have a coach assigned to your team." msgstr "Aucun coach n’est assigné à votre équipe." -#: app/routes/users.py:868 +#: app/routes/users.py:893 msgid "Cannot request One on One - no coach assigned." msgstr "Impossible de demander une rencontre : aucun coach assigné." -#: app/routes/users.py:876 +#: app/routes/users.py:901 msgid "Invalid date or time format." msgstr "Format de date ou d’heure invalide." -#: app/routes/users.py:890 +#: app/routes/users.py:915 msgid "The requested time is not within the coach's availability." msgstr "L’horaire demandé ne correspond à aucune disponibilité du coach." -#: app/routes/users.py:913 +#: app/routes/users.py:938 msgid "Your One on One request has been submitted!" msgstr "Votre demande de rencontre a été envoyée." -#: app/routes/users.py:948 +#: app/routes/users.py:973 msgid "Only coaches can accept One on One requests." msgstr "Seuls les coachs peuvent accepter une demande de rencontre." -#: app/routes/users.py:954 app/routes/users.py:995 +#: app/routes/users.py:979 app/routes/users.py:1020 msgid "This request is not for you." msgstr "Cette demande ne vous est pas destinée." -#: app/routes/users.py:958 app/routes/users.py:999 +#: app/routes/users.py:983 app/routes/users.py:1024 msgid "This request has already been processed." msgstr "Cette demande a déjà été traitée." -#: app/routes/users.py:980 +#: app/routes/users.py:1005 #, python-format msgid "One on One request from %(player)s has been approved!" msgstr "La demande de rencontre de %(player)s a été approuvée." -#: app/routes/users.py:989 +#: app/routes/users.py:1014 msgid "Only coaches can reject One on One requests." msgstr "Seuls les coachs peuvent refuser une demande de rencontre." -#: app/routes/users.py:1026 +#: app/routes/users.py:1051 #, python-format msgid "One on One request from %(player)s has been rejected." msgstr "La demande de rencontre de %(player)s a été refusée." -#: app/routes/users.py:1039 +#: app/routes/users.py:1064 msgid "This page is for players only." msgstr "Cette page est réservée aux joueurs." -#: app/routes/users.py:1070 +#: app/routes/users.py:1095 msgid "Only coaches can manage availability." msgstr "Seuls les coachs peuvent gérer leurs disponibilités." -#: app/routes/users.py:1132 +#: app/routes/users.py:1157 msgid "Only coaches can access the notes dashboard." msgstr "Seuls les coachs ont accès au tableau des notes." -#: app/routes/users.py:1196 +#: app/routes/users.py:1221 msgid "Only coaches can manage team notes." msgstr "Seuls les coachs peuvent gérer les notes d’équipe." -#: app/routes/users.py:1202 +#: app/routes/users.py:1227 msgid "You are not assigned to a team." msgstr "Vous n’êtes assigné à aucune équipe." -#: app/routes/users.py:1215 +#: app/routes/users.py:1240 msgid "Team notes saved successfully!" msgstr "Notes d’équipe enregistrées." -#: app/routes/users.py:1229 +#: app/routes/users.py:1254 msgid "Only coaches can manage personal notes." msgstr "Seuls les coachs peuvent gérer les notes personnelles." -#: app/routes/users.py:1236 app/routes/users.py:1278 app/routes/users.py:1328 -#: app/routes/users.py:1379 +#: app/routes/users.py:1261 app/routes/users.py:1303 app/routes/users.py:1353 +#: app/routes/users.py:1404 msgid "Player and content are required." msgstr "Le joueur et le contenu sont obligatoires." -#: app/routes/users.py:1245 app/routes/users.py:1287 app/routes/users.py:1332 -#: app/routes/users.py:1383 +#: app/routes/users.py:1270 app/routes/users.py:1312 app/routes/users.py:1357 +#: app/routes/users.py:1408 msgid "You can only write notes about players you work with." msgstr "" "Vous ne pouvez écrire des notes que sur les joueurs avec qui vous " "travaillez." -#: app/routes/users.py:1255 app/routes/users.py:1300 +#: app/routes/users.py:1280 app/routes/users.py:1325 #, python-format msgid "Note added for %(username)s." msgstr "Note ajoutée pour %(username)s." -#: app/routes/users.py:1268 app/routes/users.py:1313 app/routes/users.py:1363 +#: app/routes/users.py:1293 app/routes/users.py:1338 app/routes/users.py:1388 msgid "Only coaches can add personal notes." msgstr "Seuls les coachs peuvent ajouter des notes personnelles." -#: app/routes/users.py:1343 app/routes/users.py:1394 +#: app/routes/users.py:1368 app/routes/users.py:1419 msgid "Note added successfully." msgstr "Note ajoutée." @@ -851,7 +854,7 @@ msgstr "Réessayer" msgid "Language" msgstr "Langue" -#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:133 +#: app/templates/layouts/base.html:33 app/templates/layouts/base.html:139 #: app/templates/pages/dashboard.html:2 app/templates/pages/dashboard.html:3 msgid "Dashboard" msgstr "Tableau de bord" @@ -910,19 +913,19 @@ msgstr "Contrats" msgid "My Profile" msgstr "Mon profil" -#: app/templates/layouts/base.html:117 +#: app/templates/layouts/base.html:122 msgid "Logout" msgstr "Déconnexion" -#: app/templates/layouts/base.html:137 +#: app/templates/layouts/base.html:143 msgid "Toggle dark mode" msgstr "Basculer le mode sombre" -#: app/templates/layouts/base.html:149 app/templates/layouts/base.html:168 +#: app/templates/layouts/base.html:155 app/templates/layouts/base.html:174 msgid "Dismiss" msgstr "Fermer" -#: app/templates/layouts/base.html:178 +#: app/templates/layouts/base.html:184 msgid "Team Tryout Management System" msgstr "Système de gestion des sélections d’équipe" diff --git a/tests/test_contract_uploads.py b/tests/test_contract_uploads.py new file mode 100644 index 0000000..fde96f5 --- /dev/null +++ b/tests/test_contract_uploads.py @@ -0,0 +1,146 @@ +"""What may be written to the contracts directory — SEC-021. + +upload_signed_contract accepted any file with a non-empty name. +ALLOWED_SIGNED_EXTENSIONS was declared next to it and never read. The file +landed on disk under a name the application later hands back through +download_signed_contract, so whatever a player uploaded is what a manager +opens. + +The name alone was never enough either: `payload.pdf` says nothing about +the bytes. Both routes now check the signature as well. +""" + +import io +import os + +import pytest + +from app.extensions import db +from app.models import Contract + +PDF_BYTES = b'%PDF-1.7\n1 0 obj\n<<>>\nendobj\ntrailer\n%%EOF\n' +NOT_PDF_BYTES = b'MZ\x90\x00\x03\x00\x00\x00' # a Windows executable header + + +@pytest.fixture +def contract_for(app, tmp_path): + """A contract row whose file lives in a throwaway directory.""" + + def _make(player_id, uploader_id): + stored = 'deadbeef.pdf' + path = tmp_path / stored + path.write_bytes(PDF_BYTES) + with app.app_context(): + contract = Contract( + player_id=player_id, uploaded_by_id=uploader_id, + original_filename='contract.pdf', stored_filename=stored, + file_path=str(path), + ) + db.session.add(contract) + db.session.commit() + return contract.id, tmp_path + + return _make + + +class TestSignedUpload: + def test_an_executable_named_pdf_is_refused( + self, app, client, as_role, make_user, contract_for + ): + player_id = as_role('player') + admin_id = make_user('admin') + contract_id, directory = contract_for(player_id, admin_id) + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(NOT_PDF_BYTES), 'signed.pdf')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + contract = db.session.get(Contract, contract_id) + assert contract.status != 'signed' + assert contract.signed_file_path is None + assert not os.path.exists(directory / 'signed_deadbeef.pdf') + + def test_a_foreign_extension_is_refused( + self, app, client, as_role, make_user, contract_for + ): + player_id = as_role('player') + admin_id = make_user('admin') + contract_id, _directory = contract_for(player_id, admin_id) + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(b''), 'shell.php')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + assert db.session.get(Contract, contract_id).status != 'signed' + + def test_a_real_pdf_still_goes_through( + self, app, client, as_role, make_user, contract_for + ): + """Guard against over-correcting: signing a contract is the point.""" + player_id = as_role('player') + admin_id = make_user('admin') + contract_id, directory = contract_for(player_id, admin_id) + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + contract = db.session.get(Contract, contract_id) + assert contract.status == 'signed' + assert contract.signed_at is not None + assert os.path.exists(directory / 'signed_deadbeef.pdf') + + def test_another_player_still_cannot_sign_it( + self, app, client, as_role, make_user, contract_for + ): + owner_id = make_user('player') + admin_id = make_user('admin') + contract_id, _directory = contract_for(owner_id, admin_id) + as_role('player') + + client.post( + f'/users/contracts/{contract_id}/upload_signed', + data={'signed_file': (io.BytesIO(PDF_BYTES), 'signed.pdf')}, + content_type='multipart/form-data', follow_redirects=True, + ) + + with app.app_context(): + assert db.session.get(Contract, contract_id).status != 'signed' + + +class TestTheHelper: + def test_it_reports_a_missing_file(self, app): + from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error + + with app.test_request_context('/'): + assert pdf_upload_error(None, ALLOWED_CONTRACT_EXTENSIONS) + + def test_it_leaves_the_stream_readable(self, app): + """The signature check consumes bytes; the caller still has to save + the whole file afterwards.""" + from werkzeug.datastructures import FileStorage + + from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error + + upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='c.pdf') + with app.test_request_context('/'): + assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS) is None + assert upload.stream.read() == PDF_BYTES + + def test_a_name_without_a_dot_is_refused(self, app): + from werkzeug.datastructures import FileStorage + + from app.routes.users import ALLOWED_CONTRACT_EXTENSIONS, pdf_upload_error + + upload = FileStorage(stream=io.BytesIO(PDF_BYTES), filename='pdf') + with app.test_request_context('/'): + assert pdf_upload_error(upload, ALLOWED_CONTRACT_EXTENSIONS)