From 74f4c17d13ae09e2c962c8b8e996eae9088b1896 Mon Sep 17 00:00:00 2001 From: Brian Martin Date: Fri, 12 Jul 2024 16:25:50 -0400 Subject: [PATCH] Registration issues (#612) * update devcontainer mysql-apt-config update mysql-apt-config version to latest (0.8.32), the previous (0.8.16) was no longer building properly * fix email pattern * fix for motto pattern needed to double-escape * simplify motto pattern This allows basically anything, as our users wanted emojis, and various punctuation allowed for their mottos. Have tested against a number of XSS and malicious inputs, without any ill effects so far.... ex `` * consolidate avatar saving and validation consolidate the avatar saving code in XSSImageCheck - Box, Team, User all had their own implementations, combined these into one `save_avatar` - new `avatar_validation` function Registration: - check avatar validation as part of form_validation function. Previously if a user provided a bad image the user/team would be created but then it would fail at avatar creation/saving....leaving them in a bad state and unable to play --- .devcontainer/Dockerfile | 4 +-- handlers/PublicHandlers.py | 11 ++++-- libs/XSSImageCheck.py | 50 ++++++++++++++++++++++++++ models/Box.py | 38 ++++++-------------- models/Team.py | 42 +++------------------- models/User.py | 41 +++------------------ static/js/pages/public/registration.js | 2 +- templates/public/registration.html | 2 +- 8 files changed, 83 insertions(+), 107 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e8c3dc63..8128e6da 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.8 -ADD [ "https://dev.mysql.com/get/mysql-apt-config_0.8.16-1_all.deb", "/" ] +ADD [ "https://dev.mysql.com/get/mysql-apt-config_0.8.32-1_all.deb", "/" ] RUN apt-get -qq update \ && export DEBIAN_FRONTEND=noninteractive \ @@ -12,7 +12,7 @@ RUN apt-get -qq update \ zlib1g-dev \ python3-pycurl \ # MySQL Tools - && dpkg -i /mysql-apt-config_0.8.16-1_all.deb \ + && dpkg -i /mysql-apt-config_0.8.32-1_all.deb \ && apt-get -qq update \ && apt-get -qq install -y --no-install-recommends \ mysql-shell \ diff --git a/handlers/PublicHandlers.py b/handlers/PublicHandlers.py index f0f50527..a6ec7995 100644 --- a/handlers/PublicHandlers.py +++ b/handlers/PublicHandlers.py @@ -64,7 +64,10 @@ send_user_registered_webhook, send_user_validated_webhook, ) -from libs.XSSImageCheck import filter_avatars +from libs.XSSImageCheck import ( + filter_avatars, + avatar_validation, +) from models import azuread_app from models.EmailToken import EmailToken from models.GameLevel import GameLevel @@ -488,7 +491,7 @@ def form_validation(self): self.get_argument("motto", None) and bool( re.match( - r"^[0-9A-Za-z _\-\.%s]{,32}$" % unicodewd, + r"^[\s\S]{0,32}$", self.get_argument("motto", ""), re.UNICODE, ) @@ -510,7 +513,9 @@ def form_validation(self): raise ValidationError("Passwords do not match") if self.config.use_recaptcha and self.verify_recaptcha() is False: raise ValidationError("Invalid reCAPTCHA") - + if hasattr(self.request, "files") and "avatar" in self.request.files: + avatar_validation(self.request.files["avatar"][0]["body"]) + def verify_recaptcha(self): """Checks recaptcha""" recaptcha_response = self.get_argument("g-recaptcha-response", None) diff --git a/libs/XSSImageCheck.py b/libs/XSSImageCheck.py index 1a750ab8..1ebf0fed 100644 --- a/libs/XSSImageCheck.py +++ b/libs/XSSImageCheck.py @@ -11,12 +11,19 @@ """ +import io import os from random import randint, sample from string import printable +from pathlib import Path +import imghdr +from PIL import Image +from resizeimage import resizeimage from tornado.options import options +from libs.ValidationError import ValidationError + MAX_AVATAR_SIZE = 1024 * 1024 MIN_AVATAR_SIZE = 64 IMG_FORMATS = ["png", "jpeg", "jpg", "gif", "bmp"] @@ -87,3 +94,46 @@ def existing_avatars(dir): if user.avatar is not None: avatars.append(user.avatar) return avatars + + +def avatar_validation(image_data) -> str: + """Avatar validation check + + Returns image extension as str if checks pass + """ + if MIN_AVATAR_SIZE < len(image_data) < MAX_AVATAR_SIZE: + ext = imghdr.what("", h=image_data) + if ext in IMG_FORMATS and not is_xss_image(image_data): + return ext + else: + raise ValidationError( + "Invalid image format, avatar must be: %s" + % (", ".join(IMG_FORMATS)) + ) + + else: + raise ValidationError( + "The image is too large must be %d - %d bytes" + % (MIN_AVATAR_SIZE, MAX_AVATAR_SIZE) + ) + +def save_avatar(path: str, image_data: bytes) -> str: + """ + Save avatar image to path + + Returns image path without avatar_dir + """ + try: + base_path = Path(path) + image_path = os.path.join(options.avatar_dir, base_path) + + if os.path.exists(image_path): + os.unlink(image_path) + + image = Image.open(io.BytesIO(image_data)) + cover = resizeimage.resize_cover(image, [500, 250]) + cover.save(image_path, image.format) + return str(base_path) + + except Exception as e: + raise ValidationError(e) \ No newline at end of file diff --git a/models/Box.py b/models/Box.py index bcdddfc4..b640d964 100644 --- a/models/Box.py +++ b/models/Box.py @@ -22,16 +22,12 @@ import binascii import enum -import imghdr -import io import os import xml.etree.cElementTree as ET from collections import OrderedDict from os import urandom from uuid import uuid4 -from PIL import Image -from resizeimage import resizeimage from sqlalchemy import Column, ForeignKey from sqlalchemy.orm import backref, relationship from sqlalchemy.types import Boolean, Enum, Integer, String, Unicode @@ -39,7 +35,11 @@ from libs.StringCoding import decode, encode from libs.ValidationError import ValidationError -from libs.XSSImageCheck import get_new_avatar, is_xss_image +from libs.XSSImageCheck import ( + get_new_avatar, + avatar_validation, + save_avatar +) from models import dbsession from models.BaseModels import DatabaseObject from models.Category import Category @@ -352,28 +352,12 @@ def avatar(self, image_data): if self.uuid is None: self.uuid = str(uuid4()) - if len(image_data) < (1024 * 1024): - ext = imghdr.what("", h=image_data) - if ext in ["png", "jpeg", "gif", "bmp"] and not is_xss_image(image_data): - try: - if self._avatar is not None: - current_image_path = os.path.join(options.avatar_dir, avatar_path, self._avatar) if avatar_path == "upload" else os.path.join(options.avatar_dir, avatar_path) - if os.path.exists(current_image_path): - os.unlink(current_image_path) - - new_image_path = os.path.join(avatar_path, f"{self.uuid}.{ext}") if avatar_path == "upload" else avatar_path - image = Image.open(io.BytesIO(image_data)) - cover = resizeimage.resize_cover(image, [500, 250]) - cover.save(os.path.join(options.avatar_dir,new_image_path), image.format) - self._avatar = new_image_path - except Exception as e: - raise ValidationError(e) - else: - raise ValidationError( - "Invalid image format, avatar must be: .png .jpeg .gif or .bmp" - ) - else: - raise ValidationError("The image is too large") + + if avatar_path == "upload": + os.path.join("upload", f"{self.uuid}.{ext}") + + ext = avatar_validation(image_data) + self._avatar = save_avatar(avatar_path) @property def ipv4s(self): diff --git a/models/Team.py b/models/Team.py index 2af8548a..0c39b532 100644 --- a/models/Team.py +++ b/models/Team.py @@ -21,8 +21,6 @@ # pylint: disable=no-member -import imghdr -import io import os import xml.etree.cElementTree as ET from builtins import str @@ -30,8 +28,6 @@ from random import randint from uuid import uuid4 -from PIL import Image -from resizeimage import resizeimage from sqlalchemy import Column, desc from sqlalchemy.orm import backref, relationship from sqlalchemy.types import Integer, String, Unicode @@ -41,11 +37,9 @@ from libs.StringCoding import encode from libs.ValidationError import ValidationError from libs.XSSImageCheck import ( - IMG_FORMATS, - MAX_AVATAR_SIZE, - MIN_AVATAR_SIZE, - get_new_avatar, - is_xss_image, + get_new_avatar, + avatar_validation, + save_avatar, ) from models import dbsession from models.BaseModels import DatabaseObject @@ -263,34 +257,8 @@ def avatar(self): @avatar.setter def avatar(self, image_data): - if MIN_AVATAR_SIZE < len(image_data) < MAX_AVATAR_SIZE: - ext = imghdr.what("", h=image_data) - if ext in IMG_FORMATS and not is_xss_image(image_data): - try: - if self._avatar is not None and os.path.exists( - options.avatar_dir + "/upload/" + self._avatar - ): - os.unlink(options.avatar_dir + "/upload/" + self._avatar) - file_path = str( - options.avatar_dir + "/upload/" + self.uuid + "." + ext - ) - image = Image.open(io.BytesIO(image_data)) - cover = resizeimage.resize_cover(image, [500, 250]) - cover.save(file_path, image.format) - self._avatar = "upload/" + self.uuid + "." + ext - except Exception as e: - raise ValidationError(e) - - else: - raise ValidationError( - "Invalid image format, avatar must be: %s" - % (", ".join(IMG_FORMATS)) - ) - else: - raise ValidationError( - "The image is too large must be %d - %d bytes" - % (MIN_AVATAR_SIZE, MAX_AVATAR_SIZE) - ) + ext = avatar_validation(image_data) + self._avatar = save_avatar(os.path.join("upload", f"{self.uuid}.{ext}"),image_data) @property def levels(self): diff --git a/models/User.py b/models/User.py index 4641587a..5dcb6f2b 100644 --- a/models/User.py +++ b/models/User.py @@ -25,8 +25,6 @@ """ -import imghdr -import io import os import random import string @@ -39,8 +37,6 @@ from past.builtins import basestring from pbkdf2 import PBKDF2 -from PIL import Image -from resizeimage import resizeimage from sqlalchemy import Column, ForeignKey, desc, func from sqlalchemy.orm import backref, relationship, synonym from sqlalchemy.types import Boolean, DateTime, Integer, String, Unicode @@ -50,12 +46,10 @@ from libs.ValidationError import ValidationError from libs.WebhookHelpers import send_user_validated_webhook from libs.XSSImageCheck import ( - IMG_FORMATS, - MAX_AVATAR_SIZE, - MIN_AVATAR_SIZE, + avatar_validation, + save_avatar, default_avatar, - get_new_avatar, - is_xss_image, + get_new_avatar, ) from models import dbsession from models.BaseModels import DatabaseObject @@ -354,33 +348,8 @@ def avatar(self): @avatar.setter def avatar(self, image_data): - if MIN_AVATAR_SIZE < len(image_data) < MAX_AVATAR_SIZE: - ext = imghdr.what("", h=image_data) - if ext in IMG_FORMATS and not is_xss_image(image_data): - try: - if self._avatar is not None and os.path.exists( - options.avatar_dir + "/upload/" + self._avatar - ): - os.unlink(options.avatar_dir + "/upload/" + self._avatar) - file_path = str( - options.avatar_dir + "/upload/" + self.uuid + "." + ext - ) - image = Image.open(io.BytesIO(image_data)) - cover = resizeimage.resize_cover(image, [500, 250]) - cover.save(file_path, image.format) - self._avatar = "upload/" + self.uuid + "." + ext - except Exception as e: - raise ValidationError(e) - else: - raise ValidationError( - "Invalid image format, avatar must be: %s" - % (", ".join(IMG_FORMATS)) - ) - else: - raise ValidationError( - "The image is too large must be %d - %d bytes" - % (MIN_AVATAR_SIZE, MAX_AVATAR_SIZE) - ) + ext = avatar_validation(image_data) + self._avatar = save_avatar(os.path.join("upload", f"{self.uuid}.{ext}"),image_data) def has_item(self, item_name): """Check to see if a team has purchased an item""" diff --git a/static/js/pages/public/registration.js b/static/js/pages/public/registration.js index 45cc8539..1d8eb2ce 100644 --- a/static/js/pages/public/registration.js +++ b/static/js/pages/public/registration.js @@ -66,6 +66,6 @@ $(document).ready(function() { let unicodewd = "ªµºÀ-ÖØ-öø-ˁˆ-ˑˠ-ˤˬˮͰ-ʹͶͷͺ-ͽͿΆΈ-ΊΌΎ-ΡΣ-ϵϷ-ҁҊ-ԯԱ-Ֆՙՠ-ֈא-תׯ-ײؠ-يٮٯٱ-ۓەۥۦۮۯۺ-ۼۿܐܒ-ܯݍ-ޥޱߊ-ߪߴߵߺࠀ-ࠕࠚࠤࠨࡀ-ࡘࡠ-ࡪࡰ-ࢇࢉ-ࢎࢠ-ࣉऄ-हऽॐक़-ॡॱ-ঀঅ-ঌএঐও-নপ-রলশ-হঽৎড়ঢ়য়-ৡৰৱৼਅ-ਊਏਐਓ-ਨਪ-ਰਲਲ਼ਵਸ਼ਸਹਖ਼-ੜਫ਼ੲ-ੴઅ-ઍએ-ઑઓ-નપ-રલળવ-હઽૐૠૡૹଅ-ଌଏଐଓ-ନପ-ରଲଳଵ-ହଽଡ଼ଢ଼ୟ-ୡୱஃஅ-ஊஎ-ஐஒ-கஙசஜஞடணதந-பம-ஹௐఅ-ఌఎ-ఐఒ-నప-హఽౘ-ౚౝౠౡಀಅ-ಌಎ-ಐಒ-ನಪ-ಳವ-ಹಽೝೞೠೡೱೲഄ-ഌഎ-ഐഒ-ഺഽൎൔ-ൖൟ-ൡൺ-ൿඅ-ඖක-නඳ-රලව-ෆก-ะาำเ-ๆກຂຄຆ-ຊຌ-ຣລວ-ະາຳຽເ-ໄໆໜ-ໟༀཀ-ཇཉ-ཬྈ-ྌက-ဪဿၐ-ၕၚ-ၝၡၥၦၮ-ၰၵ-ႁႎႠ-ჅჇჍა-ჺჼ-ቈቊ-ቍቐ-ቖቘቚ-ቝበ-ኈኊ-ኍነ-ኰኲ-ኵኸ-ኾዀዂ-ዅወ-ዖዘ-ጐጒ-ጕጘ-ፚᎀ-ᎏᎠ-Ᏽᏸ-ᏽᐁ-ᙬᙯ-ᙿᚁ-ᚚᚠ-ᛪᛱ-ᛸᜀ-ᜑᜟ-ᜱᝀ-ᝑᝠ-ᝬᝮ-ᝰក-ឳៗៜᠠ-ᡸᢀ-ᢄᢇ-ᢨᢪᢰ-ᣵᤀ-ᤞᥐ-ᥭᥰ-ᥴᦀ-ᦫᦰ-ᧉᨀ-ᨖᨠ-ᩔᪧᬅ-ᬳᭅ-ᭌᮃ-ᮠᮮᮯᮺ-ᯥᰀ-ᰣᱍ-ᱏᱚ-ᱽᲀ-ᲈᲐ-ᲺᲽ-Ჿᳩ-ᳬᳮ-ᳳᳵᳶᳺᴀ-ᶿḀ-ἕἘ-Ἕἠ-ὅὈ-Ὅὐ-ὗὙὛὝὟ-ώᾀ-ᾴᾶ-ᾼιῂ-ῄῆ-ῌῐ-ΐῖ-Ίῠ-Ῥῲ-ῴῶ-ῼⁱⁿₐ-ₜℂℇℊ-ℓℕℙ-ℝℤΩℨK-ℭℯ-ℹℼ-ℿⅅ-ⅉⅎↃↄⰀ-ⳤⳫ-ⳮⳲⳳⴀ-ⴥⴧⴭⴰ-ⵧⵯⶀ-ⶖⶠ-ⶦⶨ-ⶮⶰ-ⶶⶸ-ⶾⷀ-ⷆⷈ-ⷎⷐ-ⷖⷘ-ⷞⸯ々〆〱-〵〻〼ぁ-ゖゝ-ゟァ-ヺー-ヿㄅ-ㄯㄱ-ㆎㆠ-ㆿㇰ-ㇿ㐀-䶿一-ꒌꓐ-ꓽꔀ-ꘌꘐ-ꘟꘪꘫꙀ-ꙮꙿ-ꚝꚠ-ꛥꜗ-ꜟꜢ-ꞈꞋ-ꟊꟐꟑꟓꟕ-ꟙꟲ-ꠁꠃ-ꠅꠇ-ꠊꠌ-ꠢꡀ-ꡳꢂ-ꢳꣲ-ꣷꣻꣽꣾꤊ-ꤥꤰ-ꥆꥠ-ꥼꦄ-ꦲꧏꧠ-ꧤꧦ-ꧯꧺ-ꧾꨀ-ꨨꩀ-ꩂꩄ-ꩋꩠ-ꩶꩺꩾ-ꪯꪱꪵꪶꪹ-ꪽꫀꫂꫛ-ꫝꫠ-ꫪꫲ-ꫴꬁ-ꬆꬉ-ꬎꬑ-ꬖꬠ-ꬦꬨ-ꬮꬰ-ꭚꭜ-ꭩꭰ-ꯢ가-힣ힰ-ퟆퟋ-ퟻ豈-舘並-龎ff-stﬓ-ﬗיִײַ-ﬨשׁ-זּטּ-לּמּנּסּףּפּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-ﷻﹰ-ﹴﹶ-ﻼA-Za-zヲ-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ"; $("#playername").attr("pattern", "^[0-9A-Za-z " + unicodewd + "]{3,64}$"); - $("#motto").attr("pattern", "^[0-9A-Za-z _\-\." + unicodewd + "]{,32}$"); + $("#motto").attr("pattern", "^[\\s\\S]{0,32}$"); }); \ No newline at end of file diff --git a/templates/public/registration.html b/templates/public/registration.html index 0453fe62..f5e48e92 100644 --- a/templates/public/registration.html +++ b/templates/public/registration.html @@ -136,7 +136,7 @@

{{ _("ERROR") }}