diff --git a/app/api/license.py b/app/api/license.py index bb71439..a01903b 100644 --- a/app/api/license.py +++ b/app/api/license.py @@ -200,9 +200,11 @@ def generate_licenses(): except Exception as e: db.session.rollback() current_app.logger.error(f"生成卡密失败: {str(e)}") + is_production = current_app.config.get('FLASK_ENV') == 'production' or not current_app.config.get('DEBUG', False) + error_message = '生成卡密失败,请稍后重试' if is_production else f'生成卡密失败: {str(e)}' return jsonify({ 'success': False, - 'message': f'生成卡密失败: {str(e)}' + 'message': error_message }), 500 except Exception as e: @@ -657,9 +659,12 @@ def import_licenses(): db.session.commit() except Exception as e: db.session.rollback() + current_app.logger.error(f"保存卡密失败: {str(e)}") + is_production = current_app.config.get('FLASK_ENV') == 'production' or not current_app.config.get('DEBUG', False) + error_message = '保存失败,请稍后重试' if is_production else f'保存失败: {str(e)}' return jsonify({ 'success': False, - 'message': f'保存失败: {str(e)}' + 'message': error_message }), 500 return jsonify({ diff --git a/app/api/product.py b/app/api/product.py index bf760e4..acdeb2f 100644 --- a/app/api/product.py +++ b/app/api/product.py @@ -9,6 +9,35 @@ import traceback import sys import os from app.utils.logger import log_operation +from werkzeug.utils import secure_filename + +# 允许的图片文件扩展名 +ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'} +# 允许的MIME类型 +ALLOWED_IMAGE_MIMES = { + 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', + 'image/webp', 'image/bmp', 'image/x-ms-bmp' +} +# 最大图片文件大小(5MB) +MAX_IMAGE_SIZE = 5 * 1024 * 1024 + +def allowed_image_file(filename): + """检查文件扩展名是否允许""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + +def validate_image_file(file, filename): + """验证图片文件(扩展名和MIME类型)""" + # 检查扩展名 + if not allowed_image_file(filename): + return False, '不支持的文件类型,只允许上传图片文件(PNG、JPG、JPEG、GIF、WEBP、BMP)' + + # 检查MIME类型 + if hasattr(file, 'content_type') and file.content_type: + if file.content_type not in ALLOWED_IMAGE_MIMES: + return False, f'不支持的文件MIME类型: {file.content_type}' + + return True, None @api_bp.route('/products', methods=['GET']) @require_login @@ -125,42 +154,96 @@ def get_products(): def create_product(): """创建产品""" try: + # 检查请求类型 + is_multipart = request.content_type and 'multipart/form-data' in request.content_type + # 处理文件上传 image_path = None if 'image' in request.files: image_file = request.files['image'] if image_file and image_file.filename: - # 确保上传目录存在 - upload_folder = os.path.join(current_app.static_folder, 'producepic') + # 验证文件类型(扩展名和MIME类型) + is_valid, error_msg = validate_image_file(image_file, image_file.filename) + if not is_valid: + return jsonify({ + 'success': False, + 'message': error_msg + }), 400 + + # 验证文件大小(优先使用content_length,否则读取文件) + file_size = None + if request.content_length: + file_size = request.content_length + else: + # 备用方案:读取文件大小 + image_file.seek(0, os.SEEK_END) + file_size = image_file.tell() + image_file.seek(0) # 重置文件指针 + + if file_size and file_size > MAX_IMAGE_SIZE: + return jsonify({ + 'success': False, + 'message': f'文件大小超过限制,最大允许 {MAX_IMAGE_SIZE / (1024*1024):.1f}MB' + }), 400 + + # 确保上传目录存在(使用绝对路径) + static_folder = os.path.abspath(current_app.static_folder) + upload_folder = os.path.join(static_folder, 'producepic') + # 验证路径安全性(确保在static目录下) + if not upload_folder.startswith(static_folder): + return jsonify({ + 'success': False, + 'message': '无效的上传路径' + }), 400 + os.makedirs(upload_folder, exist_ok=True) - # 生成唯一文件名 + # 生成安全的唯一文件名 import uuid - filename = f"{uuid.uuid4().hex}_{image_file.filename}" + original_filename = secure_filename(image_file.filename) + file_ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else 'jpg' + filename = f"{uuid.uuid4().hex}.{file_ext}" file_path = os.path.join(upload_folder, filename) + # 再次验证路径安全性 + real_path = os.path.abspath(file_path) + if not real_path.startswith(os.path.abspath(upload_folder)): + return jsonify({ + 'success': False, + 'message': '文件路径不安全' + }), 400 + # 保存文件 image_file.save(file_path) # 保存相对路径到数据库 image_path = f"/static/producepic/{filename}" - # 处理JSON数据 - data = request.form.get('data') - if data: - import json - data = json.loads(data) + # 处理数据:如果是multipart请求,只从form获取;否则尝试JSON + data = {} + if is_multipart or request.files: + # multipart/form-data 请求:从form中获取数据 + data_str = request.form.get('data') + if data_str: + import json + try: + data = json.loads(data_str) + except json.JSONDecodeError: + current_app.logger.warning(f"无法解析form中的data字段: {data_str[:100]}") + data = {} + + # 如果仍然没有数据,尝试从form中直接获取各个字段 + if not data: + data = { + 'product_name': request.form.get('product_name', ''), + 'description': request.form.get('description', ''), + 'product_id': request.form.get('product_id', ''), + 'status': request.form.get('status', '1') + } else: + # application/json 请求:从JSON获取数据 data = request.get_json() or {} - # 如果仍然没有数据,尝试从form中获取 - if not data: - data = { - 'product_name': request.form.get('product_name', ''), - 'description': request.form.get('description', ''), - 'product_id': request.form.get('product_id', '') - } - if not data or not data.get('product_name', '').strip(): return jsonify({ 'success': False, @@ -170,6 +253,14 @@ def create_product(): product_name = data.get('product_name', '').strip() description = data.get('description', '').strip() custom_id = data.get('product_id', '').strip() + # 处理 status 字段:确保是整数类型 + status = data.get('status', 1) + try: + status = int(status) if status else 1 + except (ValueError, TypeError): + status = 1 + # 确保 status 只能是 0 或 1 + status = 1 if status not in [0, 1] else status if not product_name: return jsonify({ @@ -192,7 +283,7 @@ def create_product(): product_name=product_name, description=description, image_path=image_path, - status=1 + status=status ) db.session.add(product) @@ -254,41 +345,92 @@ def update_product(product_id): 'message': '产品不存在' }), 404 + # 检查请求类型 + is_multipart = request.content_type and 'multipart/form-data' in request.content_type + # 处理文件上传 if 'image' in request.files: image_file = request.files['image'] if image_file and image_file.filename: - # 确保上传目录存在 - upload_folder = os.path.join(current_app.static_folder, 'producepic') + # 验证文件类型(扩展名和MIME类型) + is_valid, error_msg = validate_image_file(image_file, image_file.filename) + if not is_valid: + return jsonify({ + 'success': False, + 'message': error_msg + }), 400 + + # 验证文件大小(优先使用content_length) + file_size = None + if request.content_length: + file_size = request.content_length + else: + image_file.seek(0, os.SEEK_END) + file_size = image_file.tell() + image_file.seek(0) + + if file_size and file_size > MAX_IMAGE_SIZE: + return jsonify({ + 'success': False, + 'message': f'文件大小超过限制,最大允许 {MAX_IMAGE_SIZE / (1024*1024):.1f}MB' + }), 400 + + # 确保上传目录存在(使用绝对路径) + static_folder = os.path.abspath(current_app.static_folder) + upload_folder = os.path.join(static_folder, 'producepic') + if not upload_folder.startswith(static_folder): + return jsonify({ + 'success': False, + 'message': '无效的上传路径' + }), 400 + os.makedirs(upload_folder, exist_ok=True) - # 生成唯一文件名 + # 生成安全的唯一文件名 import uuid - filename = f"{uuid.uuid4().hex}_{image_file.filename}" + original_filename = secure_filename(image_file.filename) + file_ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else 'jpg' + filename = f"{uuid.uuid4().hex}.{file_ext}" file_path = os.path.join(upload_folder, filename) + # 再次验证路径安全性 + real_path = os.path.abspath(file_path) + if not real_path.startswith(os.path.abspath(upload_folder)): + return jsonify({ + 'success': False, + 'message': '文件路径不安全' + }), 400 + # 保存文件 image_file.save(file_path) # 保存相对路径到数据库 product.image_path = f"/static/producepic/{filename}" - # 处理JSON数据 - data = request.form.get('data') - if data: - import json - data = json.loads(data) + # 处理数据:如果是multipart请求,只从form获取;否则尝试JSON + data = {} + if is_multipart or request.files: + # multipart/form-data 请求:从form中获取数据 + data_str = request.form.get('data') + if data_str: + import json + try: + data = json.loads(data_str) + except json.JSONDecodeError: + current_app.logger.warning(f"无法解析form中的data字段: {data_str[:100]}") + data = {} + + # 如果仍然没有数据,尝试从form中直接获取各个字段 + if not data: + data = { + 'product_name': request.form.get('product_name', ''), + 'description': request.form.get('description', ''), + 'status': request.form.get('status', '') + } else: + # application/json 请求:从JSON获取数据 data = request.get_json() or {} - # 如果仍然没有数据,尝试从form中获取 - if not data: - data = { - 'product_name': request.form.get('product_name', ''), - 'description': request.form.get('description', ''), - 'status': request.form.get('status', '') - } - if not data: return jsonify({ 'success': False, @@ -300,7 +442,14 @@ def update_product(product_id): if 'description' in data: product.description = data['description'].strip() if 'status' in data: - product.status = data['status'] + # 确保 status 是整数类型 + try: + status = int(data['status']) if data['status'] else product.status + # 确保 status 只能是 0 或 1 + product.status = 1 if status not in [0, 1] else status + except (ValueError, TypeError): + # 如果转换失败,保持原值 + pass db.session.commit() diff --git a/app/api/version.py b/app/api/version.py index bb8a3c7..fbb876b 100644 --- a/app/api/version.py +++ b/app/api/version.py @@ -227,11 +227,13 @@ def create_version(): except Exception as e: db.session.rollback() - current_app.logger.error(f"创建版本失败: {str(e)}") - # 添加更详细的错误信息 import traceback + current_app.logger.error(f"创建版本失败: {str(e)}") current_app.logger.error(f"创建版本失败详细信息: {traceback.format_exc()}") - return jsonify({'success': False, 'message': f'服务器内部错误: {str(e)}'}), 500 + # 生产环境不泄露详细错误信息 + is_production = current_app.config.get('FLASK_ENV') == 'production' or not current_app.config.get('DEBUG', False) + error_message = '服务器内部错误' if is_production else f'服务器内部错误: {str(e)}' + return jsonify({'success': False, 'message': error_message}), 500 @api_bp.route('/versions//publish', methods=['POST']) @require_login @@ -410,10 +412,13 @@ def upload_version_file(): return jsonify({'success': False, 'message': '文件上传失败'}), 400 except Exception as e: - current_app.logger.error(f"文件上传失败: {str(e)}") import traceback + current_app.logger.error(f"文件上传失败: {str(e)}") current_app.logger.error(f"文件上传失败详细信息: {traceback.format_exc()}") - return jsonify({'success': False, 'message': f'文件上传失败: {str(e)}'}), 500 + # 生产环境不泄露详细错误信息 + is_production = current_app.config.get('FLASK_ENV') == 'production' or not current_app.config.get('DEBUG', False) + error_message = '文件上传失败,请稍后重试' if is_production else f'文件上传失败: {str(e)}' + return jsonify({'success': False, 'message': error_message}), 500 def calculate_file_hash(file_path): """计算文件SHA256哈希值""" diff --git a/app/web/__init__.py b/app/web/__init__.py index 1a26885..e84007f 100644 --- a/app/web/__init__.py +++ b/app/web/__init__.py @@ -19,22 +19,28 @@ def login(): if request.method == 'POST': current_app.logger.info(f"收到登录请求 - IP: {request.remote_addr}, User-Agent: {request.headers.get('User-Agent')}") - # 检查CSRF令牌 - 生产环境部署时更宽松的验证 + # 检查CSRF令牌 - 至少验证请求来源 csrf_token = request.form.get('csrf_token') + origin = request.headers.get('Origin') + referer = request.headers.get('Referer') + x_requested_with = request.headers.get('X-Requested-With') + + # 如果缺少CSRF令牌,至少验证请求来源 if not csrf_token: - current_app.logger.warning(f"登录请求缺少CSRF令牌 - IP: {request.remote_addr}, User-Agent: {request.headers.get('User-Agent')}, Origin: {request.headers.get('Origin')}, Referer: {request.headers.get('Referer')}") - # 在生产环境中,如果缺少CSRF令牌,记录警告但继续处理 - # 这有助于解决域名部署时的CSRF问题 - if current_app.config.get('FLASK_ENV') == 'production': - current_app.logger.info("生产环境: 缺少CSRF令牌但继续处理登录请求") - else: - # 开发环境严格验证 - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + current_app.logger.warning(f"登录请求缺少CSRF令牌 - IP: {request.remote_addr}") + # 对于AJAX请求,至少检查X-Requested-With头 + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + # AJAX请求必须来自同源或可信源 + if not origin and not referer: + current_app.logger.warning("可疑的AJAX请求:缺少Origin和Referer头") return jsonify({ 'success': False, - 'message': '缺少CSRF令牌,请刷新页面后重试' + 'message': '请求验证失败,请刷新页面后重试' }), 400 - flash('缺少CSRF令牌,请刷新页面后重试', 'error') + # 对于表单提交,检查Referer + elif not referer: + current_app.logger.warning("可疑的表单提交:缺少Referer头") + flash('请求验证失败,请刷新页面后重试', 'error') return render_template('login.html') username = request.form.get('username', '').strip() diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 02971b7..624909a 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -393,8 +393,11 @@ } showLoading(); + + // 检查body是否是FormData,如果是则不设置Content-Type(让浏览器自动设置) + const isFormData = options.body instanceof FormData; const defaultOptions = { - headers: { + headers: isFormData ? {} : { 'Content-Type': 'application/json' }, credentials: 'same-origin' // 重要: 使用session cookies @@ -463,6 +466,19 @@ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } + // HTML转义函数 - 防止XSS攻击 + function escapeHtml(text) { + if (text == null) return ''; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return String(text).replace(/[&<>"']/g, m => map[m]); + } + // 显示通知 - 统一使用静态JS中的函数 function showNotification(message, type = 'info') { // 创建通知元素 diff --git a/app/web/templates/product/create.html b/app/web/templates/product/create.html index 8319b86..070d045 100644 --- a/app/web/templates/product/create.html +++ b/app/web/templates/product/create.html @@ -144,13 +144,6 @@ function createProduct() { method: 'POST', body: formData }) - .then(response => { - if (response.status === 401) { - window.location.href = '/login'; - throw new Error('未授权访问'); - } - return response.json(); - }) .then(data => { if (data.success) { showNotification('产品创建成功', 'success'); @@ -164,7 +157,10 @@ function createProduct() { }) .catch(error => { console.error('Failed to create product:', error); - showNotification('网络错误,请稍后重试', 'error'); + // apiRequest 已经处理了 401 等错误,这里只处理其他错误 + if (error.message !== 'SESSION_EXPIRED') { + showNotification('网络错误,请稍后重试', 'error'); + } }) .finally(() => { // 恢复按钮状态 diff --git a/app/web/templates/product/edit.html b/app/web/templates/product/edit.html index 676ccc5..c4200d8 100644 --- a/app/web/templates/product/edit.html +++ b/app/web/templates/product/edit.html @@ -152,13 +152,6 @@ function updateProduct() { method: 'PUT', body: formData }) - .then(response => { - if (response.status === 401) { - window.location.href = '/login'; - throw new Error('未授权访问'); - } - return response.json(); - }) .then(data => { if (data.success) { showNotification('产品更新成功', 'success'); @@ -172,7 +165,10 @@ function updateProduct() { }) .catch(error => { console.error('Failed to update product:', error); - showNotification('网络错误,请稍后重试', 'error'); + // apiRequest 已经处理了 401 等错误,这里只处理其他错误 + if (error.message !== 'SESSION_EXPIRED') { + showNotification('网络错误,请稍后重试', 'error'); + } }) .finally(() => { // 恢复按钮状态 diff --git a/app/web/templates/product/list.html b/app/web/templates/product/list.html index 9d2454f..9cf92a1 100644 --- a/app/web/templates/product/list.html +++ b/app/web/templates/product/list.html @@ -285,20 +285,20 @@ function renderProductList(products) { tbody.innerHTML = products.map(product => ` - + - ${product.product_id} + ${escapeHtml(product.product_id)} - ${product.product_name} - ${product.latest_version ? `
最新版本: ${product.latest_version}` : ''} + ${escapeHtml(product.product_name)} + ${product.latest_version ? `
最新版本: ${escapeHtml(product.latest_version)}` : ''} - ${product.description || '-'} + ${escapeHtml(product.description || '-')} - ${product.image_path ? `产品图片` : '-'} + ${product.image_path ? `产品图片` : '-'} - ${product.status_name} + ${escapeHtml(product.status_name)} diff --git a/app/web/templates/ticket/detail.html b/app/web/templates/ticket/detail.html index d383a66..a2d5acc 100644 --- a/app/web/templates/ticket/detail.html +++ b/app/web/templates/ticket/detail.html @@ -63,7 +63,7 @@
- {{ ticket.content|safe }} + {{ ticket.content|e|nl2br }}
diff --git a/app/web/templates/ticket/list.html b/app/web/templates/ticket/list.html index c87089c..50e6c7f 100644 --- a/app/web/templates/ticket/list.html +++ b/app/web/templates/ticket/list.html @@ -336,19 +336,19 @@ function renderTicketList(tickets) { tbody.innerHTML = tickets.map(ticket => ` - + - ${ticket.ticket_id} + ${escapeHtml(ticket.ticket_id)} - - ${ticket.title} + + ${escapeHtml(ticket.title)} ${ticket.reply_count > 0 ? `${ticket.reply_count}` : ''} - ${ticket.product_name || '-'} + ${escapeHtml(ticket.product_name || '-')} ${getPriorityBadge(ticket.priority)} diff --git a/config.py b/config.py index 3a329e1..62bcdac 100644 --- a/config.py +++ b/config.py @@ -5,7 +5,12 @@ from logging.handlers import TimedRotatingFileHandler class Config: """基础配置类""" - SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' + # SECRET_KEY必须从环境变量获取,生产环境不允许使用默认值 + SECRET_KEY = os.environ.get('SECRET_KEY') + if not SECRET_KEY: + import sys + print("警告: SECRET_KEY未设置,使用不安全的默认值。生产环境必须设置SECRET_KEY环境变量!", file=sys.stderr) + SECRET_KEY = 'dev-secret-key-change-in-production' # 数据库配置 - 优先使用环境变量中的DATABASE_URL SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///instance/kamaxitong.db') diff --git a/master.zip b/master.zip index 05f191e..942532c 100644 Binary files a/master.zip and b/master.zip differ