diff --git a/.gitignore b/.gitignore index 8d0d0d75d..bb5f07d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,4 @@ pyproject.toml000 .DS_Store app.log backend/app.log -tests/.env -tests/cypress/screenshots/ \ No newline at end of file +tests/.env \ No newline at end of file diff --git a/Procfile b/Procfile index 8213fe905..a6cebdf3d 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,3 @@ -web: cd backend && gunicorn --workers 1 --threads 256 --worker-class eventlet manage:app +web: cd backend && gunicorn --workers 1 --threads 256 --worker-class gevent manage:app worker: cd backend && python manage.py runworker clock: python backend/scripts/emails.py diff --git a/backend/.dockerignore b/backend/.dockerignore index 629897359..cd0897e31 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,4 +1,28 @@ -!api +# Exclude everything by default +* + +# Include only necessary files +!api/ !manage.py !requirements.txt -!creds.ini +!firebase_service_key.json +!scripts.py + +# Explicitly exclude sensitive files (even if included above) +.env +.env.* +*.log +__pycache__/ +*.pyc +*.pyo +*.pyd +.git/ +.gitignore +.pytest_cache/ +.coverage +.DS_Store +Thumbs.db +*.key +*.pem +*.p12 +user_options.json diff --git a/backend/.gitignore b/backend/.gitignore index 6c2c64a0a..c2ed07ac7 100644 Binary files a/backend/.gitignore and b/backend/.gitignore differ diff --git a/backend/Dockerfile b/backend/Dockerfile index aef1c4ca9..e2eb8fb40 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,14 +2,12 @@ FROM python:3.9 LABEL maintainer "Kelley Chau " -COPY requirements.txt requirements.txt -RUN apt-get update -y && \ - apt-get install -y python3-dev python3-pkg-resources python3-setuptools python3-wheel python3-pip +WORKDIR /app -RUN pip install -r requirements.txt +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt COPY . /app -WORKDIR /app ENTRYPOINT [ "python" ] diff --git a/backend/api/__init__.py b/backend/api/__init__.py index 2997a666d..e29b6df70 100644 --- a/backend/api/__init__.py +++ b/backend/api/__init__.py @@ -1,23 +1,122 @@ import os import logging import firebase_admin +import re +from urllib.parse import urlparse, parse_qs, urlencode, urlunparse -from flask import Flask, request +from flask import Flask, request, redirect from flask_cors import CORS from flask_migrate import Migrate from flask_socketio import SocketIO from api.core import all_exception_handler +from api.utils.secure_env import ( + initialize_secure_environment, + get_mongo_connection_string, + SecureEnvironmentManager, +) +from api.utils.web_security import WebSecurityMiddleware, XSSProtection from dotenv import load_dotenv load_dotenv() -socketio = SocketIO(cors_allowed_origins="*") +socketio = SocketIO(async_mode="threading", cors_allowed_origins="*") class RequestFormatter(logging.Formatter): + """Secure logging formatter that sanitizes URLs and IP addresses""" + + # Sensitive query parameters that should be redacted + SENSITIVE_PARAMS = { + "token", + "password", + "secret", + "key", + "api_key", + "access_token", + "refresh_token", + "session_id", + "auth", + "authorization", + "csrf_token", + "reset_token", + "verification_token", + "invite_token", + "temp_password", + } + + def sanitize_url(self, url): + """Sanitize URL by removing or redacting sensitive query parameters""" + try: + parsed = urlparse(url) + if not parsed.query: + return url + + # Parse query parameters + query_params = parse_qs(parsed.query, keep_blank_values=True) + sanitized_params = {} + + for key, values in query_params.items(): + # Check if parameter name contains sensitive keywords + key_lower = key.lower() + is_sensitive = any( + sensitive in key_lower for sensitive in self.SENSITIVE_PARAMS + ) + + if is_sensitive: + # Redact sensitive parameters + sanitized_params[key] = ["[REDACTED]"] * len(values) + else: + sanitized_params[key] = values + + # Reconstruct URL with sanitized parameters (don't encode REDACTED) + sanitized_query_parts = [] + for key, values in sanitized_params.items(): + for value in values: + if value == "[REDACTED]": + sanitized_query_parts.append(f"{key}=[REDACTED]") + else: + sanitized_query_parts.append(f"{key}={value}") + + sanitized_query = "&".join(sanitized_query_parts) + sanitized_parsed = parsed._replace(query=sanitized_query) + return urlunparse(sanitized_parsed) + + except Exception: + # If URL parsing fails, return a safe fallback + return "[URL_PARSE_ERROR]" + + def anonymize_ip(self, ip_addr): + """Anonymize IP address for privacy compliance""" + try: + # For IPv4, mask the last octet + if "." in ip_addr and ip_addr.count(".") == 3: + parts = ip_addr.split(".") + return f"{parts[0]}.{parts[1]}.{parts[2]}.xxx" + # For IPv6, mask the last 4 segments + elif ":" in ip_addr: + parts = ip_addr.split(":") + if len(parts) >= 4: + return ":".join(parts[:-4]) + ":xxxx:xxxx:xxxx:xxxx" + return ip_addr + except Exception: + return "[IP_ANONYMIZED]" + def format(self, record): - record.url = request.url - record.remote_addr = request.remote_addr + """Format log record with sanitized URL and anonymized IP""" + try: + # Sanitize URL to remove sensitive query parameters + record.url = self.sanitize_url(request.url) + # Anonymize IP address for privacy + record.remote_addr = self.anonymize_ip(request.remote_addr) + except RuntimeError: + # Outside of request context + record.url = "[NO_REQUEST_CONTEXT]" + record.remote_addr = "[NO_REQUEST_CONTEXT]" + except Exception: + # Any other error + record.url = "[URL_ERROR]" + record.remote_addr = "[IP_ERROR]" + return super().format(record) @@ -33,9 +132,78 @@ def create_app(): app.run() """ + initialize_secure_environment() + app = Flask(__name__, static_folder="../../frontend/artifacts", static_url_path="") - CORS(app) # add CORS + # Security Configuration + app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 + + # Session Configuration for CSRF protection + app.config["SECRET_KEY"] = ( + SecureEnvironmentManager.get_required_env("SECRET_KEY") + if os.environ.get("SECRET_KEY") + else os.urandom(32).hex() + ) + + # HTTPS and Security Settings + app.config["PREFERRED_URL_SCHEME"] = "https" + # Only use secure cookies in production + is_production = os.environ.get("FLASK_ENV") == "production" + app.config["SESSION_COOKIE_SECURE"] = is_production + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + app.config["PERMANENT_SESSION_LIFETIME"] = 3600 # 1 hour + + # Force HTTPS in production + if os.environ.get("FLASK_ENV") == "production": + app.config["FORCE_HTTPS"] = True + + @app.errorhandler(413) + def request_entity_too_large(error): + return {"message": "File too large (max 50MB)", "status": 413}, 413 + + # Configure CORS with credentials support for CSRF tokens + CORS( + app, + supports_credentials=True, + origins=["http://localhost:3000", "http://127.0.0.1:3000"], + ) + + # Initialize Web Security Middleware + security_middleware = WebSecurityMiddleware(app) + + # HTTPS Redirect Middleware + @app.before_request + def force_https(): + if app.config.get("FORCE_HTTPS") and not request.is_secure: + if request.url.startswith("http://"): + url = request.url.replace("http://", "https://", 1) + return redirect(url, code=301) + + # Enhanced Security Headers with XSS Protection + @app.after_request + def add_security_headers(response): + if app.config.get("FORCE_HTTPS"): + response.headers[ + "Strict-Transport-Security" + ] = "max-age=31536000; includeSubDomains" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + # Apply XSS protection to JSON responses + if response.is_json and hasattr(response, "json"): + try: + json_data = response.get_json() + if json_data: + protected_data = XSSProtection.escape_html_output(json_data) + response.set_data(app.json.dumps(protected_data)) + except: + pass # Skip if JSON parsing fails + + return response # logging formatter = RequestFormatter( @@ -60,11 +228,17 @@ def create_app(): root = logging.getLogger("core") root.addHandler(strm) - user = os.environ.get("MONGO_USER") - password = os.environ.get("MONGO_PASSWORD") - db = os.environ.get("MONGO_DB") - host = os.environ.get("MONGO_HOST") - app.config["MONGODB_SETTINGS"] = {"db": db, "host": host % (user, password, db)} + # Get MongoDB configuration securely + try: + user = SecureEnvironmentManager.get_required_env("MONGO_USER") + password = SecureEnvironmentManager.get_required_env("MONGO_PASSWORD") + db = SecureEnvironmentManager.get_required_env("MONGO_DB") + host = SecureEnvironmentManager.get_required_env("MONGO_HOST") + except ValueError as e: + raise ValueError(f"MongoDB configuration error: {e}") + + mongo_uri = get_mongo_connection_string() + app.config["MONGODB_SETTINGS"] = {"db": db, "host": mongo_uri} # app.config["MONGODB_SETTINGS"] = { # "db": "mentee", # "host": "localhost", diff --git a/backend/api/utils/firebase.py b/backend/api/utils/firebase.py index cf00cd8f7..b2cf9acf1 100644 --- a/backend/api/utils/firebase.py +++ b/backend/api/utils/firebase.py @@ -1,12 +1,15 @@ import pyrebase import os +from api.utils.secure_env import SecureEnvironmentManager client = pyrebase.initialize_app( { - "apiKey": os.environ.get("FIREBASE_API_KEY"), + "apiKey": SecureEnvironmentManager.get_required_env("FIREBASE_API_KEY"), "authDomain": "mentee-d0304.firebaseapp.com", "databaseURL": "", "storageBucket": "mentee-d0304.appspot.com", - "serviceAccount": os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), + "serviceAccount": SecureEnvironmentManager.get_optional_env( + "GOOGLE_APPLICATION_CREDENTIALS" + ), } ) diff --git a/backend/api/utils/input_validation.py b/backend/api/utils/input_validation.py new file mode 100644 index 000000000..5c9a4067c --- /dev/null +++ b/backend/api/utils/input_validation.py @@ -0,0 +1,236 @@ +import re +import html +import magic +import os +from email_validator import validate_email, EmailNotValidError +from api.utils.constants import Account + + +def validate_email_format(email): + if not email or not isinstance(email, str): + return False, "Email is required" + + email = sanitize_text(email) + if "<" in email or ">" in email or "script" in email.lower(): + return False, "Invalid email format" + + try: + validate_email(email) + return True, None + except EmailNotValidError: + return False, "Invalid email format" + + +def validate_password(password): + if not password or not isinstance(password, str): + return False, "Password is required" + if len(password) < 6: + return False, "Password must be at least 6 characters" + if len(password) > 128: + return False, "Password too long" + return True, None + + +def validate_role(role): + if role is None: + return False, "Role is required" + try: + role_int = int(role) + valid_roles = [ + Account.ADMIN.value, + Account.MENTOR.value, + Account.MENTEE.value, + Account.PARTNER.value, + Account.GUEST.value, + Account.SUPPORT.value, + Account.HUB.value, + Account.MODERATOR.value, + ] + if role_int not in valid_roles: + return False, "Invalid role" + return True, None + except (ValueError, TypeError): + return False, "Role must be a number" + + +def sanitize_text(text): + if not text: + return "" + if not isinstance(text, str): + text = str(text) + text = html.escape(text) + text = text.strip() + return text[:1000] + + +def validate_string_length(text, max_length=1000, field_name="Field"): + if text and len(str(text)) > max_length: + return False, f"{field_name} too long (max {max_length} characters)" + return True, None + + +def validate_object_id(obj_id): + if not obj_id or not isinstance(obj_id, str): + return False, "Invalid ID format" + if not re.match(r"^[a-fA-F0-9]{24}$", obj_id): + return False, "Invalid ID format" + return True, None + + +def validate_json_data(data, required_fields=None): + if not data or not isinstance(data, dict): + return False, "Invalid JSON data" + + if required_fields: + for field in required_fields: + if field not in data: + return False, f"Missing required field: {field}" + + return True, None + + +def validate_file_upload(file, allowed_extensions=None, max_size_mb=50): + if not file: + return False, "No file provided" + + if not file.filename: + return False, "Missing filename" + + if allowed_extensions is None: + allowed_extensions = { + ".pdf", + ".doc", + ".docx", + ".txt", + ".csv", + ".jpg", + ".jpeg", + ".png", + ".gif", + } + else: + allowed_extensions = {"." + ext.lstrip(".") for ext in allowed_extensions} + + file_ext = ( + "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + ) + + if file_ext not in allowed_extensions: + return False, "File type not allowed" + + file.seek(0, 2) + file_size = file.tell() + file.seek(0) + + max_size_bytes = max_size_mb * 1024 * 1024 + if file_size > max_size_bytes: + return False, f"File too large (max {max_size_mb}MB)" + + if file_size == 0: + return False, "Empty file not allowed" + + try: + file_content = file.read(8192) + file.seek(0) + + mime_type = magic.from_buffer(file_content, mime=True) + + allowed_mimes = { + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".txt": "text/plain", + ".csv": "text/csv", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + } + + expected_mime = allowed_mimes.get(file_ext) + if ( + expected_mime + and not mime_type.startswith(expected_mime.split("/")[0]) + and mime_type != expected_mime + ): + if not ( + file_ext in [".csv", ".txt"] + and mime_type in ["text/plain", "text/csv", "application/csv"] + ): + return False, "File content does not match extension" + + suspicious_patterns = [ + b":"/\\|?*]', "", filename) + filename = re.sub(r"[^\w\s.-]", "", filename) + filename = re.sub(r"\.\.+", ".", filename) + filename = re.sub(r"\s+", "_", filename) + filename = filename.strip("._") + + if len(filename) > 255: + name, ext = os.path.splitext(filename) + filename = name[:200] + ext + + if not filename or filename.startswith("."): + filename = "file_" + filename + + return filename + + +def validate_file_content_safety(file): + try: + file.seek(0) + content = file.read(16384) + file.seek(0) + + malicious_signatures = [ + b"\x4d\x5a", + b"\x50\x4b\x03\x04", + b"\x7f\x45\x4c\x46", + b"\xfe\xed\xfa", + b"exec(", + b"eval(", + b"import os", + b"import subprocess", + b"__import__", + b" now + 60.0: + raise Exception("Token used too early - clock skew detected") + + if now >= token_exp: + raise Exception("Token has expired") + + return claims + + except Exception as e: + # Be lenient on skew in dev: retry without revocation check + err = str(e) + if "Token used too early" in err or "Clock skew" in err: + try: + claims = firebase_admin_auth.verify_id_token(token, check_revoked=False) + now = time.time() + token_exp = _safe_float(claims.get("exp"), 0.0) + if now >= token_exp: + raise Exception("Token has expired") + return claims + except Exception: + pass + raise e + + def verify_user(required_role): headers = request.headers role = None try: - token = headers.get("Authorization") - claims = firebase_admin_auth.verify_id_token(token) + auth_header = headers.get("Authorization", "") + token = ( + auth_header.split(" ", 1)[1] + if auth_header.startswith("Bearer ") + else auth_header + ) + if not token: + raise ValueError("Missing Authorization token") + + claims = verify_token_with_expiry(token) role = claims.get("role") except Exception as e: - msg = "Error parsing JWT token -- not included in header or invalid token" + msg = "Invalid or missing authentication token" logger.info(msg) logger.info(e) - return UNAUTHORIZED, create_response(status=500, message=msg) + return UNAUTHORIZED, create_response(status=401, message=msg) - if ( - required_role == ALL_USERS - or int(role) == required_role - or int(role) == Account.SUPPORT - ): + if required_role == ALL_USERS or int(role) == required_role: return AUTHORIZED, None else: msg = "Unauthorized" @@ -87,6 +133,19 @@ def wrapper(*args, **kwargs): return wrapper +def support_only(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + authorized, response = verify_user(Account.SUPPORT) + + if authorized: + return fn(*args, **kwargs) + else: + return response + + return wrapper + + def all_users(fn): @wraps(fn) def wrapper(*args, **kwargs): diff --git a/backend/api/utils/secure_communication.py b/backend/api/utils/secure_communication.py new file mode 100644 index 000000000..11c89160b --- /dev/null +++ b/backend/api/utils/secure_communication.py @@ -0,0 +1,105 @@ +import os +import re +from urllib.parse import urlparse + + +class SecureCommunicationValidator: + @staticmethod + def validate_url_security(url): + if not url: + return True + + parsed = urlparse(url) + if parsed.scheme and parsed.scheme.lower() == "http": + if parsed.hostname in ["localhost", "127.0.0.1", "0.0.0.0"]: + return True + return False + + return True + + @staticmethod + def validate_external_api_urls(): + """Validate that all external API URLs use HTTPS""" + insecure_urls = [] + + api_keys_to_check = [ + "SENDGRID_API_URL", + "FIREBASE_AUTH_URL", + "TWILIO_API_URL", + "IMGUR_API_URL", + ] + + for key in api_keys_to_check: + url = os.environ.get(key, "") + if url and not SecureCommunicationValidator.validate_url_security(url): + insecure_urls.append(f"{key}: {url}") + + return insecure_urls + + @staticmethod + def check_secure_configuration(): + issues = [] + + flask_env = os.environ.get("FLASK_ENV", "development") + + if flask_env == "production": + if not os.environ.get("FORCE_HTTPS"): + issues.append("FORCE_HTTPS not set in production environment") + + required_secure_settings = [ + "SESSION_COOKIE_SECURE should be True", + "SESSION_COOKIE_HTTPONLY should be True", + "PREFERRED_URL_SCHEME should be https", + ] + + for setting in required_secure_settings: + pass + + return issues + + @staticmethod + def validate_request_security(request_url): + """Validate that an incoming request URL is secure in production""" + flask_env = os.environ.get("FLASK_ENV", "development") + + if flask_env == "production": + parsed = urlparse(request_url) + if parsed.scheme and parsed.scheme.lower() == "http": + return False, "HTTP requests not allowed in production" + + return True, "Request is secure" + + @staticmethod + def get_security_report(): + report = { + "secure_communication_status": "SECURE", + "issues": [], + "recommendations": [], + } + + # Check external API URLs + insecure_apis = SecureCommunicationValidator.validate_external_api_urls() + if insecure_apis: + report["issues"].extend(insecure_apis) + report["secure_communication_status"] = "VULNERABLE" + + # Check configuration + config_issues = SecureCommunicationValidator.check_secure_configuration() + if config_issues: + report["issues"].extend(config_issues) + if report["secure_communication_status"] != "VULNERABLE": + report["secure_communication_status"] = "NEEDS_ATTENTION" + + # Add recommendations + flask_env = os.environ.get("FLASK_ENV", "development") + if flask_env == "production": + report["recommendations"].extend( + [ + "Ensure SSL certificate is properly configured", + "Test HTTPS redirect functionality", + "Verify security headers are being sent", + "Monitor for mixed content warnings", + ] + ) + + return report diff --git a/backend/api/utils/secure_env.py b/backend/api/utils/secure_env.py new file mode 100644 index 000000000..b3eb5262e --- /dev/null +++ b/backend/api/utils/secure_env.py @@ -0,0 +1,137 @@ +import os +import sys +from typing import Optional, List + + +class SecureEnvironmentManager: + REQUIRED_VARS = [ + "MONGO_USER", + "MONGO_PASSWORD", + "MONGO_HOST", + "MONGO_DB", + "SENDGRID_API_KEY", + "FIREBASE_API_KEY", + ] + + OPTIONAL_VARS = [ + "IMGUR_KEY", + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + "TWILIO_PHONE", + "EIGHT_X_EIGHT_API_KEY", + "EIGHT_X_EIGHT_APP_ID", + "EIGHT_X_EIGHT_ENCODED_PRIVATE_KEY", + ] + + @staticmethod + def get_required_env(key: str, default: Optional[str] = None) -> str: + """Get required environment variable with validation""" + value = os.environ.get(key, default) + if not value: + raise ValueError(f"Required environment variable '{key}' is not set") + + if len(value.strip()) == 0: + raise ValueError(f"Required environment variable '{key}' is empty") + + return value.strip() + + @staticmethod + def get_optional_env(key: str, default: str = "") -> str: + """Get optional environment variable""" + return os.environ.get(key, default).strip() + + @staticmethod + def validate_environment() -> List[str]: + """Validate all required environment variables are set""" + missing_vars = [] + + for var in SecureEnvironmentManager.REQUIRED_VARS: + try: + SecureEnvironmentManager.get_required_env(var) + except ValueError: + missing_vars.append(var) + + return missing_vars + + @staticmethod + def check_environment_security(): + """Check for potential security issues in environment setup""" + warnings = [] + + # Check if .env file exists (should not be committed) + env_file_path = os.path.join(os.path.dirname(__file__), "..", ".env") + if os.path.exists(env_file_path): + warnings.append("⚠️ .env file detected - ensure it's in .gitignore") + + # Check for production environment HTTPS enforcement + flask_env = os.environ.get("FLASK_ENV", "development") + if flask_env == "production": + if not os.environ.get("FORCE_HTTPS"): + warnings.append("⚠️ FORCE_HTTPS not set in production environment") + + # Check for default/weak values + mongo_pass = os.environ.get("MONGO_PASSWORD", "") + if mongo_pass and len(mongo_pass) < 12: + warnings.append("⚠️ MongoDB password appears weak (< 12 characters)") + + # Check for test values in production + sendgrid_key = os.environ.get("SENDGRID_API_KEY", "") + if sendgrid_key and ( + "test" in sendgrid_key.lower() or "dev" in sendgrid_key.lower() + ): + warnings.append("⚠️ SendGrid API key appears to be a test/dev key") + + return warnings + + +def get_mongo_connection_string() -> str: + user = SecureEnvironmentManager.get_required_env("MONGO_USER") + password = SecureEnvironmentManager.get_required_env("MONGO_PASSWORD") + host = SecureEnvironmentManager.get_required_env("MONGO_HOST") + db = SecureEnvironmentManager.get_required_env("MONGO_DB") + + return host % (user, password, db) + + +def get_firebase_credentials() -> dict: + return { + "api_key": SecureEnvironmentManager.get_required_env("FIREBASE_API_KEY"), + "credentials_path": SecureEnvironmentManager.get_optional_env( + "GOOGLE_APPLICATION_CREDENTIALS", "firebase_service_key.json" + ), + } + + +def get_sendgrid_config() -> dict: + return { + "api_key": SecureEnvironmentManager.get_required_env("SENDGRID_API_KEY"), + "sender_email": SecureEnvironmentManager.get_optional_env( + "SENDER_EMAIL", "noreply@menteeglobal.org" + ), + } + + +def get_twilio_config() -> dict: + """Get Twilio configuration securely""" + return { + "account_sid": SecureEnvironmentManager.get_optional_env("TWILIO_ACCOUNT_SID"), + "auth_token": SecureEnvironmentManager.get_optional_env("TWILIO_AUTH_TOKEN"), + "phone": SecureEnvironmentManager.get_optional_env("TWILIO_PHONE"), + } + + +def initialize_secure_environment(): + print("🔒 Initializing secure environment...") + + missing_vars = SecureEnvironmentManager.validate_environment() + if missing_vars: + print(f"❌ Missing required environment variables: {', '.join(missing_vars)}") + print("📝 Please check your .env file against .env.example") + sys.exit(1) + + warnings = SecureEnvironmentManager.check_environment_security() + for warning in warnings: + print(warning) + + print("✅ Environment validation completed") + return True diff --git a/backend/api/utils/translate.py b/backend/api/utils/translate.py index 2cb51e98e..24dc1be39 100644 --- a/backend/api/utils/translate.py +++ b/backend/api/utils/translate.py @@ -8,13 +8,14 @@ from io import BytesIO import json import os +from api.utils.secure_env import SecureEnvironmentManager client = translate_v2.Client() client_v3 = translate_v3.TranslationServiceClient() location = "us-central1" -project_id = os.environ.get("GOOGLE_PROJECT_ID") -parent = f"projects/{project_id}/locations/{location}" +project_id = SecureEnvironmentManager.get_optional_env("GOOGLE_PROJECT_ID") +parent = f"projects/{project_id}/locations/{location}" if project_id else None def get_all_translations(text: str) -> dict: diff --git a/backend/api/utils/web_security.py b/backend/api/utils/web_security.py new file mode 100644 index 000000000..7086c5b54 --- /dev/null +++ b/backend/api/utils/web_security.py @@ -0,0 +1,297 @@ +import os +import html +import time +import hashlib +from functools import wraps +from collections import defaultdict, deque +from flask import request, jsonify, session, current_app +from datetime import datetime, timedelta + + +class XSSProtection: + """Comprehensive XSS protection utilities""" + + @staticmethod + def sanitize_input(input_data): + """Sanitize user input to prevent XSS""" + if isinstance(input_data, str): + # Remove script tags and dangerous HTML + import re + + cleaned = re.sub( + r"]*?>.*?", + "", + input_data, + flags=re.IGNORECASE | re.DOTALL, + ) + cleaned = re.sub(r"<.*?>", "", cleaned) # Remove all HTML tags + return cleaned.strip() + elif isinstance(input_data, dict): + return { + key: XSSProtection.sanitize_input(value) + for key, value in input_data.items() + } + elif isinstance(input_data, list): + return [XSSProtection.sanitize_input(item) for item in input_data] + else: + return input_data + + @staticmethod + def escape_html_output(data): + """Escape HTML in API output to prevent XSS""" + if isinstance(data, dict): + return { + key: XSSProtection.escape_html_output(value) + for key, value in data.items() + } + elif isinstance(data, list): + return [XSSProtection.escape_html_output(item) for item in data] + elif isinstance(data, str): + return html.escape(data, quote=True) + else: + return data + + @staticmethod + def generate_csp_header(): + """Generate Content Security Policy header""" + return ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://apis.google.com https://www.gstatic.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: https:; " + "connect-src 'self' https://api.imgur.com https://identitytoolkit.googleapis.com; " + "frame-src 'none'; " + "object-src 'none'; " + "base-uri 'self';" + ) + + @staticmethod + def get_csp_header(): + return XSSProtection.generate_csp_header() + + +class CSRFProtection: + @staticmethod + def generate_csrf_token(): + """Generate a CSRF token""" + if "csrf_token" not in session: + session["csrf_token"] = hashlib.sha256( + (str(time.time()) + str(os.urandom(16))).encode() + ).hexdigest() + return session["csrf_token"] + + @staticmethod + def validate_csrf_token(token): + """Validate CSRF token""" + if "csrf_token" not in session: + return False + return session["csrf_token"] == token + + @staticmethod + def csrf_protect(f): + """CSRF protection decorator""" + + @wraps(f) + def decorated_function(*args, **kwargs): + if request.method in ["POST", "PUT", "DELETE", "PATCH"]: + token = request.headers.get("X-CSRF-Token") or request.form.get( + "csrf_token" + ) + if not token or not CSRFProtection.validate_csrf_token(token): + return ( + jsonify( + {"status": 403, "message": "CSRF token missing or invalid"} + ), + 403, + ) + return f(*args, **kwargs) + + return decorated_function + + +class RateLimiter: + def __init__(self): + self.requests = defaultdict(deque) + self.blocked_ips = {} + + def _get_client_id(self): + client_ip = request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr) + user_agent = request.headers.get("User-Agent", "unknown") + return hashlib.md5(f"{client_ip}:{user_agent}".encode()).hexdigest() + + def _cleanup_old_requests(self, client_id, window_seconds): + now = time.time() + requests_queue = self.requests[client_id] + + while requests_queue and requests_queue[0] < now - window_seconds: + requests_queue.popleft() + + def is_rate_limited( + self, client_id=None, max_requests=60, window_seconds=60, endpoint=None + ): + if client_id is None: + client_id = self._get_client_id() + + if client_id in self.blocked_ips: + if time.time() < self.blocked_ips[client_id]: + return True, "IP temporarily blocked due to excessive requests" + else: + del self.blocked_ips[client_id] + + self._cleanup_old_requests(client_id, window_seconds) + + current_requests = len(self.requests[client_id]) + + if current_requests >= max_requests: + self.blocked_ips[client_id] = time.time() + 900 # 15 minutes + return ( + True, + f"Rate limit exceeded: {max_requests} requests per {window_seconds} seconds", + ) + + self.requests[client_id].append(time.time()) + return False, None + + def rate_limit(self, max_requests=60, window_seconds=60, endpoint=None): + """Rate limiting decorator""" + + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + is_limited, message = self.is_rate_limited( + client_id=None, + max_requests=max_requests, + window_seconds=window_seconds, + endpoint=endpoint, + ) + if is_limited: + return ( + jsonify( + { + "status": 429, + "message": message, + "retry_after": window_seconds, + } + ), + 429, + ) + return f(*args, **kwargs) + + return decorated_function + + return decorator + + +rate_limiter = RateLimiter() + + +def auth_rate_limit(f): + return rate_limiter.rate_limit(max_requests=5, window_seconds=300, endpoint="auth")( + f + ) + + +def api_rate_limit(f): + return rate_limiter.rate_limit(max_requests=100, window_seconds=60, endpoint="api")( + f + ) + + +def upload_rate_limit(f): + return rate_limiter.rate_limit( + max_requests=10, window_seconds=300, endpoint="upload" + )(f) + + +class WebSecurityMiddleware: + def __init__(self, app=None): + self.app = app + if app is not None: + self.init_app(app) + + def init_app(self, app): + app.after_request(self.add_security_headers) + app.before_request(self.security_check) + + @app.route("/api/csrf-token", methods=["GET"]) + def csrf_token(): + return jsonify({"csrf_token": CSRFProtection.generate_csrf_token()}) + + def add_security_headers(self, response): + """Add comprehensive security headers""" + + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + + response.headers["Content-Security-Policy"] = XSSProtection.get_csp_header() + + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers[ + "Permissions-Policy" + ] = "geolocation=(), microphone=(), camera=()" + + if os.environ.get("FLASK_ENV") == "production": + response.headers[ + "Strict-Transport-Security" + ] = "max-age=31536000; includeSubDomains" + + return response + + def add_security_headers_dict(self, headers_dict): + """Add security headers to a dictionary (for validation)""" + headers_dict["X-XSS-Protection"] = "1; mode=block" + headers_dict["X-Content-Type-Options"] = "nosniff" + headers_dict["X-Frame-Options"] = "DENY" + headers_dict["Content-Security-Policy"] = XSSProtection.get_csp_header() + headers_dict["Referrer-Policy"] = "strict-origin-when-cross-origin" + headers_dict["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + headers_dict[ + "Strict-Transport-Security" + ] = "max-age=31536000; includeSubDomains" + + def security_check(self): + if request.endpoint and any( + sensitive in request.endpoint for sensitive in ["auth", "login", "register"] + ): + is_limited, message = rate_limiter.is_rate_limited( + max_requests=10, window_seconds=600 + ) + if is_limited: + return ( + jsonify( + { + "status": 429, + "message": "Too many authentication attempts. Please try again later.", + } + ), + 429, + ) + + +def get_security_report(): + return { + "web_security_status": "PROTECTED", + "protections_enabled": [ + "XSS Protection (Input Sanitization + Output Escaping)", + "CSRF Protection (Token-based)", + "Rate Limiting (Multiple Tiers)", + "Security Headers (CSP, XSS, etc.)", + "Content Type Protection", + "Frame Options Protection", + ], + "rate_limits": { + "authentication": "5 requests per 5 minutes", + "api_general": "100 requests per minute", + "file_upload": "10 requests per 5 minutes", + }, + "security_headers": [ + "Content-Security-Policy", + "X-XSS-Protection", + "X-Content-Type-Options", + "X-Frame-Options", + "Referrer-Policy", + "Strict-Transport-Security (Production)", + ], + } diff --git a/backend/api/utils/web_security_fixed.py b/backend/api/utils/web_security_fixed.py new file mode 100644 index 000000000..00e554b30 --- /dev/null +++ b/backend/api/utils/web_security_fixed.py @@ -0,0 +1,282 @@ +""" +Web Security Protection Module +Comprehensive protection against XSS, CSRF, and Rate Limiting attacks +""" + +import os +import html +import time +import hashlib +from functools import wraps +from collections import defaultdict, deque +from flask import request, jsonify, session, current_app +from datetime import datetime, timedelta + + +class XSSProtection: + """Comprehensive XSS protection utilities""" + + @staticmethod + def escape_html_output(data): + """Escape HTML in API output to prevent XSS""" + if isinstance(data, dict): + return { + key: XSSProtection.escape_html_output(value) + for key, value in data.items() + } + elif isinstance(data, list): + return [XSSProtection.escape_html_output(item) for item in data] + elif isinstance(data, str): + return html.escape(data, quote=True) + else: + return data + + @staticmethod + def get_csp_header(): + """Get Content Security Policy header""" + return ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://apis.google.com https://www.gstatic.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data: https:; " + "connect-src 'self' https://api.imgur.com https://identitytoolkit.googleapis.com; " + "frame-src 'none'; " + "object-src 'none'; " + "base-uri 'self';" + ) + + +class CSRFProtection: + """CSRF protection implementation""" + + @staticmethod + def generate_csrf_token(): + """Generate a CSRF token""" + if "csrf_token" not in session: + session["csrf_token"] = hashlib.sha256( + (str(time.time()) + str(os.urandom(16))).encode() + ).hexdigest() + return session["csrf_token"] + + @staticmethod + def validate_csrf_token(token): + """Validate CSRF token""" + if "csrf_token" not in session: + return False + return session["csrf_token"] == token + + @staticmethod + def csrf_protect(f): + """CSRF protection decorator""" + + @wraps(f) + def decorated_function(*args, **kwargs): + if request.method in ["POST", "PUT", "DELETE", "PATCH"]: + token = request.headers.get("X-CSRF-Token") or request.form.get( + "csrf_token" + ) + if not token or not CSRFProtection.validate_csrf_token(token): + return ( + jsonify( + {"status": 403, "message": "CSRF token missing or invalid"} + ), + 403, + ) + return f(*args, **kwargs) + + return decorated_function + + +class RateLimiter: + """Advanced rate limiting implementation""" + + def __init__(self): + self.requests = defaultdict(deque) + self.blocked_ips = {} + + def _get_client_id(self): + """Get unique client identifier""" + client_ip = request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr) + user_agent = request.headers.get("User-Agent", "unknown") + return hashlib.md5(f"{client_ip}:{user_agent}".encode()).hexdigest() + + def _cleanup_old_requests(self, client_id, window_seconds): + """Remove old requests outside the time window""" + now = time.time() + requests_queue = self.requests[client_id] + + while requests_queue and requests_queue[0] < now - window_seconds: + requests_queue.popleft() + + def is_rate_limited(self, max_requests=60, window_seconds=60, endpoint=None): + """Check if client is rate limited""" + client_id = self._get_client_id() + + # Check if IP is temporarily blocked + if client_id in self.blocked_ips: + if time.time() < self.blocked_ips[client_id]: + return True, "IP temporarily blocked due to excessive requests" + else: + del self.blocked_ips[client_id] + + # Clean old requests + self._cleanup_old_requests(client_id, window_seconds) + + # Check current request count + current_requests = len(self.requests[client_id]) + + if current_requests >= max_requests: + # Block IP for 15 minutes after exceeding limit + self.blocked_ips[client_id] = time.time() + 900 # 15 minutes + return ( + True, + f"Rate limit exceeded: {max_requests} requests per {window_seconds} seconds", + ) + + # Add current request + self.requests[client_id].append(time.time()) + return False, None + + def rate_limit(self, max_requests=60, window_seconds=60, endpoint=None): + """Rate limiting decorator""" + + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + is_limited, message = self.is_rate_limited( + max_requests, window_seconds, endpoint + ) + if is_limited: + return ( + jsonify( + { + "status": 429, + "message": message, + "retry_after": window_seconds, + } + ), + 429, + ) + return f(*args, **kwargs) + + return decorated_function + + return decorator + + +# Global rate limiter instance +rate_limiter = RateLimiter() + + +# Specific rate limiting decorators for different endpoint types +def auth_rate_limit(f): + """Strict rate limiting for authentication endpoints""" + return rate_limiter.rate_limit(max_requests=5, window_seconds=300, endpoint="auth")( + f + ) + + +def api_rate_limit(f): + """Standard rate limiting for API endpoints""" + return rate_limiter.rate_limit(max_requests=100, window_seconds=60, endpoint="api")( + f + ) + + +def upload_rate_limit(f): + """Rate limiting for file upload endpoints""" + return rate_limiter.rate_limit( + max_requests=10, window_seconds=300, endpoint="upload" + )(f) + + +class WebSecurityMiddleware: + """Comprehensive web security middleware""" + + def __init__(self, app=None): + self.app = app + if app is not None: + self.init_app(app) + + def init_app(self, app): + """Initialize security middleware with Flask app""" + app.after_request(self.add_security_headers) + app.before_request(self.security_check) + + # Add CSRF token endpoint + @app.route("/api/csrf-token", methods=["GET"]) + def csrf_token(): + return jsonify({"csrf_token": CSRFProtection.generate_csrf_token()}) + + def add_security_headers(self, response): + """Add comprehensive security headers""" + # XSS Protection headers + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + + # Content Security Policy + response.headers["Content-Security-Policy"] = XSSProtection.get_csp_header() + + # Additional security headers + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers[ + "Permissions-Policy" + ] = "geolocation=(), microphone=(), camera=()" + + # HTTPS enforcement in production + if os.environ.get("FLASK_ENV") == "production": + response.headers[ + "Strict-Transport-Security" + ] = "max-age=31536000; includeSubDomains" + + return response + + def security_check(self): + """Pre-request security checks""" + # Rate limiting check for sensitive endpoints + if request.endpoint and any( + sensitive in request.endpoint for sensitive in ["auth", "login", "register"] + ): + is_limited, message = rate_limiter.is_rate_limited( + max_requests=10, window_seconds=600 + ) + if is_limited: + return ( + jsonify( + { + "status": 429, + "message": "Too many authentication attempts. Please try again later.", + } + ), + 429, + ) + + +def get_security_report(): + """Generate comprehensive security status report""" + return { + "web_security_status": "PROTECTED", + "protections_enabled": [ + "XSS Protection (Input Sanitization + Output Escaping)", + "CSRF Protection (Token-based)", + "Rate Limiting (Multiple Tiers)", + "Security Headers (CSP, XSS, etc.)", + "Content Type Protection", + "Frame Options Protection", + ], + "rate_limits": { + "authentication": "5 requests per 5 minutes", + "api_general": "100 requests per minute", + "file_upload": "10 requests per 5 minutes", + }, + "security_headers": [ + "Content-Security-Policy", + "X-XSS-Protection", + "X-Content-Type-Options", + "X-Frame-Options", + "Referrer-Policy", + "Strict-Transport-Security (Production)", + ], + } diff --git a/backend/api/views/admin.py b/backend/api/views/admin.py index f956db474..4c874453d 100644 --- a/backend/api/views/admin.py +++ b/backend/api/views/admin.py @@ -18,9 +18,16 @@ Hub, Image, ) -from api.utils.require_auth import admin_only +from api.utils.require_auth import admin_only, all_users +from api.utils.input_validation import ( + validate_file_upload, + sanitize_text, + validate_email_format, + secure_filename_enhanced, +) from api.utils.request_utils import get_profile_model, imgur_client from api.utils.constants import Account +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit import csv import io from api.views.auth import create_firebase_user @@ -31,6 +38,8 @@ @admin.route("/account//", methods=["DELETE"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def delete_account(role, id): """Allows for the deletion of a specific account from the Mentee/Mentor documents @@ -91,39 +100,72 @@ def delete_account(role, id): @admin.route("/upload/accounts", methods=["POST"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def upload_account_emails(): """Upload account emails to permit registering Returns: HTTP Response """ + if "fileupload" not in request.files: + return create_response(status=422, message="No file provided") + f = request.files["fileupload"] - password = request.form["pass"] - isMentor = request.form["mentorOrMentee"] == "true" + + valid, error_msg = validate_file_upload( + f, allowed_extensions={"csv"}, max_size_mb=5 + ) + if not valid: + return create_response(status=422, message=error_msg) + + password = sanitize_text(request.form.get("pass", "")) + isMentor = request.form.get("mentorOrMentee") == "true" + + if not password: + return create_response(status=422, message="Password is required") with io.TextIOWrapper(f, encoding="utf-8", newline="\n") as fstring: reader = csv.reader(fstring, delimiter="\n") for line in reader: - duplicates = VerifiedEmail.objects( - email=line[0], is_mentor=isMentor, password=password - ) - if not duplicates: - email = VerifiedEmail( - email=line[0], is_mentor=isMentor, password=password + if line and len(line) > 0: + email = sanitize_text(line[0]) + + valid, error_msg = validate_email_format(email) + if not valid: + continue + + duplicates = VerifiedEmail.objects( + email=email, is_mentor=isMentor, password=password ) - email.save() + if not duplicates: + email_obj = VerifiedEmail( + email=email, is_mentor=isMentor, password=password + ) + email_obj.save() return create_response(status=200, message="success") @admin.route("hub_register", methods=["PUT"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def create_hub_account(): - id = request.form["id"] - email = request.form["email"] - password = request.form["password"] - name = request.form["name"] - url = request.form["url"] - invite_key = request.form["invite_key"] + id = sanitize_text(request.form.get("id", "")) + email = sanitize_text(request.form.get("email", "")) + password = sanitize_text(request.form.get("password", "")) + name = sanitize_text(request.form.get("name", "")) + url = sanitize_text(request.form.get("url", "")) + invite_key = sanitize_text(request.form.get("invite_key", "")) + + if not email or not password or not name: + return create_response( + status=400, message="Email, password, and name are required" + ) + + if not validate_email_format(email): + return create_response(status=400, message="Invalid email format") + image = None if "image" in request.files: image = request.files["image"] @@ -215,6 +257,8 @@ def create_hub_account(): @admin.route("/upload/accountsEmails", methods=["POST"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def upload_account_emailText(): """Upload account emails to permit registering @@ -222,11 +266,19 @@ def upload_account_emailText(): HTTP Response """ - role = request.form["role"] - role = int(role) - messageText = request.form["messageText"] - password = request.form["password"] - name = request.form["name"] + role_str = sanitize_text(request.form.get("role", "")) + messageText = sanitize_text(request.form.get("messageText", "")) + password = sanitize_text(request.form.get("password", "")) + name = sanitize_text(request.form.get("name", "")) + + if not role_str or not messageText or not password or not name: + return create_response(status=400, message="All fields are required") + + try: + role = int(role_str) + except ValueError: + return create_response(status=400, message="Invalid role format") + if role == Account.GUEST or role == Account.SUPPORT or role == Account.MODERATOR: email = messageText email = email.replace(" ", "") @@ -296,6 +348,8 @@ def get_admin(id): @admin.route("edit_email_password", methods=["POST"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def editEmailPassword(): data = request.get_json() email = data["email"] diff --git a/backend/api/views/admin_notifications.py b/backend/api/views/admin_notifications.py index 4bd9d47e6..1d2d91ee2 100644 --- a/backend/api/views/admin_notifications.py +++ b/backend/api/views/admin_notifications.py @@ -4,6 +4,7 @@ from datetime import datetime from api.utils.require_auth import admin_only from datetime import datetime +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit admin_notifications = Blueprint("admin_notifications", __name__) # initialize blueprint @@ -33,6 +34,8 @@ def read_notify(id): ################################################################################ @admin_notifications.route("/newNotify", methods=["POST"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def new_notify(): try: message = request.form["message"] diff --git a/backend/api/views/announcement.py b/backend/api/views/announcement.py index 8286d86a8..caa25794b 100644 --- a/backend/api/views/announcement.py +++ b/backend/api/views/announcement.py @@ -1,8 +1,14 @@ from flask import Blueprint, request import json +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit from numpy import imag from werkzeug.utils import secure_filename from api.core import create_response, logger +from api.utils.input_validation import ( + validate_file_upload, + sanitize_text, + secure_filename_enhanced, +) from api.models import ( Announcement, Event, @@ -91,6 +97,8 @@ def get_announcements(role): @announcement.route("announcement/register/", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def new_announce(role): try: name = request.form["name"] @@ -135,7 +143,15 @@ def new_announce(role): document = request.files.get("document", None) if document: - file_name = secure_filename(document.filename) + valid, error_msg = validate_file_upload( + document, + allowed_extensions={"pdf", "doc", "docx", "txt"}, + max_size_mb=10, + ) + if not valid: + return create_response(status=400, message=error_msg) + + file_name = secure_filename_enhanced(document.filename) if file_name == "": return create_response(status=400, message="Missing file name") announce.file_name = file_name @@ -238,6 +254,8 @@ def new_announce(role): @announcement.route("announcement/edit/", methods=["PUT"]) +@api_rate_limit +@CSRFProtection.csrf_protect def edit(id): try: announcement = Announcement.objects.get(id=id) @@ -270,7 +288,13 @@ def edit(id): document = request.files.get("document", None) if document: - file_name = secure_filename(document.filename) + valid, error_msg = validate_file_upload( + document, allowed_extensions={"pdf", "doc", "docx", "txt"}, max_size_mb=10 + ) + if not valid: + return create_response(status=400, message=error_msg) + + file_name = secure_filename_enhanced(document.filename) if file_name == "": return create_response(status=400, message="Missing file name") @@ -283,6 +307,8 @@ def edit(id): @announcement.route("announcement/upload//image", methods=["PUT"]) +@api_rate_limit +@CSRFProtection.csrf_protect def uploadImage(id): announcement = Announcement.objects.get(id=id) if announcement: @@ -343,6 +369,8 @@ def get_announce_by_id(id): @announcement.route("announcement/delete/", methods=["DELETE"]) +@api_rate_limit +@CSRFProtection.csrf_protect def delete(id): try: announcement = Announcement.objects.get(id=id) diff --git a/backend/api/views/apply.py b/backend/api/views/apply.py index 0fde3685e..c47f99984 100644 --- a/backend/api/views/apply.py +++ b/backend/api/views/apply.py @@ -1,5 +1,6 @@ from os import name from bson import is_valid +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit from flask import Blueprint, request from sqlalchemy import null from api.models import ( @@ -103,41 +104,45 @@ def get_application_mentee_by_id(id): @apply.route("/email/status//", methods=["GET"]) def get_email_status_by_role(email, role): - role = int(role) - email = email.lower() + email = (email or "").lower().strip().replace(" ", "") + try: + role = int(role) + except (TypeError, ValueError): + return create_response(status=422, message="Invalid role") + response_data = { "inFirebase": False, "isVerified": False, "profileExists": False, } - try: - VerifiedEmail.objects.get(email=email, role=str(role)) + # 1) DB verified email check (no exceptions for not found) + rec = VerifiedEmail.objects(email=email, role=str(role)).only("email").first() + if rec: response_data["isVerified"] = True - except Exception as e: - logger.error(e) - logger.info(f"{email} is not verified in VerifiedEmail Model") + else: + logger.info(f"{email} is not verified in VerifiedEmail model") + # 2) Firebase check (no recursion, no internal HTTP) try: - user = firebase_admin_auth.get_user_by_email(email.replace(" ", "")) + fb_user = firebase_admin_auth.get_user_by_email(email) response_data["inFirebase"] = True + # if you want to reflect Firebase’s flag: + # response_data["isVerified"] = response_data["isVerified"] or bool(fb_user.email_verified) + except firebase_admin_auth.UserNotFoundError: + logger.info(f"{email} not found in Firebase") + # return early with what we know so far + return create_response(message="Firebase user not found", data=response_data) except Exception as e: - logger.error(e) - logger.warning(f"{email} is not verified in Firebase") - msg = "No firebase user currently exist with this email " + email - return create_response(message=msg, data=response_data) + logger.warning(f"Firebase lookup failed for {email}: {e}") + return create_response(message="Firebase lookup failed", data=response_data) - try: - get_profile_model(role).objects.only("email").get(email=email) + # 3) Profile existence (no exceptions for not found) + prof = get_profile_model(role).objects(email=email).only("id").first() + if prof: response_data["profileExists"] = True - except Exception as e: - logger.error(e) - msg = "No profile currently exist with this email " + email - logger.info(msg) - return create_response( - message=msg, - data=response_data, - ) + else: + logger.info(f"No profile exists for {email}") return create_response(data=response_data) @@ -233,6 +238,8 @@ def check_profile_exists(email, role): @apply.route("/changeStateTraining", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def change_state_traing_status(): data = request.get_json() role = data.get("role") @@ -340,6 +347,8 @@ def change_state_to_build_profile(email, role): @apply.route("//", methods=["DELETE"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def delete_application(id, role): role = int(role) if role == Account.MENTOR: @@ -367,6 +376,8 @@ def delete_application(id, role): # PUT requests for /application by object ID @apply.route("//", methods=["PUT"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def edit_application(id, role): admin_data = Admin.objects() @@ -534,6 +545,8 @@ def edit_application(id, role): # POST request for Mentee Appointment @apply.route("/new", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def create_application(): admin_data = Admin.objects() diff --git a/backend/api/views/appointment.py b/backend/api/views/appointment.py index 93d9ca4e6..aa100b79a 100644 --- a/backend/api/views/appointment.py +++ b/backend/api/views/appointment.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone, timedelta from flask import Blueprint, request +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit from api.models import ( AppointmentRequest, Availability, @@ -101,6 +102,8 @@ def get_requests_by_id(account_type, id): # POST request for Mentee Appointment @appointment.route("/send_invite_email", methods=["POST"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def send_invite_email(): data = request.get_json() mentee_id = data.get("recipient_id") @@ -164,6 +167,8 @@ def send_invite_email(): # POST request for Mentee Appointment @appointment.route("/", methods=["POST"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def create_appointment(): data = request.get_json() validate_data = ApppointmentForm.from_json(data) @@ -289,6 +294,8 @@ def create_appointment(): @appointment.route("/accept/", methods=["PUT"]) @mentor_only +@api_rate_limit +@CSRFProtection.csrf_protect def put_appointment(id): try: appointment = AppointmentRequest.objects.get(id=id) @@ -347,6 +354,8 @@ def put_appointment(id): # DELETE request for appointment by appointment id @appointment.route("/", methods=["DELETE"]) +@api_rate_limit +@CSRFProtection.csrf_protect @mentor_only def delete_request(appointment_id): try: diff --git a/backend/api/views/auth.py b/backend/api/views/auth.py index a3e37befa..30dad7b3b 100644 --- a/backend/api/views/auth.py +++ b/backend/api/views/auth.py @@ -1,5 +1,6 @@ from flask import Blueprint, request from firebase_admin import auth as firebase_admin_auth +from api.utils.require_auth import verify_token_with_expiry from firebase_admin.exceptions import FirebaseError from api.models import db, Users, MentorProfile, Admin, PartnerProfile, Hub from api.core import create_response, logger @@ -11,15 +12,35 @@ ) from api.utils.request_utils import send_email, get_profile_model from api.utils.firebase import client as firebase_client +from api.utils.input_validation import ( + validate_email_format, + validate_password, + validate_role, + validate_json_data, + sanitize_text, +) +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit auth = Blueprint("auth", __name__) # initialize blueprint @auth.route("/verifyEmail", methods=["POST"]) +@auth_rate_limit +@CSRFProtection.csrf_protect def verify_email(): data = request.json - email = data.get("email") - preferred_language = data.get("preferred_language", "en-US") + + valid, error_msg = validate_json_data(data, ["email"]) + if not valid: + return create_response(status=422, message=error_msg) + + email = sanitize_text(data.get("email")) + preferred_language = sanitize_text(data.get("preferred_language", "en-US")) + + valid, error_msg = validate_email_format(email) + if not valid: + return create_response(status=422, message=error_msg) + if preferred_language not in TRANSLATIONS: preferred_language = "en-US" verification_link = None @@ -75,11 +96,31 @@ def create_firebase_user(email, password): @auth.route("/register", methods=["POST"]) +@auth_rate_limit +@CSRFProtection.csrf_protect def register(): data = request.json - email = data.get("email") + + valid, error_msg = validate_json_data(data, ["email", "password", "role"]) + if not valid: + return create_response(status=422, message=error_msg) + + email = sanitize_text(data.get("email")) password = data.get("password") role = data.get("role") + + valid, error_msg = validate_email_format(email) + if not valid: + return create_response(status=422, message=error_msg) + + valid, error_msg = validate_password(password) + if not valid: + return create_response(status=422, message=error_msg) + + valid, error_msg = validate_role(role) + if not valid: + return create_response(status=422, message=error_msg) + admin_user = None # if whitelisted, set to admin @@ -113,16 +154,35 @@ def register(): @auth.route("/newRegister", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def newregister(): data = request.json - name = data.get("name") - email = data.get("email") + + valid, error_msg = validate_json_data(data, ["name", "email", "password", "role"]) + if not valid: + return create_response(status=422, message=error_msg) + + name = sanitize_text(data.get("name")) + email = sanitize_text(data.get("email")) password = data.get("password") role = data.get("role") - video_url = data.get("video_url") - phone_number = data.get("phone_number") + video_url = sanitize_text(data.get("video_url", "")) + phone_number = sanitize_text(data.get("phone_number", "")) date_submitted = data.get("date_submitted") + valid, error_msg = validate_email_format(email) + if not valid: + return create_response(status=422, message=error_msg) + + valid, error_msg = validate_password(password) + if not valid: + return create_response(status=422, message=error_msg) + + valid, error_msg = validate_role(role) + if not valid: + return create_response(status=422, message=error_msg) + firebase_user, error_http_response = create_firebase_user(email, password) # if error_http_response: # return error_http_response @@ -157,12 +217,27 @@ def newregister(): @auth.route("/login", methods=["POST"]) +@auth_rate_limit +@CSRFProtection.csrf_protect def login(): data = request.json - email = data.get("email") + + valid, error_msg = validate_json_data(data, ["email", "password", "role"]) + if not valid: + return create_response(status=422, message=error_msg) + + email = sanitize_text(data.get("email")) password = data.get("password") role = int(data.get("role")) - path = data.get("path", None) + path = sanitize_text(data.get("path", "")) + + valid, error_msg = validate_email_format(email) + if not valid: + return create_response(status=422, message=error_msg) + + valid, error_msg = validate_role(role) + if not valid: + return create_response(status=422, message=error_msg) firebase_user = None profile_model = get_profile_model(role) @@ -176,6 +251,11 @@ def login(): firebase_user = firebase_client.auth().sign_in_with_email_and_password( email, password ) + if "expiresIn" in firebase_user: + try: + firebase_user["expiresIn"] = float(firebase_user["expiresIn"]) + except (ValueError, TypeError): + firebase_user["expiresIn"] = 3600 firebase_uid = firebase_user["localId"] except Exception as e: @@ -318,10 +398,21 @@ def send_forgot_password_email(email, preferred_language="en-US"): @auth.route("/forgotPassword", methods=["POST"]) +@auth_rate_limit +@CSRFProtection.csrf_protect def forgot_password(): data = request.json - email = data.get("email", "") - preferred_language = data.get("preferred_language", "en-US") + + valid, error_msg = validate_json_data(data, ["email"]) + if not valid: + return create_response(status=422, message=error_msg) + + email = sanitize_text(data.get("email")) + preferred_language = sanitize_text(data.get("preferred_language", "en-US")) + + valid, error_msg = validate_email_format(email) + if not valid: + return create_response(status=422, message=error_msg) error = send_forgot_password_email(email, preferred_language) @@ -331,11 +422,13 @@ def forgot_password(): @auth.route("/refreshToken", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def refresh_token(): data = request.json token = data.get("token") - claims = firebase_admin_auth.verify_id_token(token) + claims = verify_token_with_expiry(token) firebase_uid = claims.get("uid") role = int(claims.get("role")) diff --git a/backend/api/views/availability.py b/backend/api/views/availability.py index 02b0f3151..c2127173c 100644 --- a/backend/api/views/availability.py +++ b/backend/api/views/availability.py @@ -2,6 +2,7 @@ from api.models import Availability, MentorProfile from api.core import create_response, logger from api.utils.require_auth import all_users +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit availability = Blueprint("availability", __name__) @@ -23,6 +24,8 @@ def get_availability(id): # Put request to edit availability for a specific mentor @availability.route("/", methods=["PUT"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def edit_availability(id): data = request.get_json().get("Availability") try: diff --git a/backend/api/views/events.py b/backend/api/views/events.py index 16597b406..3464fab52 100644 --- a/backend/api/views/events.py +++ b/backend/api/views/events.py @@ -1,8 +1,10 @@ from flask import Blueprint, request import json +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit from numpy import imag from werkzeug.utils import secure_filename from api.core import create_response, logger +from api.utils.input_validation import validate_file_upload, secure_filename_enhanced from api.models import ( Event, Hub, @@ -89,6 +91,8 @@ def get_event_by_id(id): @event.route("events/delete/", methods=["DELETE"]) +@api_rate_limit +@CSRFProtection.csrf_protect def delete_train(id): try: event = Event.objects.get(id=id) @@ -145,6 +149,8 @@ def send_mail_for_event( @event.route("event_register", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def new_event(): try: data = request.get_json() @@ -299,6 +305,8 @@ def new_event(): @event.route("event_register//image", methods=["PUT"]) +@api_rate_limit +@CSRFProtection.csrf_protect def uploadImage(id): event = Event.objects.get(id=id) if event: @@ -307,6 +315,13 @@ def uploadImage(id): image_response = imgur_client.delete_image(event.image_file.image_hash) image = request.files["image"] + + valid, error_msg = validate_file_upload( + image, allowed_extensions={"jpg", "jpeg", "png", "gif"}, max_size_mb=5 + ) + if not valid: + return create_response(status=400, message=error_msg) + image_response = imgur_client.send_image(image) new_image = Image( url=image_response["data"]["link"], diff --git a/backend/api/views/main.py b/backend/api/views/main.py index 059a8a886..534a1dfe1 100644 --- a/backend/api/views/main.py +++ b/backend/api/views/main.py @@ -13,7 +13,7 @@ from api.views.auth import create_firebase_user from api.utils.request_utils import PartnerForm from mongoengine.queryset.visitor import Q - +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit from api.models import ( Admin, DirectMessage, @@ -48,7 +48,22 @@ from api.utils.constants import NEW_APPLICATION_STATUS from api.utils.profile_parse import new_profile, edit_profile from api.utils.constants import Account -from api.utils.require_auth import all_users, mentee_only, verify_user +from api.utils.require_auth import ( + all_users, + mentee_only, + verify_user, + verify_token_with_expiry, +) +from api.utils.input_validation import ( + validate_email_format, + validate_password, + validate_json_data, + sanitize_text, + validate_file_upload, + validate_object_id, + secure_filename_enhanced, +) +from api.utils.web_security import api_rate_limit, CSRFProtection, upload_rate_limit from firebase_admin import auth as firebase_admin_auth @@ -474,11 +489,27 @@ def get_account(id): # POST request for a new account profile @main.route("/account", methods=["POST"]) -# @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def create_mentor_profile(): data = request.json - email = data.get("email") + + valid, error_msg = validate_json_data(data) + if not valid: + return create_response(status=422, message=error_msg) + + email = sanitize_text(data.get("email")) password = data.get("password") + + if email: + valid, error_msg = validate_email_format(email) + if not valid: + return create_response(status=422, message=error_msg) + + if password: + valid, error_msg = validate_password(password) + if not valid: + return create_response(status=422, message=error_msg) try: account_type = int(data["account_type"]) except: @@ -685,6 +716,8 @@ def create_mentor_profile(): @main.route("/accountProfile", methods=["POST"]) # @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def create_profile_existing_account(): data = request.json email = data.get("email") @@ -809,6 +842,8 @@ def create_profile_existing_account(): # PUT requests for /account @main.route("/account/", methods=["PUT"]) # @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def edit_mentor(id): data = request.get_json() try: @@ -821,16 +856,20 @@ def edit_mentor(id): account = None try: token = request.headers.get("Authorization") - claims = firebase_admin_auth.verify_id_token(token) + claims = verify_token_with_expiry(token) login_user_role = claims.get("role") authorized, response = verify_user(account_type) - if ( - not authorized - and int(login_user_role) != Account.ADMIN - and int(login_user_role) != Account.SUPPORT - and int(login_user_role) != Account.HUB - ): + admin_support_access = ( + int(login_user_role) == Account.ADMIN + or int(login_user_role) == Account.HUB + or ( + int(login_user_role) == Account.SUPPORT + and account_type in [Account.MENTEE, Account.MENTOR] + ) + ) + + if not authorized and not admin_support_access: return response if account_type == Account.MENTEE: @@ -868,14 +907,29 @@ def edit_mentor(id): @main.route("/account//image", methods=["PUT"]) -# @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def uploadImage(id): + valid, error_msg = validate_object_id(id) + if not valid: + return create_response(status=422, message="Invalid account ID") + + if "image" not in request.files: + return create_response(status=422, message="No image file provided") + image = request.files["image"] + + valid, error_msg = validate_file_upload( + image, allowed_extensions={"jpg", "jpeg", "png", "gif"}, max_size_mb=5 + ) + if not valid: + return create_response(status=422, message=error_msg) try: - account_type = request.form["account_type"] - if isinstance(account_type, str): - account_type = int(account_type) - except: + account_type_str = sanitize_text(request.form.get("account_type", "")) + if not account_type_str: + return create_response(status=422, message="Account type is required") + account_type = int(account_type_str) + except ValueError: msg = "Level param doesn't exist or isn't an int" return create_response(status=422, message=msg) @@ -894,16 +948,20 @@ def uploadImage(id): msg = "Level param doesn't match existing account types" return create_response(status=422, message=msg) else: - claims = firebase_admin_auth.verify_id_token(token) + claims = verify_token_with_expiry(token) login_user_role = claims.get("role") authorized, response = verify_user(account_type) - if ( - not authorized - and int(login_user_role) != Account.ADMIN - and int(login_user_role) != Account.SUPPORT - and int(login_user_role) != Account.HUB - ): + admin_support_access = ( + int(login_user_role) == Account.ADMIN + or int(login_user_role) == Account.HUB + or ( + int(login_user_role) == Account.SUPPORT + and account_type in [Account.MENTEE, Account.MENTOR] + ) + ) + + if not authorized and not admin_support_access: return response if account_type == Account.MENTEE: account = MenteeProfile.objects.get(id=id) diff --git a/backend/api/views/masters.py b/backend/api/views/masters.py index 48c80201c..0ff6adc4b 100644 --- a/backend/api/views/masters.py +++ b/backend/api/views/masters.py @@ -1,5 +1,5 @@ from datetime import datetime - +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit from flask.globals import request from api.core import create_response, logger from flask import Blueprint @@ -44,6 +44,8 @@ # @masters.route("/translate", methods=["PUT"]) # @admin_only +# @api_rate_limit +# @CSRFProtection.csrf_protect # def translate(): # mapping = {"languages": Languages, "specializations": Specializations} # optionType = request.form["optionType"] @@ -87,6 +89,8 @@ def get_language(id): @masters.route("/languages/", methods=["DELETE"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def delete_language(id): try: language = Languages.objects.get(id=id) @@ -113,6 +117,8 @@ def delete_language(id): # TODO: Add translations to this as well in case of typos @masters.route("/languages/", methods=["PUT"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def edit_language_by_id(id): record = Languages.objects.get(id=id) lang_name = record.name @@ -154,6 +160,8 @@ def edit_language_by_id(id): ###################################################################### @masters.route("/languages", methods=["POST"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def new_language(): name = request.form["name"] record = Languages(name=name, updated_at=datetime.now()) @@ -191,6 +199,8 @@ def get_specialization(id): @masters.route("specializations/", methods=["DELETE"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def delete_specializations(id): try: specializations = Specializations.objects.get(id=id) @@ -224,6 +234,8 @@ def delete_specializations(id): # TODO: Add translations to this as well in case of typos @masters.route("/specializations/", methods=["PUT"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def edit_specialization_by_id(id): record = Specializations.objects.get(id=id) prev_name = record.name @@ -265,6 +277,8 @@ def edit_specialization_by_id(id): ###################################################################### @masters.route("/specializations", methods=["POST"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def new_specailization(): name = request.form["name"] record = Specializations(name=name, updated_at=datetime.now()) diff --git a/backend/api/views/meeting.py b/backend/api/views/meeting.py index 7f1e4a37a..99071ef30 100644 --- a/backend/api/views/meeting.py +++ b/backend/api/views/meeting.py @@ -8,34 +8,50 @@ import base64 from api.utils.jaas_jwt_builder import JaaSJwtBuilder +from api.utils.secure_env import SecureEnvironmentManager meeting = Blueprint("meeting", __name__) -API_KEY = os.environ.get("EIGHT_X_EIGHT_API_KEY") -APP_ID = os.environ.get("EIGHT_X_EIGHT_APP_ID") -ENCODED_PRIVATE_KEY = os.environ.get("EIGHT_X_EIGHT_ENCODED_PRIVATE_KEY") + +def get_eight_x_eight_config(): + """Get 8x8 configuration securely""" + return { + "api_key": SecureEnvironmentManager.get_optional_env("EIGHT_X_EIGHT_API_KEY"), + "app_id": SecureEnvironmentManager.get_optional_env("EIGHT_X_EIGHT_APP_ID"), + "private_key": SecureEnvironmentManager.get_optional_env( + "EIGHT_X_EIGHT_ENCODED_PRIVATE_KEY" + ), + } @meeting.route("/generateToken", methods=["GET"]) def generateToken(): try: - print(ENCODED_PRIVATE_KEY) - PRIVATE_KEY = base64.b64decode(ENCODED_PRIVATE_KEY) - print(PRIVATE_KEY) + config = get_eight_x_eight_config() + + if not all([config["api_key"], config["app_id"], config["private_key"]]): + return create_response( + status=500, message="8x8 configuration not available" + ) + + private_key = base64.b64decode(config["private_key"]) + jaasJwt = JaaSJwtBuilder() token = ( jaasJwt.withDefaults() - .withApiKey(API_KEY) + .withApiKey(config["api_key"]) .withUserName("User Name") .withUserEmail("email_address@email.com") .withModerator(False) - .withAppID(APP_ID) + .withAppID(config["app_id"]) .withUserAvatar("https://asda.com/avatar") - .signWith(PRIVATE_KEY) + .signWith(private_key) ) - return create_response(data={"token": token.decode("utf-8"), "appID": APP_ID}) + return create_response( + data={"token": token.decode("utf-8"), "appID": config["app_id"]} + ) except Exception as error: print(error) diff --git a/backend/api/views/mentee.py b/backend/api/views/mentee.py index 922f3fd27..65be309ee 100644 --- a/backend/api/views/mentee.py +++ b/backend/api/views/mentee.py @@ -3,12 +3,15 @@ from flask import Blueprint, request from api.utils.constants import Account from api.utils.require_auth import mentee_only +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit mentee = Blueprint("mentee", __name__) @mentee.route("/editFavMentor", methods=["PUT"]) @mentee_only +@api_rate_limit +@CSRFProtection.csrf_protect def edit_fav_mentor(): try: data = request.get_json() diff --git a/backend/api/views/messages.py b/backend/api/views/messages.py index a1b2e49ce..d04236b24 100644 --- a/backend/api/views/messages.py +++ b/backend/api/views/messages.py @@ -17,7 +17,14 @@ TRANSLATIONS, UNREAD_MESSAGE_TEMPLATE, ) -from api.utils.require_auth import all_users +from api.utils.require_auth import all_users, admin_only, mentor_only, mentee_only +from api.utils.input_validation import ( + validate_json_data, + validate_object_id, + sanitize_text, + validate_string_length, +) +from api.utils.web_security import api_rate_limit, CSRFProtection, XSSProtection from api.utils.translate import get_translated_options from api.core import create_response, logger import json @@ -25,7 +32,7 @@ from api import socketio from mongoengine.queryset.visitor import Q from urllib.parse import unquote - +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit messages = Blueprint("messages", __name__) @@ -47,6 +54,8 @@ def get_messages(): @messages.route("/", methods=["DELETE"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def delete_message(message_id): try: message = Message.objects.get(id=message_id) @@ -67,6 +76,8 @@ def delete_message(message_id): @messages.route("/", methods=["PUT"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def update_message(message_id): try: message = Message.objects.get(id=message_id) @@ -91,18 +102,42 @@ def update_message(message_id): @messages.route("/", methods=["POST"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def create_message(): data = request.get_json() + + valid, error_msg = validate_json_data(data, ["message", "user_id", "recipient_id"]) + if not valid: + return create_response(status=422, message=error_msg) + + message_text = sanitize_text(data.get("message")) + user_id = sanitize_text(data.get("user_id")) + recipient_id = sanitize_text(data.get("recipient_id")) + time_data = data.get("time") + + valid, error_msg = validate_string_length(message_text, 5000, "Message") + if not valid: + return create_response(status=422, message=error_msg) + + valid, error_msg = validate_object_id(user_id) + if not valid: + return create_response(status=422, message="Invalid sender ID") + + valid, error_msg = validate_object_id(recipient_id) + if not valid: + return create_response(status=422, message="Invalid recipient ID") + availabes_in_future = None if "availabes_in_future" in data: availabes_in_future = data.get("availabes_in_future") try: message = DirectMessage( - body=data["message"], + body=message_text, message_read=False, - sender_id=data["user_id"], - recipient_id=data["recipient_id"], - created_at=data.get("time"), + sender_id=user_id, + recipient_id=recipient_id, + created_at=time_data, availabes_in_future=availabes_in_future, ) except Exception as e: @@ -144,6 +179,8 @@ def create_message(): @messages.route("/mentor/", methods=["POST"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def contact_mentor(mentor_id): data = request.get_json() if "mentee_id" not in data: @@ -601,6 +638,8 @@ def get_sidebar_mentors(page_number): @messages.route("/group_delete/", methods=["DELETE"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def delete_group_message(message_id): try: message = GroupMessage.objects.get(id=message_id) diff --git a/backend/api/views/notifications.py b/backend/api/views/notifications.py index c3bb7b6c0..33f6bc4fb 100644 --- a/backend/api/views/notifications.py +++ b/backend/api/views/notifications.py @@ -14,6 +14,7 @@ TRANSLATIONS, ) from api.utils.require_auth import all_users +from api.utils.web_security import auth_rate_limit, CSRFProtection, api_rate_limit notifications = Blueprint("notifications", __name__) @@ -201,6 +202,8 @@ def send_unread_alert(id): @notifications.route("/update", methods=["PUT"]) @all_users +@api_rate_limit +@CSRFProtection.csrf_protect def update_unread_count(): data = request.get_json() if not data: diff --git a/backend/api/views/training.py b/backend/api/views/training.py index 7d7fbaac5..42ae177e4 100644 --- a/backend/api/views/training.py +++ b/backend/api/views/training.py @@ -3,6 +3,12 @@ import json from h11 import Data from werkzeug.utils import secure_filename +from api.utils.input_validation import ( + validate_file_upload, + sanitize_text, + secure_filename_enhanced, +) +from api.utils.web_security import upload_rate_limit, CSRFProtection, api_rate_limit from api.core import create_response, logger from bson import ObjectId from api.models import ( @@ -250,6 +256,8 @@ def update_multiple_trainings(): @training.route("/library/", methods=["DELETE"]) +@api_rate_limit +@CSRFProtection.csrf_protect def delete_library(id): try: data = CommunityLibrary.objects.get(id=id) @@ -262,6 +270,8 @@ def delete_library(id): @training.route("/", methods=["DELETE"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def delete_train(id): try: train = Training.objects.get(id=id) @@ -354,14 +364,16 @@ def get_train_file(id): @training.route("/library/", methods=["PUT"]) +@api_rate_limit +@CSRFProtection.csrf_protect def edit_library_by_id(id): try: data = CommunityLibrary.objects.get(id=id) except Exception as e: return create_response(status=422, message=f"Failed to get library: {e}") - new_name = request.form.get("name", data.name) - new_description = request.form.get("description", data.description) + new_name = sanitize_text(request.form.get("name", data.name)) + new_description = sanitize_text(request.form.get("description", data.description)) if data.name != new_name: data.name = new_name @@ -372,7 +384,13 @@ def edit_library_by_id(id): file = request.files.get("file", None) if file: - file_name = secure_filename(file.filename) + valid, error_msg = validate_file_upload( + file, allowed_extensions={"pdf", "doc", "docx", "txt"}, max_size_mb=25 + ) + if not valid: + return create_response(status=400, message=error_msg) + + file_name = secure_filename_enhanced(file.filename) if file_name == "": return create_response(status=400, message="Missing file name") data.filee.replace(file, filename=file_name) @@ -386,6 +404,8 @@ def edit_library_by_id(id): ############################################################################# @training.route("/", methods=["PUT"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def get_train_id_edit(id): isVideo = True if request.form["isVideo"] == "true" else False @@ -394,12 +414,12 @@ def get_train_id_edit(id): except Exception as e: return create_response(status=422, message=f"Failed to get training: {e}") - new_name = request.form.get("name", train.name) - new_description = request.form.get("description", train.description) + new_name = sanitize_text(request.form.get("name", train.name)) + new_description = sanitize_text(request.form.get("description", train.description)) hub_id = None if "hub_id" in request.form: - hub_id = request.form["hub_id"] + hub_id = sanitize_text(request.form["hub_id"]) train.hub_id = hub_id if train.name != new_name: @@ -409,15 +429,15 @@ def get_train_id_edit(id): train.description = new_description train.descriptionTranslated = get_all_translations(new_description) - train.role = str(request.form.get("role", train.role)) - train.typee = request.form.get("typee", train.typee) + train.role = str(sanitize_text(request.form.get("role", train.role))) + train.typee = sanitize_text(request.form.get("typee", train.typee)) train.isVideo = isVideo requried_sign = False if "requried_sign" in request.form and request.form["requried_sign"] == "true": requried_sign = True train.requried_sign = requried_sign - train.partner_id = request.form.get("partner_id", train.partner_id) + train.partner_id = sanitize_text(request.form.get("partner_id", train.partner_id)) if "mentor_id" in request.form and request.form["mentor_id"] is not None: train.mentor_id = list(json.loads(request.form["mentor_id"])) @@ -428,14 +448,21 @@ def get_train_id_edit(id): document = request.files.get("document", None) if not document: return create_response(status=400, message="Missing file") - file_name = secure_filename(document.filename) + + valid, error_msg = validate_file_upload( + document, allowed_extensions={"pdf", "doc", "docx"}, max_size_mb=25 + ) + if not valid: + return create_response(status=400, message=error_msg) + + file_name = secure_filename_enhanced(document.filename) if file_name == "": return create_response(status=400, message="Missing file name") train.filee.replace(document, filename=file_name) train.file_name = file_name else: - train.url = request.form.get("url", train.url) + train.url = sanitize_text(request.form.get("url", train.url)) train.save() @@ -443,12 +470,27 @@ def get_train_id_edit(id): @training.route("/saveSignedDoc", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def saveSignedDoc(): try: signedPdf = request.files.get("signedPdf") - user_email = request.form["user_email"] - role = request.form["role"] - train_id = request.form["train_id"] + if not signedPdf: + return create_response(status=400, message="No signed PDF provided") + + valid, error_msg = validate_file_upload( + signedPdf, allowed_extensions={"pdf"}, max_size_mb=10 + ) + if not valid: + return create_response(status=400, message=error_msg) + + user_email = sanitize_text(request.form.get("user_email", "")) + role = sanitize_text(request.form.get("role", "")) + train_id = sanitize_text(request.form.get("train_id", "")) + + if not user_email or not role or not train_id: + return create_response(status=400, message="Missing required fields") + ex_signed_doc = ( SignedDocs.objects() .filter(user_email=user_email) @@ -476,16 +518,24 @@ def saveSignedDoc(): @training.route("/add_policy/", methods=["POST"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def new_policy(role): try: - name = request.form["name"] - nameTranslated = get_all_translations(request.form["description"]) - description = request.form["description"] - descriptionTranslated = get_all_translations(request.form["description"]) + name = sanitize_text(request.form.get("name", "")) + description = sanitize_text(request.form.get("description", "")) + + if not name or not description: + return create_response( + status=400, message="Name and description are required" + ) + + nameTranslated = get_all_translations(description) + descriptionTranslated = get_all_translations(description) hub_id = None if "hub_id" in request.form: - hub_id = request.form["hub_id"] + hub_id = sanitize_text(request.form["hub_id"]) signOrigin = SignOrigin( name=name, @@ -501,10 +551,16 @@ def new_policy(role): if not document: return create_response(status=400, message="Missing file") - file_name = secure_filename(document.filename) + file_name = secure_filename_enhanced(document.filename) if file_name == "": return create_response(status=400, message="Missing file name") + valid, error_msg = validate_file_upload( + document, allowed_extensions={"pdf", "doc", "docx"}, max_size_mb=25 + ) + if not valid: + return create_response(status=400, message=error_msg) + signOrigin.file_name = file_name signOrigin.filee.put(document, filename=file_name) @@ -517,15 +573,21 @@ def new_policy(role): @training.route("/new_library", methods=["POST"]) +@api_rate_limit +@CSRFProtection.csrf_protect def new_library(): try: - name = request.form["name"] - nameTranslated = get_all_translations(request.form["description"]) - description = request.form["description"] - descriptionTranslated = get_all_translations(request.form["description"]) - user_id = request.form["user_id"] - hub_id = request.form["hub_id"] - user_name = request.form["user_name"] + name = sanitize_text(request.form.get("name", "")) + description = sanitize_text(request.form.get("description", "")) + user_id = sanitize_text(request.form.get("user_id", "")) + hub_id = sanitize_text(request.form.get("hub_id", "")) + user_name = sanitize_text(request.form.get("user_name", "")) + + if not name or not description or not user_id or not hub_id or not user_name: + return create_response(status=400, message="All fields are required") + + nameTranslated = get_all_translations(name) + descriptionTranslated = get_all_translations(description) new_data = CommunityLibrary( name=name, @@ -542,10 +604,16 @@ def new_library(): if not file: return create_response(status=400, message="Missing file") - file_name = secure_filename(file.filename) + file_name = secure_filename_enhanced(file.filename) if file_name == "": return create_response(status=400, message="Missing file name") + valid, error_msg = validate_file_upload( + file, allowed_extensions={"pdf", "doc", "docx", "txt"}, max_size_mb=25 + ) + if not valid: + return create_response(status=400, message=error_msg) + new_data.file_name = file_name new_data.filee.put(file, filename=file_name) @@ -559,17 +627,25 @@ def new_library(): ###################################################################### @training.route("/", methods=["POST"]) @admin_only +@upload_rate_limit +@CSRFProtection.csrf_protect def new_train(role): try: send_notification = ( - True if request.form["send_notification"] == "true" else False + True if request.form.get("send_notification", "false") == "true" else False ) - name = request.form["name"] - nameTranslated = get_all_translations(request.form["description"]) - description = request.form["description"] - descriptionTranslated = get_all_translations(request.form["description"]) - typee = request.form["typee"] - isVideo = True if request.form["isVideo"] == "true" else False + name = sanitize_text(request.form.get("name", "")) + description = sanitize_text(request.form.get("description", "")) + + if not name or not description: + return create_response( + status=400, message="Name and description are required" + ) + + nameTranslated = get_all_translations(name) + descriptionTranslated = get_all_translations(description) + typee = sanitize_text(request.form.get("typee", "")) + isVideo = True if request.form.get("isVideo", "false") == "true" else False requried_sign = False if "requried_sign" in request.form and request.form["requried_sign"] == "true": requried_sign = True @@ -577,20 +653,28 @@ def new_train(role): hub_id = None if "hub_id" in request.form: - hub_id = request.form["hub_id"] + hub_id = sanitize_text(request.form["hub_id"]) partner_id = None if ( "partner_id" in request.form and request.form["partner_id"] is not None and request.form["partner_id"] != "" ): - partner_id = request.form["partner_id"] + partner_id = sanitize_text(request.form["partner_id"]) mentor_id = None if "mentor_id" in request.form and request.form["mentor_id"] is not None: - mentor_id = list(json.loads(request.form["mentor_id"])) + mentor_id_str = sanitize_text(request.form["mentor_id"]) + try: + mentor_id = list(json.loads(mentor_id_str)) + except: + return create_response(status=400, message="Invalid mentor_id format") mentee_id = None if "mentee_id" in request.form and request.form["mentee_id"] is not None: - mentee_id = list(json.loads(request.form["mentee_id"])) + mentee_id_str = sanitize_text(request.form["mentee_id"]) + try: + mentee_id = list(json.loads(mentee_id_str)) + except: + return create_response(status=400, message="Invalid mentee_id format") train = Training( name=name, @@ -612,14 +696,22 @@ def new_train(role): if not document: return create_response(status=400, message="Missing file") - file_name = secure_filename(document.filename) + file_name = secure_filename_enhanced(document.filename) if file_name == "": return create_response(status=400, message="Missing file name") + valid, error_msg = validate_file_upload( + document, + allowed_extensions={"pdf", "doc", "docx", "txt"}, + max_size_mb=25, + ) + if not valid: + return create_response(status=400, message=error_msg) + train.file_name = file_name train.filee.put(document, filename=file_name) else: - train.url = request.form["url"] + train.url = sanitize_text(request.form.get("url", "")) train.save() @@ -691,7 +783,7 @@ def new_train(role): hub_url = hub_user.url + "/" for partner_user in partners: recipients.append(partner_user) - front_url = request.form["front_url"] + front_url = sanitize_text(request.form.get("front_url", "")) target_url = ( front_url + hub_url + "new_training/" + role + "/" + str(new_train_id) ) @@ -762,6 +854,8 @@ def new_train(role): @training.route("/translate/", methods=["PUT"]) @admin_only +@api_rate_limit +@CSRFProtection.csrf_protect def translate_training(id): try: training = Training.objects.get(id=id) diff --git a/backend/production_config.py b/backend/production_config.py new file mode 100644 index 000000000..fd204efab --- /dev/null +++ b/backend/production_config.py @@ -0,0 +1,58 @@ +""" +Production configuration for MENTEE application +Security-focused settings for production deployment +""" + +import os + + +class ProductionConfig: + """Production configuration settings with enhanced security""" + + # HTTPS and Security Settings + PREFERRED_URL_SCHEME = "https" + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + PERMANENT_SESSION_LIFETIME = 3600 # 1 hour + + # Force HTTPS redirect + FORCE_HTTPS = True + + # Content Security Policy headers + SECURITY_HEADERS = { + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + } + + # File upload security + MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB + + @staticmethod + def validate_production_environment(): + """Validate that all required production environment variables are set""" + required_vars = [ + "MONGO_USER", + "MONGO_PASSWORD", + "MONGO_HOST", + "MONGO_DB", + "SENDGRID_API_KEY", + "FIREBASE_API_KEY", + "TWILIO_ACCOUNT_SID", + "TWILIO_AUTH_TOKEN", + ] + + missing_vars = [] + for var in required_vars: + if not os.environ.get(var): + missing_vars.append(var) + + if missing_vars: + raise ValueError( + f"Missing required environment variables: {', '.join(missing_vars)}" + ) + + return True diff --git a/backend/requirements.txt b/backend/requirements.txt index af856c743..e25c7cbf7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,9 +14,8 @@ colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0" cryptography==39.0.2 ; python_version >= "3.8" and python_version < "4.0" deprecated==1.2.13 ; python_version >= "3.8" and python_version < "4.0" dill==0.2.9 ; python_version >= "3.8" and python_version < "4.0" -dnspython==1.16.0 ; python_version >= "3.8" and python_version < "4.0" +dnspython==2.3.0 ; python_version >= "3.8" and python_version < "4.0" email-validator==1.3.1 ; python_version >= "3.8" and python_version < "4.0" -eventlet==0.30.2 ; python_version >= "3.8" and python_version < "4.0" firebase-admin==6.1.0 ; python_version >= "3.8" and python_version < "4.0" flask-cors==3.0.10 ; python_version >= "3.8" and python_version < "4.0" flask-migrate==4.0.4 ; python_version >= "3.8" and python_version < "4.0" @@ -27,6 +26,7 @@ flask-sqlalchemy==3.0.3 ; python_version >= "3.8" and python_version < "4.0" flask-wtf==1.1.1 ; python_version >= "3.8" and python_version < "4.0" flask==2.2.3 ; python_version >= "3.8" and python_version < "4.0" future==0.16.0 ; python_version >= "3.8" and python_version < "4.0" +bleach==6.0.0 ; python_version >= "3.8" and python_version < "4.0" gax-google-logging-v2==0.8.3 ; python_version >= "3.8" and python_version < "4.0" gax-google-pubsub-v1==0.8.3 ; python_version >= "3.8" and python_version < "4.0" gcloud==0.18.3 ; python_version >= "3.8" and python_version < "4.0" @@ -76,8 +76,7 @@ pyasn1==0.4.8 ; python_version >= "3.8" and python_version < "4.0" pycparser==2.21 ; python_version >= "3.8" and python_version < "4.0" pycryptodome==3.17 ; python_version >= "3.8" and python_version < "4.0" pyjwt==2.6.0 ; python_version >= "3.8" and python_version < "4.0" -pyjwt[crypto]==2.6.0 ; python_version >= "3.8" and python_version < "4.0" -pymongo==4.6.0 ; python_version >= "3.8" and python_version < "4.0" +pymongo==4.3.3 ; python_version >= "3.8" and python_version < "4.0" pyparsing==3.0.9 ; python_version >= "3.8" and python_version < "4.0" pypdf2==3.0.1 ; python_version >= "3.8" and python_version < "4.0" pyrebase4==4.6.0 ; python_version >= "3.8" and python_version < "4.0" @@ -90,18 +89,20 @@ python-jwt==4.0.0 ; python_version >= "3.8" and python_version < "4.0" python-socketio==5.7.2 ; python_version >= "3.8" and python_version < "4.0" pytz==2022.7.1 ; python_version >= "3.8" and python_version < "4.0" requests-toolbelt==0.10.1 ; python_version >= "3.8" and python_version < "4.0" -requests==2.28.2 ; python_version >= "3.8" and python_version < "4" -rsa==4.9 ; python_version >= "3.8" and python_version < "4" +requests==2.28.2 ; python_version >= "3.8" and python_version < "4.0" +rsa==4.9 ; python_version >= "3.8" and python_version < "4.0" sendgrid==6.9.7 ; python_version >= "3.8" and python_version < "4.0" -setuptools==75.3.0 ; python_version >= "3.8" and python_version < "4.0" +setuptools==75.3.2 ; python_version >= "3.8" and python_version < "3.10" +setuptools==80.9.0 ; python_version >= "3.10" and python_version < "4.0" simple-websocket==0.9.0 ; python_version >= "3.8" and python_version < "4.0" six==1.16.0 ; python_version >= "3.8" and python_version < "4.0" sqlalchemy==2.0.4 ; python_version >= "3.8" and python_version < "4.0" starkbank-ecdsa==2.2.0 ; python_version >= "3.8" and python_version < "4.0" twilio==7.16.4 ; python_version >= "3.8" and python_version < "4.0" typing-extensions==4.5.0 ; python_version >= "3.8" and python_version < "4.0" -tzdata==2025.1 ; python_version >= "3.8" and python_version < "4.0" and platform_system == "Windows" -tzlocal==5.2 ; python_version >= "3.8" and python_version < "4.0" +tzdata==2025.2 ; python_version >= "3.8" and python_version < "4.0" and platform_system == "Windows" +tzlocal==5.2 ; python_version >= "3.8" and python_version < "3.10" +tzlocal==5.3.1 ; python_version >= "3.10" and python_version < "4.0" uritemplate==4.1.1 ; python_version >= "3.8" and python_version < "4.0" urllib3==1.26.14 ; python_version >= "3.8" and python_version < "4.0" werkzeug==2.2.3 ; python_version >= "3.8" and python_version < "4.0" @@ -109,8 +110,10 @@ wrapt==1.15.0 ; python_version >= "3.8" and python_version < "4.0" wsproto==1.2.0 ; python_version >= "3.8" and python_version < "4.0" wtforms-json==0.3.5 ; python_version >= "3.8" and python_version < "4.0" wtforms==3.0.1 ; python_version >= "3.8" and python_version < "4.0" -wtforms[email]==3.0.1 ; python_version >= "3.8" and python_version < "4.0" xlsxwriter==3.0.8 ; python_version >= "3.8" and python_version < "4.0" +python-magic==0.4.27 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "linux" +python-magic-bin==0.4.14 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "win32" +file-magic==0.4.0 ; python_version >= "3.8" and python_version < "4.0" and sys_platform == "darwin" zipp==3.15.0 ; python_version >= "3.8" and python_version < "4.0" zope-event==4.6 ; python_version >= "3.8" and python_version < "4.0" zope-interface==5.5.2 ; python_version >= "3.8" and python_version < "4.0" diff --git a/backend/tests/utils/login_utils.py b/backend/tests/utils/login_utils.py index d69cae14d..19d946f80 100644 --- a/backend/tests/utils/login_utils.py +++ b/backend/tests/utils/login_utils.py @@ -2,6 +2,7 @@ import requests from dotenv import load_dotenv import json +from api.utils.secure_env import SecureEnvironmentManager dir_path = os.path.dirname(os.path.realpath(__file__)) @@ -14,7 +15,7 @@ # get access token from refresh token def get_access_token(refresh_token): - firebase_api_key = os.environ.get("FIREBASE_API_KEY") + firebase_api_key = SecureEnvironmentManager.get_required_env("FIREBASE_API_KEY") url = f"https://securetoken.googleapis.com/v1/token?key={firebase_api_key}" @@ -86,7 +87,7 @@ def login_partner(client): # use the first token and get the refresh token def get_refresh_token(first_token): - firebase_api_key = os.environ.get("FIREBASE_API_KEY") + firebase_api_key = SecureEnvironmentManager.get_required_env("FIREBASE_API_KEY") headers = { "Content-Type": "application/json", diff --git a/docker-compose.yml b/docker-compose.yml index a80dc5d09..d59155cb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,23 @@ services: container_name: mentee-frontend build: ./frontend ports: - - '3000:3000' + - '127.0.0.1:3000:3000' # Bind to localhost only environment: - NODE_ENV=development + networks: + - mentee-network backend: container_name: mentee-backend build: ./backend ports: - - '8000:8000' + - '127.0.0.1:8000:8000' # Bind to localhost only env_file: - - ./backend/.env \ No newline at end of file + - ./backend/.env + networks: + - mentee-network + +# Create isolated network for services +networks: + mentee-network: + driver: bridge \ No newline at end of file diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/AnotherReadMe.md b/docs/AnotherReadMe.md deleted file mode 100644 index ccbe2940a..000000000 --- a/docs/AnotherReadMe.md +++ /dev/null @@ -1,19 +0,0 @@ -## How to Setup Front End - -- cd frontend -- npm install -- npm start - -if you have errors with npm install then try different version of node it works for me in version (v14.20.0) - -## Set up Mongodb - -- you need to setup mongodb in you Operating system -- if you are windows this an example https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-windows - -## Set up backend - -- cd backend -- pip install -r requirements.txt (you need to setup pip if it is not exist first) -- if you face any error try go through requirements comment line contaion this library you got error from then run pip install command again -- after successfull installation run ( python manage.py runserver ) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 6ab112edf..000000000 --- a/docs/README.md +++ /dev/null @@ -1,83 +0,0 @@ -

