diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3805c7228..0fc4648c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@
## [Unreleased]
### Fixed
+* Utility 安全修复:配置凭据脱敏、解压炸弹上限、冲突异常 Secret 脱敏与压缩失败显式抛出(C433,GFX-1173,GitHub #919):①`AppSetting.ToString/ToFormatString` 经浅拷贝掩码后序列化——`DataBasePassword`/`HttpSignSecret`/`InterNodeAuthSecret`/`HttpsCertPassword` 非空值输出 `***`(null/空串原样保留区分「未配置」),四个凭据不再随启动/停止日志明文外发;②`CompressionHelper.Decompress` 新增 `maxOutputLength` 上限重载(旧签名转发 const 默认 64MB),膨胀过程内超限即抛 `InvalidDataException`——Network `DefaultMessageDecompressHandler` 增构造参数上限、RemoteMessaging `IMessageCompressionAlgorithm.Decompress` 接口加参(const 默认值,源码兼容;`DefaultMessageCodec` 显式传 `_maxDecompressedSize`),事后长度检查降级为兜底防线,1MB 高压缩比帧不再可膨胀至 GB 级托管内存;③`SettingFieldConflict` 敏感字段判定扩展 Secret/Token 子串——多 Role 配置冲突异常不再明文携带 `HttpSignSecret`/`InterNodeAuthSecret`;④`RandomHelper` 类文档声明双随机源分级(基础接口 `Random.Shared` 非加密 PRNG 禁用于令牌/验证码/防作弊场景,安全场景用权重系列 CSPRNG 或 `RandomNumberGenerator`);⑤落地已归档 C349 既定方案:`Compress` 异常原样抛出、`Decompress` 对非法/损坏/截断数据统一抛 `InvalidDataException`(含 SharpZipLib 截断流死循环主动判定),删除「失败静默返回原始字节」回退与 `CompressFailed` 本地化键;⑥`FP.Sqrt/Acos` 越界异常构造器实参换序(本地化消息进 `Message`、`"x"` 进 `ParamName`,修复前 `ParamName == null`)。公共 API 行为变更经本条披露;解压炸弹/脱敏/截断/参数名均有回归用例锁定。
* 入队与定时撮合解耦(C406,GFX-1104):删除每次入队的全量撮合及最新票据扫描,复用 `matchmaker-run` 按宿主调度节奏批量处理队列(默认 1000ms)。单轮撮合算法保持不变;桥测试覆盖四名玩家单批次形成两组及批次前取消不参与分配。
* 撮合通知异步执行与停用退订(C407,GFX-1105,GitHub #733):`ICapabilityEventBus.Subscribe` 返回幂等 `IDisposable` 退订句柄(void → IDisposable 为二进制破坏性变更——包族消费方需重新编译;源码侧忽略返回值的既有调用不受影响);online-match 模块撮合分配事件回调改为纯入队(移除同步总线回调内 `ResolveAssignmentAsync` / `session.WriteAsync` 两处 `GetAwaiter().GetResult()` 死锁面),新增 `match-assignment-notification-tick` 每轮有限批次(64)异步解析并推送(条目级失败不阻断同批其余条目,异常交调度器既有日志路径);`DeactivateAsync` 停用收口——关闭入队、退订、清空待处理并等待在途批次完成,热更/停机后旧模块零通知。回归 13 例:总线退订契约 5 + 模块生命周期 8(含对旧同步实现确定性红的发布不同步解析 / 停用退订 / 停用清空 / 容错批次)。
diff --git a/GameFrameX.Localization/Keys.Utility.cs b/GameFrameX.Localization/Keys.Utility.cs
index 4c4334920..cba7c8407 100644
--- a/GameFrameX.Localization/Keys.Utility.cs
+++ b/GameFrameX.Localization/Keys.Utility.cs
@@ -604,24 +604,33 @@ public static class CompressionHelper
{
///
- /// 数据压缩失败的完整日志消息
+ /// 数据解压失败的异常消息
///
///
- /// 键名: Utility.CompressionHelper.CompressFailed
- /// 用途: 当数据压缩发生异常时记录完整日志
+ /// 键名: Utility.CompressionHelper.DecompressFailed
+ /// 用途: 解压失败(SharpZipLib 异常包装为 InvalidDataException)时的异常消息
/// 参数: {0} - 异常详情
///
- public const string CompressFailed = "Utility.CompressionHelper.CompressFailed";
+ public const string DecompressFailed = "Utility.CompressionHelper.DecompressFailed";
///
- /// 数据解压失败的完整日志消息
+ /// 压缩数据被截断的异常消息
///
///
- /// 键名: Utility.CompressionHelper.DecompressFailed
- /// 用途: 当数据解压发生异常时记录完整日志
- /// 参数: {0} - 异常详情
+ /// 键名: Utility.CompressionHelper.DecompressInputTruncated
+ /// 用途: 解压时检测到输入数据被截断(Inflate 返回 0 且需要更多输入),主动抛出防止 SharpZipLib 空转死循环
///
- public const string DecompressFailed = "Utility.CompressionHelper.DecompressFailed";
+ public const string DecompressInputTruncated = "Utility.CompressionHelper.DecompressInputTruncated";
+
+ ///
+ /// 解压输出超过上限的异常消息
+ ///
+ ///
+ /// 键名: Utility.CompressionHelper.DecompressedSizeExceedsLimit
+ /// 用途: 解压累积输出超过调用方声明的上限(解压炸弹防线),膨胀过程立即中止并抛出
+ /// 参数: {0} - 允许的最大解压输出字节数
+ ///
+ public const string DecompressedSizeExceedsLimit = "Utility.CompressionHelper.DecompressedSizeExceedsLimit";
}
///
diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx
index a048fd266..074f4ebfb 100644
--- a/GameFrameX.Localization/Localization/Messages/Resources.resx
+++ b/GameFrameX.Localization/Localization/Messages/Resources.resx
@@ -2156,12 +2156,16 @@
{0} port [{1}] occupation details: {2}
-
- CompressionHelper.Compress Compression helper exception. Error: {0}.
+
+ CompressionHelper.Decompress decompression failed. Error: {0}.
-
- CompressionHelper.Decompress Compression helper exception. Error: {0}.
+
+ CompressionHelper.Decompress input data is truncated; decompression cannot complete.
+
+
+
+ CompressionHelper.Decompress decompressed output exceeds the maximum allowed length: {0}.
diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
index f6ec046d9..c82615a37 100644
--- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
+++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx
@@ -2158,12 +2158,16 @@
{0}端口[{1}]占用详情: {2}
-
- CompressionHelper.Compress 压缩辅助类异常。错误:{0}。
+
+ CompressionHelper.Decompress 解压失败。错误:{0}。
-
- CompressionHelper.Decompress 压缩辅助类异常。错误:{0}。
+
+ CompressionHelper.Decompress 输入数据已被截断,无法完成解压。
+
+
+
+ CompressionHelper.Decompress 解压输出超过允许的最大长度:{0}。
diff --git a/GameFrameX.Network.RemoteMessaging/Transport/DefaultMessageCodec.cs b/GameFrameX.Network.RemoteMessaging/Transport/DefaultMessageCodec.cs
index e584cfd79..729b1ccc8 100644
--- a/GameFrameX.Network.RemoteMessaging/Transport/DefaultMessageCodec.cs
+++ b/GameFrameX.Network.RemoteMessaging/Transport/DefaultMessageCodec.cs
@@ -205,7 +205,9 @@ public async Task DecodeAsync(Stream stream, CancellationToken ca
throw new NotSupportedException(LocalizationService.GetString(Localization.Keys.RemoteMessaging.Codec.CompressionAlgorithmNotRegistered, algorithmId));
}
- messageData = compressionAlgorithm.Decompress(messageData);
+ // 解压上限前置到膨胀过程内(_maxDecompressedSize),下方的长度校验降级为兜底防线——
+ // 高压缩比小帧(解压炸弹)在膨胀超过上限的瞬间即被中止,而非膨胀完成后才检查。
+ messageData = compressionAlgorithm.Decompress(messageData, _maxDecompressedSize);
ValidateDecodedPayloadLength(messageData.Length);
}
diff --git a/GameFrameX.Network.RemoteMessaging/Transport/DeflateMessageCompressionAlgorithm.cs b/GameFrameX.Network.RemoteMessaging/Transport/DeflateMessageCompressionAlgorithm.cs
index 0149c16ae..6bb7fd651 100644
--- a/GameFrameX.Network.RemoteMessaging/Transport/DeflateMessageCompressionAlgorithm.cs
+++ b/GameFrameX.Network.RemoteMessaging/Transport/DeflateMessageCompressionAlgorithm.cs
@@ -71,15 +71,18 @@ public byte[] Compress(byte[] input)
}
///
- /// 使用 Deflate 算法解压数据。
+ /// 使用 Deflate 算法解压数据(解压过程受输出上限约束,超限立即中止)。
///
///
- /// Decompresses data using the Deflate algorithm.
+ /// Decompresses data using the Deflate algorithm; the inflation aborts as soon as the
+ /// accumulated output exceeds the given limit.
///
/// 待解压的压缩字节数组 / The compressed byte array to decompress
+ /// 允许的最大解压输出字节数 / Maximum allowed decompressed output length in bytes
/// 解压后的原始字节数组 / The decompressed byte array
- public byte[] Decompress(byte[] input)
+ /// 压缩数据非法、损坏、截断或解压输出超过上限时抛出 / Thrown when the data is invalid, corrupted, truncated, or exceeds the output limit
+ public byte[] Decompress(byte[] input, long maxOutputLength)
{
- return CompressionHelper.Decompress(input);
+ return CompressionHelper.Decompress(input, maxOutputLength);
}
}
\ No newline at end of file
diff --git a/GameFrameX.Network.RemoteMessaging/Transport/IMessageCompressionAlgorithm.cs b/GameFrameX.Network.RemoteMessaging/Transport/IMessageCompressionAlgorithm.cs
index dc8498dc3..8addac9f7 100644
--- a/GameFrameX.Network.RemoteMessaging/Transport/IMessageCompressionAlgorithm.cs
+++ b/GameFrameX.Network.RemoteMessaging/Transport/IMessageCompressionAlgorithm.cs
@@ -57,12 +57,17 @@ public interface IMessageCompressionAlgorithm
byte[] Compress(byte[] input);
///
- /// 解压数据。
+ /// 解压数据(带输出上限)。
///
///
- /// Decompresses data.
+ /// Decompresses data with a bounded output length. Implementations must abort the inflation
+ /// as soon as the accumulated output exceeds (defence against
+ /// decompression bombs); a post-hoc length check after full inflation is not a valid substitute.
+ /// Callers that know their protocol-level maximum message size should always pass it explicitly.
///
/// 待解压的压缩字节数组 / The compressed byte array to decompress
+ /// 允许的最大解压输出字节数 / Maximum allowed decompressed output length in bytes
/// 解压后的原始字节数组 / The decompressed byte array
- byte[] Decompress(byte[] input);
+ /// 压缩数据非法、损坏、截断或解压输出超过上限时抛出 / Thrown when the data is invalid, corrupted, truncated, or exceeds the output limit
+ byte[] Decompress(byte[] input, long maxOutputLength = CompressionHelper.DefaultMaxDecompressedOutputLength);
}
\ No newline at end of file
diff --git a/GameFrameX.Network/Message/DefaultMessageDecompressHandler.cs b/GameFrameX.Network/Message/DefaultMessageDecompressHandler.cs
index 110e91fa5..7b88deb32 100644
--- a/GameFrameX.Network/Message/DefaultMessageDecompressHandler.cs
+++ b/GameFrameX.Network/Message/DefaultMessageDecompressHandler.cs
@@ -35,15 +35,42 @@ namespace GameFrameX.Network.Message;
///
/// 默认消息解压器
///
+///
+/// 解压输出受 约束:膨胀过程一旦超过上限立即中止并抛出
+/// ,用于防御高压缩比小帧的解压炸弹(DoS)。
+/// Default message decompressor with a bounded output length to guard against decompression bombs.
+///
public sealed class DefaultMessageDecompressHandler : IMessageDecompressHandler
{
+ ///
+ /// 允许的最大解压输出字节数
+ ///
+ private readonly long _maxOutputLength;
+
+ ///
+ /// 构造函数
+ ///
+ ///
+ /// Constructor with an explicit decompressed output limit; when omitted, the conservative
+ /// (64MB) applies.
+ ///
+ /// 允许的最大解压输出字节数,缺省取 / Maximum allowed decompressed output length in bytes
+ public DefaultMessageDecompressHandler(long maxOutputLength = CompressionHelper.DefaultMaxDecompressedOutputLength)
+ {
+ _maxOutputLength = maxOutputLength;
+ }
+
///
/// 解压处理
///
- /// 消息压缩内容
- ///
+ ///
+ /// Decompresses the message data with the configured output limit.
+ ///
+ /// 消息压缩内容 / Compressed message content
+ /// 解压后的字节数组 / The decompressed byte array
+ /// 压缩数据非法、损坏、截断或解压输出超过上限时抛出 / Thrown when the compressed data is invalid, corrupted, truncated, or exceeds the output limit
public byte[] Handler(byte[] message)
{
- return CompressionHelper.Decompress(message);
+ return CompressionHelper.Decompress(message, _maxOutputLength);
}
}
\ No newline at end of file
diff --git a/GameFrameX.Utility/CompressionHelper.cs b/GameFrameX.Utility/CompressionHelper.cs
index 583b919bf..2a9fe685d 100644
--- a/GameFrameX.Utility/CompressionHelper.cs
+++ b/GameFrameX.Utility/CompressionHelper.cs
@@ -29,7 +29,7 @@
using System.Buffers;
using GameFrameX.Foundation.Localization.Core;
-using GameFrameX.Foundation.Logger;
+using ICSharpCode.SharpZipLib;
using ICSharpCode.SharpZipLib.Zip.Compression;
namespace GameFrameX.Utility;
@@ -50,14 +50,27 @@ public static class CompressionHelper
///
private const int BufferSize = 8192;
+ ///
+ /// Default maximum decompressed output length: 64 MB. Used when the legacy overload
+ /// is invoked without an explicit caller-declared limit; guards against decompression bombs (a small highly-compressed
+ /// frame otherwise inflates into gigabytes of managed memory before any post-hoc length check runs).
+ ///
+ ///
+ /// 默认解压输出上限:64MB。供旧重载 在调用方未显式声明上限时使用;
+ /// 用于防御解压炸弹(高压缩比小帧若不设上限,会在任何事后长度检查之前膨胀为数 GB 托管内存)。
+ /// 调用方已知协议级最大消息体时应显式传入更贴身的上限。
+ ///
+ public const long DefaultMaxDecompressedOutputLength = 64 * 1024 * 1024;
+
///
/// Compresses data. Uses the Deflate algorithm to compress the original byte array into a smaller byte array.
+ /// Failures propagate the original exception (no silent fallback returning the input).
///
///
- /// 压缩数据。使用Deflate算法将原始字节数组压缩成更小的字节数组。
+ /// 压缩数据。使用Deflate算法将原始字节数组压缩成更小的字节数组。失败时异常原样抛出,不静默回退返回原始字节。
///
/// The original byte array to be compressed. Cannot be null. / 要压缩的原始字节数组。不能为null。
- /// The compressed byte array. If the input is an empty array, returns the empty array directly. If an exception occurs during compression, returns the original array. / 压缩后的字节数组。如果输入为空数组,则直接返回该空数组。如果压缩过程中发生异常,则返回原始数组。
+ /// The compressed byte array. If the input is an empty array, returns the empty array directly. / 压缩后的字节数组。如果输入为空数组,则直接返回该空数组。
/// Thrown when the input parameter bytes is null. / 当输入参数bytes为null时抛出。
public static byte[] Compress(byte[] bytes)
{
@@ -86,33 +99,49 @@ public static byte[] Compress(byte[] bytes)
return compressorMemoryStream.ToArray();
}
- catch (Exception e)
- {
- // Localization: Utility.CompressionHelper.CompressFailed - CompressionHelper.Compress 压缩辅助类异常。错误:{0}。
- LogHelper.Fatal(e, LocalizationService.GetString(Localization.Keys.Utility.CompressionHelper.CompressFailed, e.ToString()));
- }
finally
{
ArrayPool.Shared.Return(buffer);
}
-
- return bytes;
}
}
///
- /// Decompresses data. Uses the Inflate algorithm to restore the compressed byte array to the original byte array.
+ /// Decompresses data. Uses the Inflate algorithm to restore the compressed byte array to the original byte array,
+ /// with the default output limit .
///
///
- /// 解压数据。使用Inflate算法将压缩的字节数组还原成原始字节数组。
+ /// 解压数据。使用Inflate算法将压缩的字节数组还原成原始字节数组,输出上限取默认值 。
///
/// The compressed byte array to be decompressed. Cannot be null. / 要解压的压缩字节数组。不能为null。
- /// The decompressed original byte array. If the input is an empty array, returns the empty array directly. If an exception occurs during decompression, returns the original array. / 解压后的原始字节数组。如果输入为空数组,则直接返回该空数组。如果解压过程中发生异常,则返回原始数组。
+ /// The decompressed original byte array. If the input is an empty array, returns the empty array directly. / 解压后的原始字节数组。如果输入为空数组,则直接返回该空数组。
/// Thrown when the input parameter bytes is null. / 当输入参数bytes为null时抛出。
- /// Thrown when the compressed data format is invalid or corrupted. / 当压缩数据格式无效或已损坏时抛出。
+ /// Thrown when the compressed data format is invalid, corrupted or truncated, or when the decompressed output exceeds the default limit. / 当压缩数据格式无效、已损坏或被截断,或解压输出超过默认上限时抛出。
public static byte[] Decompress(byte[] bytes)
+ {
+ return Decompress(bytes, DefaultMaxDecompressedOutputLength);
+ }
+
+ ///
+ /// Decompresses data with an explicit output limit. Uses the Inflate algorithm to restore the compressed byte array
+ /// to the original byte array; the loop aborts as soon as the accumulated output exceeds
+ /// , so the inflation itself is bounded instead of being checked only after completion.
+ ///
+ ///
+ /// 解压数据(显式输出上限)。使用Inflate算法将压缩的字节数组还原成原始字节数组;
+ /// 累积输出一旦超过 立即中止并抛出异常——上限约束作用于膨胀过程本身,
+ /// 而不是在解压完成后才检查。调用方(网络消息解压入口)应传入各自协议的最大消息体上限。
+ ///
+ /// The compressed byte array to be decompressed. Cannot be null. / 要解压的压缩字节数组。不能为null。
+ /// Maximum allowed decompressed output length in bytes; must not be negative. / 允许的最大解压输出字节数,不得为负。
+ /// The decompressed original byte array. If the input is an empty array, returns the empty array directly. / 解压后的原始字节数组。如果输入为空数组,则直接返回该空数组。
+ /// Thrown when the input parameter bytes is null. / 当输入参数bytes为null时抛出。
+ /// Thrown when maxOutputLength is negative. / 当maxOutputLength为负数时抛出。
+ /// Thrown when the compressed data format is invalid, corrupted or truncated, or when the decompressed output exceeds maxOutputLength. / 当压缩数据格式无效、已损坏或被截断,或解压输出超过maxOutputLength时抛出。
+ public static byte[] Decompress(byte[] bytes, long maxOutputLength)
{
ArgumentNullException.ThrowIfNull(bytes);
+ ArgumentOutOfRangeException.ThrowIfNegative(maxOutputLength);
if (bytes.Length == 0)
{
return bytes;
@@ -128,22 +157,33 @@ public static byte[] Decompress(byte[] bytes)
while (!decompressor.IsFinished)
{
var count = decompressor.Inflate(buffer);
+ if (count == 0 && (decompressor.IsNeedingInput || decompressor.IsNeedingDictionary))
+ {
+ // SharpZipLib 对截断流 Inflate 恒返回 0 且 IsFinished 恒 false,必须主动判定,否则死循环。
+ // Localization: Utility.CompressionHelper.DecompressInputTruncated - 压缩数据已被截断,无法完成解压。
+ throw new InvalidDataException(LocalizationService.GetString(Localization.Keys.Utility.CompressionHelper.DecompressInputTruncated));
+ }
+
decompressMemoryStream.Write(buffer, 0, count);
+ if (decompressMemoryStream.Length > maxOutputLength)
+ {
+ // 解压炸弹防线:上限检查必须在膨胀过程内,事后检查等于没有检查。
+ // Localization: Utility.CompressionHelper.DecompressedSizeExceedsLimit - 解压输出超过允许的最大长度:{0}。
+ throw new InvalidDataException(LocalizationService.GetString(Localization.Keys.Utility.CompressionHelper.DecompressedSizeExceedsLimit, maxOutputLength));
+ }
}
return decompressMemoryStream.ToArray();
}
- catch (Exception e)
+ catch (SharpZipBaseException e)
{
// Localization: Utility.CompressionHelper.DecompressFailed - CompressionHelper.Decompress 压缩辅助类异常。错误:{0}。
- LogHelper.Fatal(e, LocalizationService.GetString(Localization.Keys.Utility.CompressionHelper.DecompressFailed, e.ToString()));
+ throw new InvalidDataException(LocalizationService.GetString(Localization.Keys.Utility.CompressionHelper.DecompressFailed, e.ToString()), e);
}
finally
{
ArrayPool.Shared.Return(buffer, true);
}
-
- return bytes;
}
}
-}
\ No newline at end of file
+}
diff --git a/GameFrameX.Utility/Math/FP.cs b/GameFrameX.Utility/Math/FP.cs
index 29b95bd73..2f34ad1c8 100644
--- a/GameFrameX.Utility/Math/FP.cs
+++ b/GameFrameX.Utility/Math/FP.cs
@@ -917,7 +917,7 @@ public static FP Sqrt(FP x)
{
// 我们无法像 Single 和 Double 一样表示无穷大,且 Sqrt 在 x < 0 时
// 数学上是未定义的。因此我们抛出异常。
- throw new ArgumentOutOfRangeException(LocalizationService.GetString(Keys.Utility.ExceptionMessages.SqrtValueNegative), "x");
+ throw new ArgumentOutOfRangeException("x", LocalizationService.GetString(Keys.Utility.ExceptionMessages.SqrtValueNegative));
}
var num = (ulong)xl;
@@ -1277,7 +1277,7 @@ public static FP Acos(FP x)
{
if (x < -One || x > One)
{
- throw new ArgumentOutOfRangeException(LocalizationService.GetString(Keys.Utility.ExceptionMessages.ValueOutOfRange), "x");
+ throw new ArgumentOutOfRangeException("x", LocalizationService.GetString(Keys.Utility.ExceptionMessages.ValueOutOfRange));
}
if (x.RawValue == 0)
diff --git a/GameFrameX.Utility/RandomHelper.cs b/GameFrameX.Utility/RandomHelper.cs
index 3e94e91fd..aba8b18c7 100644
--- a/GameFrameX.Utility/RandomHelper.cs
+++ b/GameFrameX.Utility/RandomHelper.cs
@@ -45,9 +45,13 @@ namespace GameFrameX.Utility;
///
/// 核心特性:
/// - 线程安全:使用 Random.Shared 确保多线程环境下的安全性
+/// - 非加密安全:基础接口(Next/NextDouble/NextUInt32/NextUInt64/RandomSelect/Items 等)基于 Random.Shared,
+/// 是可预测的伪随机数生成器(PRNG),不得用于任何安全敏感场景
/// - 高性能:避免重复创建 Random 实例
/// - 类型安全:支持泛型方法,编译时类型检查
/// - 参数验证:使用 .NET 静态方法进行参数检查
+/// - 双随机源分级:权重系列方法(Idx/Ids/Items 权重重载、RandomSelect(int,int) 等)内部使用
+/// ConcurrentCryptoRandom(加密安全随机),与基础接口的 PRNG 强度不同,见下方注意事项
///
/// 应用场景:
/// - 游戏开发中的随机事件生成
@@ -69,6 +73,10 @@ namespace GameFrameX.Utility;
///
///
/// 注意事项:
+/// - 【安全警示】基础接口基于 Random.Shared(可预测 PRNG),通过少量输出即可还原内部状态:
+/// 严禁用于令牌、验证码、防作弊抽奖、掉落预测防护等安全场景,攻击者可据此定向刷取稀有掉落
+/// - 安全场景请使用权重系列方法(内部为 ConcurrentCryptoRandom 加密安全随机)
+/// 或 System.Security.Cryptography.RandomNumberGenerator
/// - 所有方法都进行了严格的参数验证
/// - 空集合或数组会抛出 ArgumentOutOfRangeException
/// - 负数参数会抛出相应的异常
diff --git a/GameFrameX.Utility/Setting/AppSetting.cs b/GameFrameX.Utility/Setting/AppSetting.cs
index fee60ea35..55d79c0f8 100644
--- a/GameFrameX.Utility/Setting/AppSetting.cs
+++ b/GameFrameX.Utility/Setting/AppSetting.cs
@@ -93,27 +93,75 @@ public bool IsLocal(int serverId)
}
///
- /// 将对象序列化为JSON字符串
+ /// 将对象序列化为JSON字符串;凭据字段(数据库密码、HTTP 签名密钥、节点间鉴权密钥、HTTPS 证书密码)已脱敏,
+ /// 输出可安全进入日志。
///
///
- /// Serializes the object to a JSON string.
+ /// Serializes the object to a JSON string with credential fields (database password, HTTP signature secret,
+ /// inter-node auth secret, HTTPS certificate password) masked; the output is safe for logging.
///
- /// JSON字符串 / JSON string
+ /// JSON字符串(凭据已脱敏)/ JSON string (credentials masked)
public override string ToString()
{
- return JsonHelper.Serialize(this);
+ return JsonHelper.Serialize(CreateMaskedCloneForSerialization());
}
///
- /// 将对象序列化为格式化的JSON字符串
+ /// 将对象序列化为格式化的JSON字符串;凭据字段(数据库密码、HTTP 签名密钥、节点间鉴权密钥、HTTPS 证书密码)已脱敏,
+ /// 输出可安全进入日志。
///
///
- /// Serializes the object to a formatted JSON string.
+ /// Serializes the object to a formatted JSON string with credential fields (database password, HTTP signature secret,
+ /// inter-node auth secret, HTTPS certificate password) masked; the output is safe for logging.
///
- /// 格式化的JSON字符串 / Formatted JSON string
+ /// 格式化的JSON字符串(凭据已脱敏)/ Formatted JSON string (credentials masked)
public string ToFormatString()
{
- return JsonHelper.SerializeFormat(this);
+ return JsonHelper.SerializeFormat(CreateMaskedCloneForSerialization());
+ }
+
+ ///
+ /// 凭据字段的序列化掩码:非空值输出该掩码,保留"已配置/未配置"的运维可见性,同时保证明文永不离开进程。
+ ///
+ private const string SensitiveValueMask = "***";
+
+ ///
+ /// 创建用于序列化输出的脱敏浅拷贝:、、
+ /// 、 四个凭据字段的非空值替换为
+ /// (null 与空串原样保留,用于区分"未配置")。
+ /// 安全约定:后续在 新增凭据类字段时,必须同步加入本方法的掩码清单,
+ /// 否则其明文将随启动/停止日志输出。
+ ///
+ ///
+ /// Creates a masked shallow copy for serialization output: non-empty values of the four credential fields
+ /// (, , ,
+ /// ) are replaced with (null and empty strings
+ /// are kept as-is to distinguish "not configured").
+ /// Convention: any new credential-like field added to must be added to this mask list,
+ /// otherwise its plaintext would leak through startup/shutdown logs.
+ ///
+ /// 凭据字段已掩码的浅拷贝 / A shallow copy with credential fields masked
+ private AppSetting CreateMaskedCloneForSerialization()
+ {
+ var maskedClone = (AppSetting)MemberwiseClone();
+ maskedClone.DataBasePassword = MaskSensitiveValue(DataBasePassword);
+ maskedClone.HttpSignSecret = MaskSensitiveValue(HttpSignSecret);
+ maskedClone.InterNodeAuthSecret = MaskSensitiveValue(InterNodeAuthSecret);
+ maskedClone.HttpsCertPassword = MaskSensitiveValue(HttpsCertPassword);
+ return maskedClone;
+ }
+
+ ///
+ /// 凭据值脱敏:null/空串原样返回(表示未配置),非空值返回掩码。
+ ///
+ ///
+ /// Masks a credential value: null/empty strings are returned as-is ("not configured"), non-empty values return the mask.
+ ///
+ /// 原始凭据值 / The original credential value
+ /// 脱敏后的值 / The masked value
+ private static string MaskSensitiveValue(string value)
+ {
+ return string.IsNullOrEmpty(value) ? value : SensitiveValueMask;
}
#region 从配置文件读取的属性
diff --git a/GameFrameX.Utility/Setting/SettingFieldConflict.cs b/GameFrameX.Utility/Setting/SettingFieldConflict.cs
index 4eb82d002..3df9c81fd 100644
--- a/GameFrameX.Utility/Setting/SettingFieldConflict.cs
+++ b/GameFrameX.Utility/Setting/SettingFieldConflict.cs
@@ -104,14 +104,22 @@ public SettingFieldConflict(string fieldName, string expectedSegment, object exp
///
/// 判断字段是否为敏感字段(其值不得出现在异常消息中)。
+ /// 判定为凭据类命名:字段名含 "Password"、"Secret" 或 "Token" 子串(不区分大小写)——
+ /// 例如 、、
+ /// ;值详情保留在冲突对象自身属性上。
///
///
/// Determines whether the field is sensitive (its value must not appear in exception messages).
- /// Matches credential-like names such as ; value details are preserved on the conflict object itself.
+ /// Credential-like names match: field names containing "Password", "Secret" or "Token" (case-insensitive) —
+ /// e.g. , ,
+ /// ; value details are preserved on the conflict object itself.
///
private static bool IsSensitiveFieldName(string fieldName)
{
- return fieldName != null && fieldName.IndexOf("Password", StringComparison.OrdinalIgnoreCase) >= 0;
+ return fieldName != null &&
+ (fieldName.IndexOf("Password", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ fieldName.IndexOf("Secret", StringComparison.OrdinalIgnoreCase) >= 0 ||
+ fieldName.IndexOf("Token", StringComparison.OrdinalIgnoreCase) >= 0);
}
///
diff --git a/Tests/GameFrameX.Tests/RemoteMessaging/DefaultMessageCodecTests.cs b/Tests/GameFrameX.Tests/RemoteMessaging/DefaultMessageCodecTests.cs
index 4cfb8f73b..bd38a23fc 100644
--- a/Tests/GameFrameX.Tests/RemoteMessaging/DefaultMessageCodecTests.cs
+++ b/Tests/GameFrameX.Tests/RemoteMessaging/DefaultMessageCodecTests.cs
@@ -30,6 +30,7 @@
using System.Buffers.Binary;
using System.IO;
using GameFrameX.Network.RemoteMessaging.Transport;
+using GameFrameX.Utility;
namespace GameFrameX.Tests.RemoteMessaging;
@@ -68,6 +69,23 @@ public async Task DecodeAsync_ShouldThrowInvalidDataException_WhenDecompressedPa
}
}
+ [Fact]
+ public async Task DecodeAsync_WithRealDeflateBomb_ShouldThrowInvalidDataException_WithoutFullInflation()
+ {
+ // 解压炸弹回归:64KB 全零载荷经 Deflate 压缩后仅数百字节(高压缩比),
+ // codec 声明 maxDecompressedSize = 4KB——真实 Deflate 算法必须在膨胀过程内中止,
+ // 而不是先完整膨胀成 64KB 再做事后长度检查。
+ const byte algorithmId = 1;
+ var registry = new DefaultMessageCompressionRegistry();
+ registry.Register(new DeflateMessageCompressionAlgorithm());
+ var codec = CreateCodec(registry, maxPacketSize: 1024 * 1024, maxDecompressedSize: 4 * 1024);
+ var bombPayload = CompressionHelper.Compress(new byte[64 * 1024]);
+ await using (var stream = CreatePacketStream(algorithmId, bombPayload))
+ {
+ await Assert.ThrowsAsync(() => codec.DecodeAsync(stream, CancellationToken.None));
+ }
+ }
+
private static IMessageCodec CreateCodec(int maxPacketSize, int maxDecompressedSize)
{
return CreateCodec(new DefaultMessageCompressionRegistry(), maxPacketSize, maxDecompressedSize);
@@ -126,8 +144,15 @@ public byte[] Compress(byte[] input)
return input;
}
- public byte[] Decompress(byte[] input)
+ public byte[] Decompress(byte[] input, long maxOutputLength)
{
+ // 语义对齐真实算法:输出一旦超过调用方声明的上限立即中止抛出(膨胀过程内),
+ // 由 codec 的 ValidateDecodedPayloadLength 兜底——两条防线与生产链路一致。
+ if (_decompressedBytes.Length > maxOutputLength)
+ {
+ throw new InvalidDataException($"Simulated decompressed output {_decompressedBytes.Length} exceeds limit {maxOutputLength}.");
+ }
+
return _decompressedBytes;
}
}
diff --git a/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs b/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs
index 3cabd9a6c..0fedeaba8 100644
--- a/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs
+++ b/Tests/GameFrameX.Tests/Utility/AppSettingTests.cs
@@ -285,4 +285,72 @@ public void ServerInstanceId_CanBeSet_ShouldUpdateValue()
// Assert
Assert.Equal(instanceId, appSetting.ServerInstanceId);
}
+
+ ///
+ /// ToString/ToFormatString 必须对四个凭据字段脱敏:非空值输出掩码,明文绝不进入序列化输出(SEC-H01)。
+ ///
+ [Fact]
+ public void ToString_WithCredentialFields_ShouldMaskSensitiveValues()
+ {
+ // Arrange
+ var appSetting = new AppSetting();
+ appSetting.DataBasePassword = "db-password-plain";
+ appSetting.HttpSignSecret = "http-sign-secret-plain";
+ appSetting.InterNodeAuthSecret = "inter-node-auth-secret-plain";
+ appSetting.HttpsCertPassword = "https-cert-password-plain";
+
+ // Act
+ var json = appSetting.ToString();
+ var formattedJson = appSetting.ToFormatString();
+
+ // Assert:两条序列化路径均不含任何明文凭据
+ Assert.DoesNotContain("db-password-plain", json);
+ Assert.DoesNotContain("http-sign-secret-plain", json);
+ Assert.DoesNotContain("inter-node-auth-secret-plain", json);
+ Assert.DoesNotContain("https-cert-password-plain", json);
+ Assert.DoesNotContain("db-password-plain", formattedJson);
+ Assert.DoesNotContain("http-sign-secret-plain", formattedJson);
+ Assert.DoesNotContain("inter-node-auth-secret-plain", formattedJson);
+ Assert.DoesNotContain("https-cert-password-plain", formattedJson);
+
+ // 已配置的字段以掩码出现,保留"已配置"的运维可见性
+ Assert.Contains("\"***\"", json);
+ Assert.Contains("***", formattedJson);
+ }
+
+ ///
+ /// 未配置(空串)的凭据字段保持空输出,不误报为已配置。
+ ///
+ [Fact]
+ public void ToString_WithUnsetCredentialFields_ShouldKeepEmptyValues()
+ {
+ // Arrange:默认构造——DataBasePassword 为 null(JsonHelper 跳过 null 属性),
+ // InterNodeAuthSecret/HttpSignSecret/HttpsCertPassword 为空串(保留输出空值)
+ var appSetting = new AppSetting();
+
+ // Act
+ var json = appSetting.ToString();
+
+ // Assert:空串凭据字段保留空值输出(紧凑 JSON 无空格分隔),未配置不误报为已配置(不出现掩码)
+ Assert.Contains($"\"{nameof(AppSetting.InterNodeAuthSecret)}\":\"\"", json);
+ Assert.DoesNotContain("***", json);
+ }
+
+ ///
+ /// 脱敏不得影响配置对象自身的字段值(掩码只作用于序列化拷贝)。
+ ///
+ [Fact]
+ public void ToString_AfterMasking_ShouldNotMutateOriginalSetting()
+ {
+ // Arrange
+ var appSetting = new AppSetting();
+ appSetting.DataBasePassword = "db-password-plain";
+
+ // Act
+ appSetting.ToString();
+ appSetting.ToFormatString();
+
+ // Assert
+ Assert.Equal("db-password-plain", appSetting.DataBasePassword);
+ }
}
diff --git a/Tests/GameFrameX.Tests/Utility/CompressionHelperTests.cs b/Tests/GameFrameX.Tests/Utility/CompressionHelperTests.cs
index 7a87af4c2..21f973e3d 100644
--- a/Tests/GameFrameX.Tests/Utility/CompressionHelperTests.cs
+++ b/Tests/GameFrameX.Tests/Utility/CompressionHelperTests.cs
@@ -152,19 +152,78 @@ public void Decompress_WithNullInput_ShouldThrowArgumentNullException()
}
///
- /// 测试解压缩无效数据应返回原始数据
+ /// 测试解压缩无效数据应抛出 InvalidDataException(不再静默返回原始字节)
///
[Fact]
- public void Decompress_WithInvalidData_ShouldReturnOriginalData()
+ public void Decompress_WithInvalidData_ShouldThrowInvalidDataException()
{
// Arrange
var invalidData = new byte[] { 1, 2, 3, 4, 5 }; // 无效的压缩数据
+ // Act & Assert
+ var exception = Assert.Throws(() => CompressionHelper.Decompress(invalidData));
+ Assert.NotNull(exception.InnerException);
+ }
+
+ ///
+ /// 测试解压缩被截断的数据应快速抛出 InvalidDataException(防 SharpZipLib 空转死循环)
+ ///
+ [Fact]
+ public void Decompress_WithTruncatedData_ShouldThrowInvalidDataException()
+ {
+ // Arrange:合法压缩流砍掉尾部(Deflate 结束标记与 Adler32 校验缺失)
+ var original = new byte[2048];
+ new Random(42).NextBytes(original);
+ var compressed = CompressionHelper.Compress(original);
+ var truncated = new byte[compressed.Length / 2];
+ Array.Copy(compressed, truncated, truncated.Length);
+
+ // Act & Assert
+ Assert.Throws(() => CompressionHelper.Decompress(truncated));
+ }
+
+ ///
+ /// 测试解压输出超过显式上限应抛出 InvalidDataException(解压炸弹防线:膨胀过程内中止)
+ ///
+ [Fact]
+ public void Decompress_ExceedingOutputLimit_ShouldThrowInvalidDataException()
+ {
+ // Arrange:256KB 全零载荷高压缩比压缩,上限 1KB——修复前会完整膨胀 256KB 后才可能被事后检查发现
+ var compressed = CompressionHelper.Compress(new byte[256 * 1024]);
+
+ // Act & Assert
+ Assert.Throws(() => CompressionHelper.Decompress(compressed, 1024));
+ }
+
+ ///
+ /// 测试显式上限内的正常解压应完成往返
+ ///
+ [Fact]
+ public void Decompress_WithinOutputLimit_ShouldRestoreOriginalData()
+ {
+ // Arrange
+ var original = new byte[4096];
+ new Random(7).NextBytes(original);
+ var compressed = CompressionHelper.Compress(original);
+
// Act
- var result = CompressionHelper.Decompress(invalidData);
+ var result = CompressionHelper.Decompress(compressed, original.Length);
// Assert
- Assert.Equal(invalidData, result);
+ Assert.Equal(original, result);
+ }
+
+ ///
+ /// 测试负上限参数应抛出 ArgumentOutOfRangeException
+ ///
+ [Fact]
+ public void Decompress_WithNegativeLimit_ShouldThrowArgumentOutOfRangeException()
+ {
+ // Arrange
+ var compressed = CompressionHelper.Compress(new byte[] { 1, 2, 3 });
+
+ // Act & Assert
+ Assert.Throws(() => CompressionHelper.Decompress(compressed, -1));
}
#endregion
diff --git a/Tests/GameFrameX.Tests/Utility/GlobalSettingsSettingConflictTests.cs b/Tests/GameFrameX.Tests/Utility/GlobalSettingsSettingConflictTests.cs
index db069958f..f495c69be 100644
--- a/Tests/GameFrameX.Tests/Utility/GlobalSettingsSettingConflictTests.cs
+++ b/Tests/GameFrameX.Tests/Utility/GlobalSettingsSettingConflictTests.cs
@@ -190,6 +190,32 @@ public void SetCurrentSetting_ConflictingSensitiveField_MessageOmitsValues()
Assert.DoesNotContain("secret-actual", exception.Message);
}
+ ///
+ /// Secret 类密钥字段(不含 "Password" 子串)冲突时,异常消息同样省略两侧值——
+ /// 修复前 HttpSignSecret/InterNodeAuthSecret 的明文会直接进异常消息并随启动日志输出。
+ ///
+ [Fact]
+ public void SetCurrentSetting_ConflictingSecretField_MessageOmitsValues()
+ {
+ var firstSetting = CreateSetting("Game");
+ firstSetting.InterNodeAuthSecret = "node-secret-expected";
+ firstSetting.HttpSignSecret = "sign-secret-expected";
+ GlobalSettings.SetCurrentSetting(firstSetting);
+
+ var secondSetting = CreateSetting("Social");
+ secondSetting.InterNodeAuthSecret = "node-secret-actual";
+ secondSetting.HttpSignSecret = "sign-secret-actual";
+
+ var exception = Assert.Throws(() => GlobalSettings.SetCurrentSetting(secondSetting));
+
+ Assert.Contains(nameof(AppSetting.InterNodeAuthSecret), exception.Message);
+ Assert.Contains(nameof(AppSetting.HttpSignSecret), exception.Message);
+ Assert.DoesNotContain("node-secret-expected", exception.Message);
+ Assert.DoesNotContain("node-secret-actual", exception.Message);
+ Assert.DoesNotContain("sign-secret-expected", exception.Message);
+ Assert.DoesNotContain("sign-secret-actual", exception.Message);
+ }
+
///
/// 冲突抛出后 CurrentSetting 保持原值(不被部分更新)。
///
diff --git a/Tests/GameFrameX.Tests/Utility/Math/FPTests.cs b/Tests/GameFrameX.Tests/Utility/Math/FPTests.cs
index b514912f2..f24f86535 100644
--- a/Tests/GameFrameX.Tests/Utility/Math/FPTests.cs
+++ b/Tests/GameFrameX.Tests/Utility/Math/FPTests.cs
@@ -719,9 +719,11 @@ public void Sqrt_WithNegativeValue_ShouldThrowArgumentOutOfRangeException()
{
// Arrange
FP negativeFp = -1;
-
- // Act & Assert
- Assert.Throws(() => FP.Sqrt(negativeFp));
+
+ // Act & Assert:本地化消息必须进 Message,参数名 "x" 必须进 ParamName(修复前两实参绑定颠倒)
+ var exception = Assert.Throws(() => FP.Sqrt(negativeFp));
+ Assert.Equal("x", exception.ParamName);
+ Assert.NotEqual("x", exception.Message);
}
///
@@ -848,9 +850,11 @@ public void Acos_WithOutOfRangeValue_ShouldThrowArgumentOutOfRangeException(int
{
// Arrange
FP fp = value;
-
- // Act & Assert
- Assert.Throws(() => FP.Acos(fp));
+
+ // Act & Assert:本地化消息必须进 Message,参数名 "x" 必须进 ParamName(修复前两实参绑定颠倒)
+ var exception = Assert.Throws(() => FP.Acos(fp));
+ Assert.Equal("x", exception.ParamName);
+ Assert.NotEqual("x", exception.Message);
}
///