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); } ///