This commit is contained in:
+8
@@ -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 (keyBytes.length < 32) {
|
if (key == null) {
|
||||||
byte[] padded = new byte[32];
|
synchronized (this) {
|
||||||
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
|
key = cachedKey;
|
||||||
keyBytes = padded;
|
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) {
|
public String issue(Long userId, String username, String deviceId) {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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': '用户名或密码错误'})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user