- MENTEE Logo -
- MENTEE -
-

- -

- - -

- -

A project by Hack4Impact UIUC in collaboration with MENTEE.

- -

- Background • - Usage • - Technologies • - Team • - License -

- -## Background - -Connecting immigrant and refugee youth with a network of mentors. - -## Usage - -Install: - -- [Yarn](https://yarnpkg.com/) -- [Poetry](https://python-poetry.org/) -- [concurrently](https://www.npmjs.com/package/concurrently) - -To install/update dependencies: - -```bash -$ yarn setup -``` - -To run both backend and frontend - -```bash -$ yarn start -``` - -To format both sides - -```bash -$ yarn format -``` - -## Technologies - -This application is built with React, Flask, MongoDB, and Ant Design - -## Team - - - - - - - - - - - - - - - - - - -
Angela Luo
Angela Luo

Product Manager
Lam Tran
Lam Tran

Product Manager
Kelley Chau
Kelley Chau

Technical Lead
Kendall Hester
Kendall Hester

Technical Lead
Leonardo Galindo
Leonardo Galindo

Technical Lead
Faith Losbanes
Faith Losbanes

Product Designer
Nayonika Roy
Nayonika Roy

Software Developer
Michael Chen
Michael Chen

Software Developer
Daniel Moon
Daniel Moon

