安全加固:CORS白名单、JWT密钥自动生成、SSRF防护、路径遍历与错误信息泄露修复
Build Backend JAR / build (push) Has been cancelled

This commit is contained in:
2026-08-25 18:05:28 +08:00
parent c8fb93ff29
commit 338493ecaa
12 changed files with 168 additions and 31 deletions
@@ -2,6 +2,7 @@ package com.nanri.aiimage.common.security;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -12,8 +13,11 @@ import java.security.MessageDigest;
import java.util.Base64; import java.util.Base64;
@Service @Service
@Slf4j
public class ShopCredentialCryptoService { public class ShopCredentialCryptoService {
private static final String INSECURE_DEFAULT_KEY = "change-me-shop-credential-key";
@Value("${aiimage.security.shop-credential-key:change-me-shop-credential-key}") @Value("${aiimage.security.shop-credential-key:change-me-shop-credential-key}")
private String rawKey; private String rawKey;
@@ -22,6 +26,10 @@ public class ShopCredentialCryptoService {
@PostConstruct @PostConstruct
public void init() { public void init() {
try { try {
if (rawKey == null || rawKey.isBlank() || INSECURE_DEFAULT_KEY.equals(rawKey.trim())) {
log.warn("[security] AIIMAGE_SHOP_CREDENTIAL_KEY 未配置或仍为默认值,店铺凭据加密强度不足,"
+ "请通过环境变量 AIIMAGE_SHOP_CREDENTIAL_KEY 配置固定密钥");
}
MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] full = sha256.digest(rawKey.getBytes(StandardCharsets.UTF_8)); byte[] full = sha256.digest(rawKey.getBytes(StandardCharsets.UTF_8));
byte[] key16 = new byte[16]; byte[] key16 = new byte[16];
@@ -27,9 +27,15 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*")); // 限制为可信域名,支持本地开发和生产环境
configuration.setAllowedOriginPatterns(List.of(
"http://localhost:*",
"http://127.0.0.1:*",
"https://*.aishufu.top",
"http://*.aishufu.top"
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "X-Device-Id", "X-Internal-Token"));
configuration.setExposedHeaders(List.of("Content-Disposition")); configuration.setExposedHeaders(List.of("Content-Disposition"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L); configuration.setMaxAge(3600L);
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Date; import java.util.Date;
@@ -20,16 +21,42 @@ import java.util.Date;
@Slf4j @Slf4j
public class JwtService { public class JwtService {
private static final String INSECURE_DEFAULT_SECRET = "please-change-this-secret-please-rotate-at-least-32-bytes";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final AuthProperties props; private final AuthProperties props;
private volatile SecretKey cachedKey;
private SecretKey signingKey() { private SecretKey signingKey() {
byte[] keyBytes = props.getJwtSecret().getBytes(StandardCharsets.UTF_8); SecretKey key = cachedKey;
if (key == null) {
synchronized (this) {
key = cachedKey;
if (key == null) {
String configured = props.getJwtSecret();
boolean insecure = configured == null || configured.isBlank()
|| INSECURE_DEFAULT_SECRET.equals(configured.trim());
byte[] keyBytes;
if (insecure) {
keyBytes = new byte[32];
SECURE_RANDOM.nextBytes(keyBytes);
log.warn("[auth] AIIMAGE_JWT_SECRET 未配置或仍为默认值,已自动生成随机密钥;"
+ "服务重启后已签发的 token 将失效,请通过环境变量 AIIMAGE_JWT_SECRET 配置固定密钥");
} else {
keyBytes = configured.getBytes(StandardCharsets.UTF_8);
}
if (keyBytes.length < 32) { if (keyBytes.length < 32) {
byte[] padded = new byte[32]; byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length); System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
keyBytes = padded; keyBytes = padded;
} }
return Keys.hmacShaKeyFor(keyBytes); key = Keys.hmacShaKeyFor(keyBytes);
cachedKey = key;
}
}
}
return key;
} }
public String issue(Long userId, String username, String deviceId) { public String issue(Long userId, String username, String deviceId) {
+4
View File
@@ -26,6 +26,7 @@ from config import (
accessKeyId, accessKeyId,
accessKeySecret, accessKeySecret,
) )
from utils.ssrf import is_internal_url
_client = None _client = None
_client_lock = threading.Lock() _client_lock = threading.Lock()
@@ -180,6 +181,9 @@ def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
del file_content del file_content
continue continue
if is_internal_url(data_url):
raise ValueError("拒绝下载内网/本机地址的图片")
with requests.get(data_url, stream=True, timeout=_REMOTE_IMAGE_TIMEOUT) as response: with requests.get(data_url, stream=True, timeout=_REMOTE_IMAGE_TIMEOUT) as response:
response.raise_for_status() response.raise_for_status()
content_length = response.headers.get("Content-Length") content_length = response.headers.get("Content-Length")
+18 -1
View File
@@ -18,11 +18,28 @@ from blueprints.version import version_bp
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR) app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
CORS(app) # CORS配置:限制为可信域名
CORS(app, resources={
r"/api/*": {
"origins": [
"http://localhost:*",
"http://127.0.0.1:*",
"https://*.aishufu.top",
"http://*.aishufu.top"
],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"allow_headers": ["Authorization", "Content-Type", "X-Requested-With", "X-Device-Id"],
"supports_credentials": True
}
})
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32)) app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
# 文件上传大小限制:2GB(数字人 ZIP 包等大文件) # 文件上传大小限制:2GB(数字人 ZIP 包等大文件)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024
# 会话安全配置
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# 注册蓝图 # 注册蓝图
app.register_blueprint(auth) app.register_blueprint(auth)
+18 -11
View File
@@ -11,7 +11,6 @@ import zipfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
import traceback
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
@@ -33,6 +32,7 @@ from werkzeug.security import generate_password_hash
from utils.db import get_db from utils.db import get_db
from utils.auth import admin_required, login_required, get_current_admin_role from utils.auth import admin_required, login_required, get_current_admin_role
from utils.ssrf import is_internal_url
from ali_oss import upload_fileobj as oss_upload_fileobj from ali_oss import upload_fileobj as oss_upload_fileobj
@@ -47,6 +47,12 @@ _backend_java_session_local = threading.local()
_internal_token_lock = threading.Lock() _internal_token_lock = threading.Lock()
IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data' IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data'
SHOP_DATA_CRAWL_DATA_PERMISSION_KEY = 'admin_shop_data_crawl_task_data' SHOP_DATA_CRAWL_DATA_PERMISSION_KEY = 'admin_shop_data_crawl_task_data'
def _internal_error(exc, status=500):
"""记录内部错误日志,仅向客户端返回通用消息,避免泄露内部信息。"""
current_app.logger.error('[admin_api] internal error: %s', exc, exc_info=True)
return jsonify({'success': False, 'error': '服务器内部错误,请稍后重试'}), status
try: try:
VERSION_UPLOAD_MAX_BYTES = int(os.environ.get('VERSION_UPLOAD_MAX_BYTES', str(512 * 1024 * 1024))) VERSION_UPLOAD_MAX_BYTES = int(os.environ.get('VERSION_UPLOAD_MAX_BYTES', str(512 * 1024 * 1024)))
except ValueError: except ValueError:
@@ -1022,7 +1028,7 @@ def list_users():
'admins': admins, 'admins': admins,
}) })
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}) return _internal_error(e)
@admin_api.route('/user', methods=['POST']) @admin_api.route('/user', methods=['POST'])
@admin_required @admin_required
@@ -1113,7 +1119,7 @@ def create_user():
except pymysql.IntegrityError: except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'}) return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}) return _internal_error(e)
@admin_api.route('/user/<int:uid>', methods=['PUT']) @admin_api.route('/user/<int:uid>', methods=['PUT'])
@@ -1201,7 +1207,7 @@ def update_user(uid):
pass pass
return jsonify({'success': False, 'error': str(exc)}), 403 return jsonify({'success': False, 'error': str(exc)}), 403
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}) return _internal_error(e)
@admin_api.route('/user/<int:uid>', methods=['DELETE']) @admin_api.route('/user/<int:uid>', methods=['DELETE'])
@@ -1239,7 +1245,7 @@ def delete_user(uid):
return jsonify({'success': False, 'error': '用户不存在'}) return jsonify({'success': False, 'error': '用户不存在'})
return jsonify({'success': True, 'msg': '删除成功'}) return jsonify({'success': True, 'msg': '删除成功'})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}) return _internal_error(e)
# ---------- 生成历史 ---------- # ---------- 生成历史 ----------
@@ -1311,7 +1317,7 @@ def history():
}) })
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size}) return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}) return _internal_error(e)
# ---------- 栏目权限配置 ---------- # ---------- 栏目权限配置 ----------
@@ -1634,7 +1640,7 @@ def list_shop_data_crawl_tasks():
except ValueError as exc: except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400 return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc: except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500 return _internal_error(exc)
def _load_shop_data_crawl_download_rows(result_ids): def _load_shop_data_crawl_download_rows(result_ids):
@@ -1914,6 +1920,8 @@ def download_image_video_tasks_zip():
filename = f'task-{task_id}-video-{video_index + 1}.{extension}' filename = f'task-{task_id}-video-{video_index + 1}.{extension}'
remote_response = None remote_response = None
try: try:
if is_internal_url(url):
raise ValueError('拒绝下载内网/本机地址的视频')
remote_response = requests.get(url, stream=True, timeout=(10, 120)) remote_response = requests.get(url, stream=True, timeout=(10, 120))
remote_response.raise_for_status() remote_response.raise_for_status()
with output_zip.open(filename, mode='w', force_zip64=True) as target: with output_zip.open(filename, mode='w', force_zip64=True) as target:
@@ -2017,7 +2025,7 @@ def list_image_video_tasks():
except ValueError as exc: except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400 return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc: except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500 return _internal_error(exc)
@admin_api.route('/image-video-tasks/<int:task_id>') @admin_api.route('/image-video-tasks/<int:task_id>')
@@ -2573,7 +2581,7 @@ def list_versions():
] ]
return jsonify({'success': True, 'items': items}) return jsonify({'success': True, 'items': items})
except Exception as e: except Exception as e:
return jsonify({'success': False, 'error': str(e)}) return _internal_error(e)
# ========== 数字人版本管理(代理到 Java 后端)========== # ========== 数字人版本管理(代理到 Java 后端)==========
@@ -2778,8 +2786,7 @@ def upload_version():
'msg': '上传成功', 'msg': '上传成功',
}) })
except Exception as e: except Exception as e:
traceback.print_exc() return _internal_error(e)
return jsonify({'success': False, 'error': str(e)})
finally: finally:
if conn is not None: if conn is not None:
try: try:
+2 -1
View File
@@ -49,8 +49,9 @@ def login():
return jsonify({'success': True, 'redirect': url_for('main.admin_page')}) return jsonify({'success': True, 'redirect': url_for('main.admin_page')})
return redirect(url_for('main.admin_page')) return redirect(url_for('main.admin_page'))
except Exception as exc: except Exception as exc:
current_app.logger.error('[auth] login error: %s', exc, exc_info=True)
if wants_json: if wants_json:
return jsonify({'success': False, 'error': str(exc)}) return jsonify({'success': False, 'error': '登录失败,请稍后重试'})
return render_html('login.html', error='登录失败,请稍后重试') return render_html('login.html', error='登录失败,请稍后重试')
if wants_json: if wants_json:
return jsonify({'success': False, 'error': '用户名或密码错误'}) return jsonify({'success': False, 'error': '用户名或密码错误'})
+4 -5
View File
@@ -3,6 +3,7 @@
""" """
import os import os
from flask import Blueprint, redirect, url_for, send_file, session from flask import Blueprint, redirect, url_for, send_file, session
from werkzeug.utils import safe_join
from utils.auth import login_required, is_session_user_valid from utils.auth import login_required, is_session_user_valid
from utils.render import render_html from utils.render import render_html
@@ -29,9 +30,7 @@ def admin_page():
@main.route('/static/<path:filename>') @main.route('/static/<path:filename>')
def serve_static(filename): def serve_static(filename):
"""提供 static 目录及子目录下的静态文件访问""" """提供 static 目录及子目录下的静态文件访问"""
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename)) filepath = safe_join(STATIC_DIR, filename)
static_abs = os.path.abspath(STATIC_DIR) if filepath is None or not os.path.isfile(filepath):
file_abs = os.path.abspath(filepath)
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
return '', 404 return '', 404
return send_file(file_abs, as_attachment=False) return send_file(filepath, as_attachment=False)
+3 -2
View File
@@ -2,7 +2,7 @@
版本公开 API 蓝图:当前版本、最新版本下载链接 版本公开 API 蓝图:当前版本、最新版本下载链接
""" """
import os import os
from flask import Blueprint, jsonify from flask import Blueprint, jsonify, current_app
from utils.db import get_db from utils.db import get_db
@@ -37,4 +37,5 @@ def api_version_latest():
'file_url': row['file_url'] or '', 'file_url': row['file_url'] or '',
}) })
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 current_app.logger.error('[version] internal error: %s', e, exc_info=True)
return jsonify({'error': '服务器内部错误,请稍后重试'}), 500
+1 -1
View File
@@ -107,7 +107,7 @@ def admin_required(f):
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
except Exception as exc: except Exception as exc:
if _is_ajax_request(): if _is_ajax_request():
return jsonify({'success': False, 'error': str(exc)}), 500 return jsonify({'success': False, 'error': '服务器内部错误,请稍后重试'}), 500
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
+11 -2
View File
@@ -2,6 +2,7 @@
数据库连接与初始化 数据库连接与初始化
""" """
import os import os
import re
import pymysql import pymysql
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -31,6 +32,13 @@ mysql_user_source = config_mysql_user_source
mysql_database_source = config_mysql_database_source mysql_database_source = config_mysql_database_source
def _safe_identifier(name):
"""仅允许字母、数字、下划线的数据库/表名片段,防止注入 DDL 片段。"""
if not re.fullmatch(r'[A-Za-z0-9_]+', name or ''):
raise ValueError(f'非法标识符: {name!r}')
return name
def describe_db_target(): def describe_db_target():
return ( return (
f"{mysql_user}@{mysql_host}/{mysql_database} " f"{mysql_user}@{mysql_host}/{mysql_database} "
@@ -80,9 +88,10 @@ def init_db():
charset='utf8mb4' charset='utf8mb4'
) )
try: try:
db_name = _safe_identifier(mysql_database)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4") cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}` DEFAULT CHARSET utf8mb4")
cur.execute(f"USE `{mysql_database}`") cur.execute(f"USE `{db_name}`")
cur.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
+58
View File
@@ -0,0 +1,58 @@
"""
SSRF 防护:检测 URL 是否指向内网/本机地址,禁止服务端请求。
"""
import ipaddress
import socket
from urllib.parse import urlparse
_PRIVATE_NETWORKS = [
ipaddress.ip_network('0.0.0.0/8'),
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('100.64.0.0/10'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.0.0.0/24'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('198.18.0.0/15'),
ipaddress.ip_network('224.0.0.0/4'),
ipaddress.ip_network('240.0.0.0/4'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'),
ipaddress.ip_network('fe80::/10'),
]
_LOCAL_HOSTNAMES = {
'localhost',
'localhost.localdomain',
'metadata.google.internal',
'metadata.azure.internal',
'169.254.169.254',
}
def is_internal_url(url):
"""判断 URL 是否解析到内网/本机/保留地址。解析失败视为不可信返回 True。"""
if not url or not isinstance(url, str):
return True
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return True
host = parsed.hostname
if not host:
return True
host_lower = host.lower().rstrip('.')
if host_lower in _LOCAL_HOSTNAMES:
return True
try:
infos = socket.getaddrinfo(host, parsed.port or 80)
except socket.gaierror:
return True
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
continue
if any(ip in network for network in _PRIVATE_NETWORKS):
return True
return False