更新加密验证器

This commit is contained in:
taiyi 2025-11-22 22:59:31 +08:00
parent f0603e40f8
commit 74405da203
12 changed files with 263 additions and 85 deletions

View File

@ -200,9 +200,11 @@ def generate_licenses():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"生成卡密失败: {str(e)}") 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({ return jsonify({
'success': False, 'success': False,
'message': f'生成卡密失败: {str(e)}' 'message': error_message
}), 500 }), 500
except Exception as e: except Exception as e:
@ -657,9 +659,12 @@ def import_licenses():
db.session.commit() db.session.commit()
except Exception as e: except Exception as e:
db.session.rollback() 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({ return jsonify({
'success': False, 'success': False,
'message': f'保存失败: {str(e)}' 'message': error_message
}), 500 }), 500
return jsonify({ return jsonify({

View File

@ -9,6 +9,35 @@ import traceback
import sys import sys
import os import os
from app.utils.logger import log_operation 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']) @api_bp.route('/products', methods=['GET'])
@require_login @require_login
@ -125,42 +154,96 @@ def get_products():
def create_product(): def create_product():
"""创建产品""" """创建产品"""
try: try:
# 检查请求类型
is_multipart = request.content_type and 'multipart/form-data' in request.content_type
# 处理文件上传 # 处理文件上传
image_path = None image_path = None
if 'image' in request.files: if 'image' in request.files:
image_file = request.files['image'] image_file = request.files['image']
if image_file and image_file.filename: if image_file and image_file.filename:
# 确保上传目录存在 # 验证文件类型扩展名和MIME类型
upload_folder = os.path.join(current_app.static_folder, 'producepic') 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) os.makedirs(upload_folder, exist_ok=True)
# 生成唯一文件名 # 生成安全的唯一文件名
import uuid 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) 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_file.save(file_path)
# 保存相对路径到数据库 # 保存相对路径到数据库
image_path = f"/static/producepic/{filename}" image_path = f"/static/producepic/{filename}"
# 处理JSON数据 # 处理数据如果是multipart请求只从form获取否则尝试JSON
data = request.form.get('data') data = {}
if data: if is_multipart or request.files:
import json # multipart/form-data 请求从form中获取数据
data = json.loads(data) 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: else:
# application/json 请求从JSON获取数据
data = request.get_json() or {} 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(): if not data or not data.get('product_name', '').strip():
return jsonify({ return jsonify({
'success': False, 'success': False,
@ -170,6 +253,14 @@ def create_product():
product_name = data.get('product_name', '').strip() product_name = data.get('product_name', '').strip()
description = data.get('description', '').strip() description = data.get('description', '').strip()
custom_id = data.get('product_id', '').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: if not product_name:
return jsonify({ return jsonify({
@ -192,7 +283,7 @@ def create_product():
product_name=product_name, product_name=product_name,
description=description, description=description,
image_path=image_path, image_path=image_path,
status=1 status=status
) )
db.session.add(product) db.session.add(product)
@ -254,41 +345,92 @@ def update_product(product_id):
'message': '产品不存在' 'message': '产品不存在'
}), 404 }), 404
# 检查请求类型
is_multipart = request.content_type and 'multipart/form-data' in request.content_type
# 处理文件上传 # 处理文件上传
if 'image' in request.files: if 'image' in request.files:
image_file = request.files['image'] image_file = request.files['image']
if image_file and image_file.filename: if image_file and image_file.filename:
# 确保上传目录存在 # 验证文件类型扩展名和MIME类型
upload_folder = os.path.join(current_app.static_folder, 'producepic') 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) os.makedirs(upload_folder, exist_ok=True)
# 生成唯一文件名 # 生成安全的唯一文件名
import uuid 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) 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_file.save(file_path)
# 保存相对路径到数据库 # 保存相对路径到数据库
product.image_path = f"/static/producepic/{filename}" product.image_path = f"/static/producepic/{filename}"
# 处理JSON数据 # 处理数据如果是multipart请求只从form获取否则尝试JSON
data = request.form.get('data') data = {}
if data: if is_multipart or request.files:
import json # multipart/form-data 请求从form中获取数据
data = json.loads(data) 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: else:
# application/json 请求从JSON获取数据
data = request.get_json() or {} 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: if not data:
return jsonify({ return jsonify({
'success': False, 'success': False,
@ -300,7 +442,14 @@ def update_product(product_id):
if 'description' in data: if 'description' in data:
product.description = data['description'].strip() product.description = data['description'].strip()
if 'status' in data: 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() db.session.commit()

View File

@ -227,11 +227,13 @@ def create_version():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f"创建版本失败: {str(e)}")
# 添加更详细的错误信息
import traceback import traceback
current_app.logger.error(f"创建版本失败: {str(e)}")
current_app.logger.error(f"创建版本失败详细信息: {traceback.format_exc()}") 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/<int:version_id>/publish', methods=['POST']) @api_bp.route('/versions/<int:version_id>/publish', methods=['POST'])
@require_login @require_login
@ -410,10 +412,13 @@ def upload_version_file():
return jsonify({'success': False, 'message': '文件上传失败'}), 400 return jsonify({'success': False, 'message': '文件上传失败'}), 400
except Exception as e: except Exception as e:
current_app.logger.error(f"文件上传失败: {str(e)}")
import traceback import traceback
current_app.logger.error(f"文件上传失败: {str(e)}")
current_app.logger.error(f"文件上传失败详细信息: {traceback.format_exc()}") 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): def calculate_file_hash(file_path):
"""计算文件SHA256哈希值""" """计算文件SHA256哈希值"""

View File

@ -19,22 +19,28 @@ def login():
if request.method == 'POST': if request.method == 'POST':
current_app.logger.info(f"收到登录请求 - IP: {request.remote_addr}, User-Agent: {request.headers.get('User-Agent')}") 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') 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: 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')}") current_app.logger.warning(f"登录请求缺少CSRF令牌 - IP: {request.remote_addr}")
# 在生产环境中如果缺少CSRF令牌记录警告但继续处理 # 对于AJAX请求至少检查X-Requested-With头
# 这有助于解决域名部署时的CSRF问题 if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
if current_app.config.get('FLASK_ENV') == 'production': # AJAX请求必须来自同源或可信源
current_app.logger.info("生产环境: 缺少CSRF令牌但继续处理登录请求") if not origin and not referer:
else: current_app.logger.warning("可疑的AJAX请求缺少Origin和Referer头")
# 开发环境严格验证
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({ return jsonify({
'success': False, 'success': False,
'message': '缺少CSRF令牌,请刷新页面后重试' 'message': '请求验证失败,请刷新页面后重试'
}), 400 }), 400
flash('缺少CSRF令牌请刷新页面后重试', 'error') # 对于表单提交检查Referer
elif not referer:
current_app.logger.warning("可疑的表单提交缺少Referer头")
flash('请求验证失败,请刷新页面后重试', 'error')
return render_template('login.html') return render_template('login.html')
username = request.form.get('username', '').strip() username = request.form.get('username', '').strip()

View File

@ -393,8 +393,11 @@
} }
showLoading(); showLoading();
// 检查body是否是FormData如果是则不设置Content-Type让浏览器自动设置
const isFormData = options.body instanceof FormData;
const defaultOptions = { const defaultOptions = {
headers: { headers: isFormData ? {} : {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
credentials: 'same-origin' // 重要: 使用session cookies credentials: 'same-origin' // 重要: 使用session cookies
@ -463,6 +466,19 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
// HTML转义函数 - 防止XSS攻击
function escapeHtml(text) {
if (text == null) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, m => map[m]);
}
// 显示通知 - 统一使用静态JS中的函数 // 显示通知 - 统一使用静态JS中的函数
function showNotification(message, type = 'info') { function showNotification(message, type = 'info') {
// 创建通知元素 // 创建通知元素

View File

@ -144,13 +144,6 @@ function createProduct() {
method: 'POST', method: 'POST',
body: formData body: formData
}) })
.then(response => {
if (response.status === 401) {
window.location.href = '/login';
throw new Error('未授权访问');
}
return response.json();
})
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showNotification('产品创建成功', 'success'); showNotification('产品创建成功', 'success');
@ -164,7 +157,10 @@ function createProduct() {
}) })
.catch(error => { .catch(error => {
console.error('Failed to create product:', error); console.error('Failed to create product:', error);
showNotification('网络错误,请稍后重试', 'error'); // apiRequest 已经处理了 401 等错误,这里只处理其他错误
if (error.message !== 'SESSION_EXPIRED') {
showNotification('网络错误,请稍后重试', 'error');
}
}) })
.finally(() => { .finally(() => {
// 恢复按钮状态 // 恢复按钮状态

View File

@ -152,13 +152,6 @@ function updateProduct() {
method: 'PUT', method: 'PUT',
body: formData body: formData
}) })
.then(response => {
if (response.status === 401) {
window.location.href = '/login';
throw new Error('未授权访问');
}
return response.json();
})
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showNotification('产品更新成功', 'success'); showNotification('产品更新成功', 'success');
@ -172,7 +165,10 @@ function updateProduct() {
}) })
.catch(error => { .catch(error => {
console.error('Failed to update product:', error); console.error('Failed to update product:', error);
showNotification('网络错误,请稍后重试', 'error'); // apiRequest 已经处理了 401 等错误,这里只处理其他错误
if (error.message !== 'SESSION_EXPIRED') {
showNotification('网络错误,请稍后重试', 'error');
}
}) })
.finally(() => { .finally(() => {
// 恢复按钮状态 // 恢复按钮状态

View File

@ -285,20 +285,20 @@ function renderProductList(products) {
tbody.innerHTML = products.map(product => ` tbody.innerHTML = products.map(product => `
<tr> <tr>
<td> <td>
<input type="checkbox" class="product-checkbox" data-product-id="${product.product_id}"> <input type="checkbox" class="product-checkbox" data-product-id="${escapeHtml(product.product_id)}">
</td> </td>
<td><code>${product.product_id}</code></td> <td><code>${escapeHtml(product.product_id)}</code></td>
<td> <td>
<strong>${product.product_name}</strong> <strong>${escapeHtml(product.product_name)}</strong>
${product.latest_version ? `<br><small class="text-muted">最新版本: ${product.latest_version}</small>` : ''} ${product.latest_version ? `<br><small class="text-muted">最新版本: ${escapeHtml(product.latest_version)}</small>` : ''}
</td> </td>
<td>${product.description || '-'}</td> <td>${escapeHtml(product.description || '-')}</td>
<td> <td>
${product.image_path ? `<img src="${product.image_path}" alt="产品图片" style="max-width: 50px; max-height: 50px;">` : '-'} ${product.image_path ? `<img src="${escapeHtml(product.image_path)}" alt="产品图片" style="max-width: 50px; max-height: 50px;">` : '-'}
</td> </td>
<td> <td>
<span class="badge ${product.status === 1 ? 'bg-success' : 'bg-secondary'}"> <span class="badge ${product.status === 1 ? 'bg-success' : 'bg-secondary'}">
${product.status_name} ${escapeHtml(product.status_name)}
</span> </span>
</td> </td>
<td> <td>

View File

@ -63,7 +63,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="bg-light p-3 rounded"> <div class="bg-light p-3 rounded">
{{ ticket.content|safe }} {{ ticket.content|e|nl2br }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -336,19 +336,19 @@ function renderTicketList(tickets) {
tbody.innerHTML = tickets.map(ticket => ` tbody.innerHTML = tickets.map(ticket => `
<tr> <tr>
<td> <td>
<input type="checkbox" class="ticket-checkbox" data-ticket-id="${ticket.ticket_id}"> <input type="checkbox" class="ticket-checkbox" data-ticket-id="${escapeHtml(ticket.ticket_id)}">
</td> </td>
<td> <td>
<code>${ticket.ticket_id}</code> <code>${escapeHtml(ticket.ticket_id)}</code>
</td> </td>
<td> <td>
<a href="/tickets/${ticket.ticket_id}" class="text-decoration-none"> <a href="/tickets/${escapeHtml(ticket.ticket_id)}" class="text-decoration-none">
${ticket.title} ${escapeHtml(ticket.title)}
</a> </a>
${ticket.reply_count > 0 ? `<span class="badge bg-primary ms-1">${ticket.reply_count}</span>` : ''} ${ticket.reply_count > 0 ? `<span class="badge bg-primary ms-1">${ticket.reply_count}</span>` : ''}
</td> </td>
<td> <td>
${ticket.product_name || '-'} ${escapeHtml(ticket.product_name || '-')}
</td> </td>
<td> <td>
${getPriorityBadge(ticket.priority)} ${getPriorityBadge(ticket.priority)}

View File

@ -5,7 +5,12 @@ from logging.handlers import TimedRotatingFileHandler
class Config: 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 # 数据库配置 - 优先使用环境变量中的DATABASE_URL
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///instance/kamaxitong.db') SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///instance/kamaxitong.db')

Binary file not shown.