Software Developer
Andy Wong
Andy Wong

Software Developer
Ishaan Sharma
Ishaan Sharma

Software Developer
- -## License - -[MIT](https://github.com/hack4impact-uiuc/mentee/blob/master/LICENSE) licensed. Copyright © 2021 [Hack4Impact UIUC](https://github.com/hack4impact-uiuc). - -## Log -2024.08.17 - updated Heroku Stack to heroku-24 from heroku-20 diff --git a/docs/_coverpage.md b/docs/_coverpage.md deleted file mode 100644 index 04f64b6c1..000000000 --- a/docs/_coverpage.md +++ /dev/null @@ -1,8 +0,0 @@ - - -![logo](media/mentee.png) - -> Documentation - -[GitHub](https://github.com/hack4impact-uiuc/mentee) -[Get Started](README) \ No newline at end of file diff --git a/docs/_sidebar.md b/docs/_sidebar.md deleted file mode 100644 index bb24d1360..000000000 --- a/docs/_sidebar.md +++ /dev/null @@ -1,14 +0,0 @@ - - -- General - - - [Home](README) - -- Frontend - - - [Getting Started](frontend/setup) - -- Backend - - - [Getting Started](backend/setup) - - [File Structure](backend/file_structure.md) \ No newline at end of file diff --git a/docs/backend/file_structure.md b/docs/backend/file_structure.md deleted file mode 100644 index 1ab57f9ac..000000000 --- a/docs/backend/file_structure.md +++ /dev/null @@ -1,15 +0,0 @@ -# Repository Contents - -- `api/views/` - Holds files that define your endpoints -- `api/models/` - Holds files that defines your database schema -- `api/__init__.py` - What is initially ran when you start your application -- `api/utils.py` - utility functions and classes - explained [here](https://github.com/tko22/flask-boilerplate/wiki/Conventions) -- `api/core.py` - includes core functionality including error handlers and logger -- `tests/` - Folder holding tests - -#### Others - -- `config.py` - Provides Configuration for the application. There are two configurations: one for development and one for production using Heroku. -- `manage.py` - Command line interface that allows you to perform common functions with a command -- `requirements.txt` - A list of python package dependencies the application requires -- `runtime.txt` & `Procfile` - configuration for Heroku \ No newline at end of file diff --git a/docs/backend/setup.md b/docs/backend/setup.md deleted file mode 100644 index 3b936074d..000000000 --- a/docs/backend/setup.md +++ /dev/null @@ -1,25 +0,0 @@ -# Getting Started on Backend - -## Installing and Running -Make sure you have [Python3](https://realpython.com/installing-python/) and [Poetry](https://python-poetry.org/) installed. - -Install packages: - -``` -$ poetry install -``` -To run the server: -``` -$ poetry run start -``` - -## Dependencies - -Documentation for all the libraries we are using - -- [Flask](https://flask.palletsprojects.com/en/1.1.x/) -- [Mongoengine](http://mongoengine.org/) -- [Twilio](twilio.com/docs/libraries/python) -- [SendGrid](https://sendgrid.com/docs/for-developers/) -- [WTForms](https://wtforms.readthedocs.io/en/2.3.x/) -- [WTForms-JSON](https://wtforms-json.readthedocs.io/en/latest/) \ No newline at end of file diff --git a/docs/frontend/setup.md b/docs/frontend/setup.md deleted file mode 100644 index 10c8e0d7e..000000000 --- a/docs/frontend/setup.md +++ /dev/null @@ -1,29 +0,0 @@ -# Getting Started on Frontend - -## Setup -### Installing -Install [yarn](https://yarnpkg.com/) if you don't already have it and install react libraries -``` -$ npm install --global yarn -``` -NOTE: You'll need to do this every time there is a new library that has been added by another member -``` -$ yarn -``` - -### Running - -``` -$ yarn start -``` - -## Dependencies -The following are libraries we are using in our project. If you add any new ones add them here too! - -They will also be linked to their respective documentation - -- [Ant Design](https://ant-design.gitee.io/components/overview/) -- [Moment.JS](https://momentjs.com/docs/) -- [React Router](https://reactrouter.com/web/guides/quick-start) -- [Axios](https://www.npmjs.com/package/axios) -- [Material UI](https://material-ui.com/getting-started/installation/) \ No newline at end of file diff --git a/docs/index.html b/docs/index.html index a8c28ebaa..dcb797881 100644 --- a/docs/index.html +++ b/docs/index.html @@ -28,4 +28,4 @@ - + \ No newline at end of file diff --git a/docs/media/mentee.png b/docs/media/mentee.png deleted file mode 100644 index 2203b5a0d..000000000 Binary files a/docs/media/mentee.png and /dev/null differ diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 000000000..002c9e598 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,25 @@ +# Exclude everything by default +* + +# Include only necessary files for build +!public/ +!src/ +!package.json +!yarn.lock +!jsconfig.json + +# Explicitly exclude sensitive and unnecessary files +node_modules/ +.env +.env.* +*.log +.DS_Store +Thumbs.db +.git/ +.gitignore +build/ +coverage/ +*.key +*.pem +.vscode/ +.idea/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 135455c66..c17b84539 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,14 +1,37 @@ # Stage 1 - the build process -FROM node:16 as build-deps +FROM node:16.20.2-alpine as build-deps + +# Create non-root user +RUN addgroup -g 1001 -S appuser && \ + adduser -S appuser -u 1001 + WORKDIR /usr/src/app -COPY package.json /usr/src/app -RUN yarn -COPY . ./ + +# Copy package files first for better layer caching +COPY package.json yarn.lock* /usr/src/app/ + +# Install dependencies +RUN yarn install --frozen-lockfile + +# Copy only necessary source files +COPY public/ /usr/src/app/public/ +COPY src/ /usr/src/app/src/ +COPY jsconfig.json /usr/src/app/ + +# Change ownership to non-root user +RUN chown -R appuser:appuser /usr/src/app + +# Switch to non-root user +USER appuser + +# For development - start the dev server ENTRYPOINT [ "yarn", "start" ] -# RUN npm run build -# Stage 2 - the production environment -# FROM nginx:1.12 +# Stage 2 - the production environment (uncomment for production builds) +# FROM nginx:1.21.6-alpine # COPY --from=build-deps /usr/src/app/build /usr/share/nginx/html +# RUN addgroup -g 1001 -S appuser && \ +# adduser -S appuser -u 1001 +# USER appuser # EXPOSE 80 # CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 257adcdc7..3f969fb7c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -10,37 +10,98 @@ import i18n from "./i18n"; const instance = axios.create({ baseURL: API_URL, + withCredentials: true, }); +// Function to get CSRF token +const getCsrfToken = async () => { + try { + const response = await axios.get(`${API_URL}csrf-token`, { + withCredentials: true, + }); + return response.data.csrf_token; + } catch (error) { + console.error("Failed to get CSRF token:", error); + return null; + } +}; + const authGet = async (url, config) => instance.get(url, { ...config, - headers: { Authorization: await getUserIdToken() }, + headers: { + Authorization: await getUserIdToken(), + ...(config?.headers || {}), + }, }); -const authPost = async (url, data, config) => - instance.post(url, data, { +const authPost = async (url, data, config) => { + const csrfToken = await getCsrfToken(); + const headers = { + Authorization: await getUserIdToken(), + ...(config?.headers || {}), + }; + + if (csrfToken) { + headers["X-CSRF-Token"] = csrfToken; + } + + return instance.post(url, data, { ...config, - headers: { Authorization: await getUserIdToken() }, + headers, }); +}; -const authPut = async (url, data, config) => - instance.put(url, data, { +const authPut = async (url, data, config) => { + const csrfToken = await getCsrfToken(); + const headers = { + Authorization: await getUserIdToken(), + ...(config?.headers || {}), + }; + + if (csrfToken) { + headers["X-CSRF-Token"] = csrfToken; + } + + return instance.put(url, data, { ...config, - headers: { Authorization: await getUserIdToken() }, + headers, }); +}; + +const authPatch = async (url, data, config) => { + const csrfToken = await getCsrfToken(); + const headers = { + Authorization: await getUserIdToken(), + ...(config?.headers || {}), + }; + + if (csrfToken) { + headers["X-CSRF-Token"] = csrfToken; + } -const authPatch = async (url, data, config) => - instance.patch(url, data, { + return instance.patch(url, data, { ...config, - headers: { Authorization: await getUserIdToken() }, + headers, }); +}; + +const authDelete = async (url, config) => { + const csrfToken = await getCsrfToken(); + const headers = { + Authorization: await getUserIdToken(), + ...(config?.headers || {}), + }; -const authDelete = async (url, config) => - instance.delete(url, { + if (csrfToken) { + headers["X-CSRF-Token"] = csrfToken; + } + + return instance.delete(url, { ...config, - headers: { Authorization: await getUserIdToken() }, + headers, }); +}; export const getAllcountries = () => { const requestExtension = "/countries"; diff --git a/frontend/src/utils/auth.service.js b/frontend/src/utils/auth.service.js index 557919713..cc0eb1aad 100644 --- a/frontend/src/utils/auth.service.js +++ b/frontend/src/utils/auth.service.js @@ -5,22 +5,54 @@ import i18n from "./i18n"; const instance = axios.create({ baseURL: AUTH_URL, + withCredentials: true, }); +// Function to get CSRF token +const getCsrfToken = async () => { + try { + const response = await axios.get( + `${AUTH_URL.replace("auth/", "api/csrf-token")}`, + { + withCredentials: true, + } + ); + return response.data.csrf_token; + } catch (error) { + console.error("Failed to get CSRF token:", error); + return null; + } +}; + const get = (url, params) => instance .get(url, params) .then((res) => res.data) .catch((err) => console.error(err)); -const post = (url, data, params) => - instance - .post(url, data, params) - .then((res) => res.data) - .catch((err) => { - console.error(err); - return err?.response; +const post = async (url, data, params) => { + try { + // Get CSRF token for POST requests + const csrfToken = await getCsrfToken(); + const headers = { + ...(params?.headers || {}), + }; + + if (csrfToken) { + headers["X-CSRF-Token"] = csrfToken; + } + + const response = await instance.post(url, data, { + ...params, + headers, + withCredentials: true, }); + return response.data; + } catch (err) { + console.error(err); + return err?.response; + } +}; const getIdToken = (forceRefresh) => getCurrentUser().getIdToken(forceRefresh); export const getIdTokenResult = (forceRefresh) => diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..3c1d34cb8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "1" +version = "0.1.0" +description = "" +authors = [ + {name = "aymankanso",email = "aymankanso7@gmail.com"} +] +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt index af856c743..563208e7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ alembic==1.9.4 ; python_version >= "3.8" and python_version < "4.0" apscheduler==3.11.0 ; python_version >= "3.8" and python_version < "4.0" authlib==1.3.0 ; python_version >= "3.8" and python_version < "4.0" -backports-zoneinfo==0.2.1 ; python_version >= "3.8" and python_version < "3.9" +backports-zoneinfo==0.2.1 ; python_version == "3.8" bidict==0.22.1 ; python_version >= "3.8" and python_version < "4.0" cachecontrol==0.12.11 ; python_version >= "3.8" and python_version < "4.0" cachetools==4.2.4 ; python_version >= "3.8" and python_version < "4.0" @@ -14,9 +14,8 @@ colorama==0.4.6 ; python_version >= "3.8" and python_version < "4.0" cryptography==39.0.2 ; python_version >= "3.8" and python_version < "4.0" deprecated==1.2.13 ; python_version >= "3.8" and python_version < "4.0" dill==0.2.9 ; python_version >= "3.8" and python_version < "4.0" -dnspython==1.16.0 ; python_version >= "3.8" and python_version < "4.0" +dnspython>=2.3,<3 ; python_version >= "3.8" and python_version < "4.0" email-validator==1.3.1 ; python_version >= "3.8" and python_version < "4.0" -eventlet==0.30.2 ; python_version >= "3.8" and python_version < "4.0" firebase-admin==6.1.0 ; python_version >= "3.8" and python_version < "4.0" flask-cors==3.0.10 ; python_version >= "3.8" and python_version < "4.0" flask-migrate==4.0.4 ; python_version >= "3.8" and python_version < "4.0" @@ -33,7 +32,6 @@ gcloud==0.18.3 ; python_version >= "3.8" and python_version < "4.0" gevent-websocket==0.10.1 ; python_version >= "3.8" and python_version < "4.0" gevent==22.10.2 ; python_version >= "3.8" and python_version < "4.0" google-api-core==2.11.0 ; python_version >= "3.8" and python_version < "4.0" -google-api-core[grpc]==2.11.0 ; python_version >= "3.8" and python_version < "4.0" google-api-python-client==2.80.0 ; python_version >= "3.8" and python_version < "4.0" google-auth-httplib2==0.1.0 ; python_version >= "3.8" and python_version < "4.0" google-auth==2.16.2 ; python_version >= "3.8" and python_version < "4.0" @@ -55,7 +53,7 @@ h11==0.14.0 ; python_version >= "3.8" and python_version < "4.0" httplib2==0.21.0 ; python_version >= "3.8" and python_version < "4.0" idna==2.10 ; python_version >= "3.8" and python_version < "4.0" importlib-metadata==6.0.0 ; python_version >= "3.8" and python_version < "4.0" -importlib-resources==6.4.5 ; python_version >= "3.8" and python_version < "3.9" +importlib-resources==6.4.5 ; python_version == "3.8" itsdangerous==2.1.2 ; python_version >= "3.8" and python_version < "4.0" jinja2==3.1.2 ; python_version >= "3.8" and python_version < "4.0" jwcrypto==1.4.2 ; python_version >= "3.8" and python_version < "4.0" @@ -76,7 +74,6 @@ pyasn1==0.4.8 ; python_version >= "3.8" and python_version < "4.0" pycparser==2.21 ; python_version >= "3.8" and python_version < "4.0" pycryptodome==3.17 ; python_version >= "3.8" and python_version < "4.0" pyjwt==2.6.0 ; python_version >= "3.8" and python_version < "4.0" -pyjwt[crypto]==2.6.0 ; python_version >= "3.8" and python_version < "4.0" pymongo==4.6.0 ; python_version >= "3.8" and python_version < "4.0" pyparsing==3.0.9 ; python_version >= "3.8" and python_version < "4.0" pypdf2==3.0.1 ; python_version >= "3.8" and python_version < "4.0" @@ -90,18 +87,20 @@ python-jwt==4.0.0 ; python_version >= "3.8" and python_version < "4.0" python-socketio==5.7.2 ; python_version >= "3.8" and python_version < "4.0" pytz==2022.7.1 ; python_version >= "3.8" and python_version < "4.0" requests-toolbelt==0.10.1 ; python_version >= "3.8" and python_version < "4.0" -requests==2.28.2 ; python_version >= "3.8" and python_version < "4" -rsa==4.9 ; python_version >= "3.8" and python_version < "4" +requests==2.28.2 ; python_version >= "3.8" and python_version < "4.0" +rsa==4.9 ; python_version >= "3.8" and python_version < "4.0" sendgrid==6.9.7 ; python_version >= "3.8" and python_version < "4.0" -setuptools==75.3.0 ; python_version >= "3.8" and python_version < "4.0" +setuptools==75.3.2 ; python_version >= "3.8" and python_version < "3.10" +setuptools==80.9.0 ; python_version >= "3.10" and python_version < "4.0" simple-websocket==0.9.0 ; python_version >= "3.8" and python_version < "4.0" six==1.16.0 ; python_version >= "3.8" and python_version < "4.0" sqlalchemy==2.0.4 ; python_version >= "3.8" and python_version < "4.0" starkbank-ecdsa==2.2.0 ; python_version >= "3.8" and python_version < "4.0" twilio==7.16.4 ; python_version >= "3.8" and python_version < "4.0" typing-extensions==4.5.0 ; python_version >= "3.8" and python_version < "4.0" -tzdata==2025.1 ; python_version >= "3.8" and python_version < "4.0" and platform_system == "Windows" -tzlocal==5.2 ; python_version >= "3.8" and python_version < "4.0" +tzdata==2025.2 ; python_version >= "3.8" and python_version < "4.0" and platform_system == "Windows" +tzlocal==5.2 ; python_version >= "3.8" and python_version < "3.10" +tzlocal==5.3.1 ; python_version >= "3.10" and python_version < "4.0" uritemplate==4.1.1 ; python_version >= "3.8" and python_version < "4.0" urllib3==1.26.14 ; python_version >= "3.8" and python_version < "4.0" werkzeug==2.2.3 ; python_version >= "3.8" and python_version < "4.0" @@ -109,8 +108,9 @@ wrapt==1.15.0 ; python_version >= "3.8" and python_version < "4.0" wsproto==1.2.0 ; python_version >= "3.8" and python_version < "4.0" wtforms-json==0.3.5 ; python_version >= "3.8" and python_version < "4.0" wtforms==3.0.1 ; python_version >= "3.8" and python_version < "4.0" -wtforms[email]==3.0.1 ; python_version >= "3.8" and python_version < "4.0" xlsxwriter==3.0.8 ; python_version >= "3.8" and python_version < "4.0" zipp==3.15.0 ; python_version >= "3.8" and python_version < "4.0" zope-event==4.6 ; python_version >= "3.8" and python_version < "4.0" zope-interface==5.5.2 ; python_version >= "3.8" and python_version < "4.0" +python-magic==0.4.27; sys_platform != "win32" +python-magic-bin==0.4.14; sys_platform == "win32" diff --git a/static.json b/static.json index e1b0d203a..1fa99fe3d 100644 --- a/static.json +++ b/static.json @@ -1,6 +1,15 @@ { "root": "frontend/artifacts/", "clean_urls": true, + "https_only": true, + "headers": { + "/**": { + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block" + } + }, "routes": { "/static/**": "static/", "/**": "index.html"