From 338493ecaaa136a1b388e3803910ff5910f16040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Tue, 25 Aug 2026 18:05:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=EF=BC=9ACO?= =?UTF-8?q?RS=E7=99=BD=E5=90=8D=E5=8D=95=E3=80=81JWT=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E3=80=81SSRF=E9=98=B2?= =?UTF-8?q?=E6=8A=A4=E3=80=81=E8=B7=AF=E5=BE=84=E9=81=8D=E5=8E=86=E4=B8=8E?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E6=B3=84=E9=9C=B2=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/ShopCredentialCryptoService.java | 8 +++ .../nanri/aiimage/config/SecurityConfig.java | 10 +++- .../modules/auth/service/JwtService.java | 39 +++++++++++-- backend/ali_oss.py | 4 ++ backend/app.py | 19 +++++- backend/blueprints/admin_api.py | 29 ++++++---- backend/blueprints/auth.py | 3 +- backend/blueprints/main.py | 9 ++- backend/blueprints/version.py | 5 +- backend/utils/auth.py | 2 +- backend/utils/db.py | 13 ++++- backend/utils/ssrf.py | 58 +++++++++++++++++++ 12 files changed, 168 insertions(+), 31 deletions(-) create mode 100644 backend/utils/ssrf.py diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/security/ShopCredentialCryptoService.java b/backend-java/src/main/java/com/nanri/aiimage/common/security/ShopCredentialCryptoService.java index 00fff2e6..aea938e6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/security/ShopCredentialCryptoService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/security/ShopCredentialCryptoService.java @@ -2,6 +2,7 @@ package com.nanri.aiimage.common.security; import com.nanri.aiimage.common.exception.BusinessException; import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -12,8 +13,11 @@ import java.security.MessageDigest; import java.util.Base64; @Service +@Slf4j 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}") private String rawKey; @@ -22,6 +26,10 @@ public class ShopCredentialCryptoService { @PostConstruct public void init() { 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"); byte[] full = sha256.digest(rawKey.getBytes(StandardCharsets.UTF_8)); byte[] key16 = new byte[16]; diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java index 06a8da1f..53bf199e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java @@ -27,9 +27,15 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { 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.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.setAllowCredentials(true); configuration.setMaxAge(3600L); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java index dab90474..e803a3dd 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; import java.util.Date; @@ -20,16 +21,42 @@ import java.util.Date; @Slf4j 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 volatile SecretKey cachedKey; + private SecretKey signingKey() { - byte[] keyBytes = props.getJwtSecret().getBytes(StandardCharsets.UTF_8); - if (keyBytes.length < 32) { - byte[] padded = new byte[32]; - System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length); - keyBytes = padded; + 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) { + byte[] padded = new byte[32]; + System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length); + keyBytes = padded; + } + key = Keys.hmacShaKeyFor(keyBytes); + cachedKey = key; + } + } } - return Keys.hmacShaKeyFor(keyBytes); + return key; } public String issue(Long userId, String username, String deviceId) { diff --git a/backend/ali_oss.py b/backend/ali_oss.py index 8f657c65..8b20aad8 100644 --- a/backend/ali_oss.py +++ b/backend/ali_oss.py @@ -26,6 +26,7 @@ from config import ( accessKeyId, accessKeySecret, ) +from utils.ssrf import is_internal_url _client = None _client_lock = threading.Lock() @@ -180,6 +181,9 @@ def upload_data_urls(data_urls: list, prefix: str = "history") -> list: del file_content continue + if is_internal_url(data_url): + raise ValueError("拒绝下载内网/本机地址的图片") + with requests.get(data_url, stream=True, timeout=_REMOTE_IMAGE_TIMEOUT) as response: response.raise_for_status() content_length = response.headers.get("Content-Length") diff --git a/backend/app.py b/backend/app.py index a90a9f59..89f4474b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -18,11 +18,28 @@ from blueprints.version import version_bp BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 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.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # 文件上传大小限制:2GB(数字人 ZIP 包等大文件) 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) diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 9dd7313d..761704d5 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -11,7 +11,6 @@ import zipfile from datetime import datetime from pathlib import Path from urllib.parse import quote -import traceback import requests from requests.adapters import HTTPAdapter @@ -33,6 +32,7 @@ from werkzeug.security import generate_password_hash from utils.db import get_db 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 @@ -47,6 +47,12 @@ _backend_java_session_local = threading.local() _internal_token_lock = threading.Lock() IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_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: VERSION_UPLOAD_MAX_BYTES = int(os.environ.get('VERSION_UPLOAD_MAX_BYTES', str(512 * 1024 * 1024))) except ValueError: @@ -1022,7 +1028,7 @@ def list_users(): 'admins': admins, }) except Exception as e: - return jsonify({'success': False, 'error': str(e)}) + return _internal_error(e) @admin_api.route('/user', methods=['POST']) @admin_required @@ -1113,7 +1119,7 @@ def create_user(): except pymysql.IntegrityError: return jsonify({'success': False, 'error': '用户名已存在'}) except Exception as e: - return jsonify({'success': False, 'error': str(e)}) + return _internal_error(e) @admin_api.route('/user/', methods=['PUT']) @@ -1201,7 +1207,7 @@ def update_user(uid): pass return jsonify({'success': False, 'error': str(exc)}), 403 except Exception as e: - return jsonify({'success': False, 'error': str(e)}) + return _internal_error(e) @admin_api.route('/user/', methods=['DELETE']) @@ -1239,7 +1245,7 @@ def delete_user(uid): return jsonify({'success': False, 'error': '用户不存在'}) return jsonify({'success': True, 'msg': '删除成功'}) 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}) 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: return jsonify({'success': False, 'error': str(exc)}), 400 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): @@ -1914,6 +1920,8 @@ def download_image_video_tasks_zip(): filename = f'task-{task_id}-video-{video_index + 1}.{extension}' remote_response = None try: + if is_internal_url(url): + raise ValueError('拒绝下载内网/本机地址的视频') remote_response = requests.get(url, stream=True, timeout=(10, 120)) remote_response.raise_for_status() 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: return jsonify({'success': False, 'error': str(exc)}), 400 except Exception as exc: - return jsonify({'success': False, 'error': str(exc)}), 500 + return _internal_error(exc) @admin_api.route('/image-video-tasks/') @@ -2573,7 +2581,7 @@ def list_versions(): ] return jsonify({'success': True, 'items': items}) except Exception as e: - return jsonify({'success': False, 'error': str(e)}) + return _internal_error(e) # ========== 数字人版本管理(代理到 Java 后端)========== @@ -2778,8 +2786,7 @@ def upload_version(): 'msg': '上传成功', }) except Exception as e: - traceback.print_exc() - return jsonify({'success': False, 'error': str(e)}) + return _internal_error(e) finally: if conn is not None: try: diff --git a/backend/blueprints/auth.py b/backend/blueprints/auth.py index 1f64bb5c..7824a1cd 100644 --- a/backend/blueprints/auth.py +++ b/backend/blueprints/auth.py @@ -49,8 +49,9 @@ def login(): return jsonify({'success': True, 'redirect': url_for('main.admin_page')}) return redirect(url_for('main.admin_page')) except Exception as exc: + current_app.logger.error('[auth] login error: %s', exc, exc_info=True) if wants_json: - return jsonify({'success': False, 'error': str(exc)}) + return jsonify({'success': False, 'error': '登录失败,请稍后重试'}) return render_html('login.html', error='登录失败,请稍后重试') if wants_json: return jsonify({'success': False, 'error': '用户名或密码错误'}) diff --git a/backend/blueprints/main.py b/backend/blueprints/main.py index f7d572e5..3f69361a 100644 --- a/backend/blueprints/main.py +++ b/backend/blueprints/main.py @@ -3,6 +3,7 @@ """ import os 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.render import render_html @@ -29,9 +30,7 @@ def admin_page(): @main.route('/static/') def serve_static(filename): """提供 static 目录及子目录下的静态文件访问""" - filepath = os.path.normpath(os.path.join(STATIC_DIR, filename)) - static_abs = os.path.abspath(STATIC_DIR) - file_abs = os.path.abspath(filepath) - if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): + filepath = safe_join(STATIC_DIR, filename) + if filepath is None or not os.path.isfile(filepath): return '', 404 - return send_file(file_abs, as_attachment=False) + return send_file(filepath, as_attachment=False) diff --git a/backend/blueprints/version.py b/backend/blueprints/version.py index 300d3cc8..18188df2 100644 --- a/backend/blueprints/version.py +++ b/backend/blueprints/version.py @@ -2,7 +2,7 @@ 版本公开 API 蓝图:当前版本、最新版本下载链接 """ import os -from flask import Blueprint, jsonify +from flask import Blueprint, jsonify, current_app from utils.db import get_db @@ -37,4 +37,5 @@ def api_version_latest(): 'file_url': row['file_url'] or '', }) 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 diff --git a/backend/utils/auth.py b/backend/utils/auth.py index 5b5c0a7d..a71bd76f 100644 --- a/backend/utils/auth.py +++ b/backend/utils/auth.py @@ -107,7 +107,7 @@ def admin_required(f): return redirect(url_for('auth.login')) except Exception as exc: 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 f(*args, **kwargs) return decorated diff --git a/backend/utils/db.py b/backend/utils/db.py index 05b9fb61..418c01e7 100644 --- a/backend/utils/db.py +++ b/backend/utils/db.py @@ -2,6 +2,7 @@ 数据库连接与初始化 """ import os +import re import pymysql 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 +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(): return ( f"{mysql_user}@{mysql_host}/{mysql_database} " @@ -80,9 +88,10 @@ def init_db(): charset='utf8mb4' ) try: + db_name = _safe_identifier(mysql_database) with conn.cursor() as cur: - cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4") - cur.execute(f"USE `{mysql_database}`") + cur.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}` DEFAULT CHARSET utf8mb4") + cur.execute(f"USE `{db_name}`") cur.execute(""" CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/backend/utils/ssrf.py b/backend/utils/ssrf.py new file mode 100644 index 00000000..eaddb63b --- /dev/null +++ b/backend/utils/ssrf.py @@ -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