修复相关漏洞

This commit is contained in:
fuenmao
2026-09-17 09:53:50 +08:00
parent 20ea1bc70c
commit e9894538e8
28 changed files with 757 additions and 51 deletions
+90
View File
@@ -0,0 +1,90 @@
using System.Collections.Concurrent;
using SimpleAdmin.Core;
using SimpleAdmin.Application;
using SqlSugar;
var passed = 0;
void Check(bool condition, string name) {
if (!condition) throw new Exception("FAIL: " + name);
passed++;
Console.WriteLine("PASS: " + name);
}
void Reject(Action action, string name) {
try { action(); } catch (InvalidOperationException) { Check(true, name); return; }
throw new Exception("FAIL: expected rejection: " + name);
}
// Uses the real BasePageInput and real ORM SQL generation; never opens a DB connection.
using var db = new SqlSugarClient(new ConnectionConfig {
ConnectionString = "Server=127.0.0.1;Database=never_connected;Uid=unused;Pwd=unused;",
DbType = SqlSugar.DbType.MySql, IsAutoCloseConnection = true
});
foreach (var order in new[] { "asc, CASE WHEN 1=1 THEN 0 ELSE 1 END", "desc;DROP TABLE x", "asc--", "asc\tLIMIT 1", "random()" })
Reject(() => new BasePageInput { SortField = "Id", SortOrder = order }.GetSafeOrderBy("Id"), "reject sort direction: " + order);
foreach (var field in new[] { "Id,(CASE\tWHEN\t1=1\tTHEN\t0\tELSE\t1\tEND)", "Id\tDESC", "Id/**/", "Id;SELECT", "Id\n", "u.Id", "`Id`", "Id,Name", new string('a', 65) })
Reject(() => new BasePageInput { SortField = field }.GetSafeOrderBy("Id"), "reject sort field: " + field.Replace("\n", "<newline>"));
Reject(() => new BasePageInput { SortField = "NotAllowed" }.GetSafeOrderBy("Id"), "column whitelist enforced");
foreach (var sample in new[] { ("ascend", "asc"), ("descend", "desc"), (" ASC ", "asc"), ("", "desc"), ((string)null, "desc") }) {
var input = new BasePageInput { SortField = "id", SortOrder = sample.Item1 };
var orderBy = input.GetSafeOrderBy("Id", "Name");
Check(orderBy == "Id " + sample.Item2, "normalizes direction and maps column: " + sample.Item1);
var sql = db.Queryable<TestRow>().OrderBy(orderBy).ToSql().Key;
Check(sql.Contains("ORDER BY", StringComparison.OrdinalIgnoreCase) && !sql.Contains("CASE"), "safe ORM SQL generated");
}
Check(new BasePageInput().GetSafeOrderBy("Id") == "", "no requested sort preserves default");
// Uses the real state guard and production status constants.
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.PendingCompanySeal)
== ElectronicSignStatus.PendingCompanySeal, "employee signed -> company seal");
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, ElectronicSignStatus.Completed)
== ElectronicSignStatus.Completed, "company seal -> completed");
Check(OnboardingSignStateGuard.Resolve(ElectronicSignStatus.Archived, ElectronicSignStatus.Completed)
== ElectronicSignStatus.Archived, "completed provider result preserves local archive");
foreach (var terminal in new[] { ElectronicSignStatus.Completed, ElectronicSignStatus.Archived, ElectronicSignStatus.Invalid, ElectronicSignStatus.Expired, ElectronicSignStatus.Rejected }) {
Check(OnboardingSignStateGuard.Resolve(terminal, terminal) == terminal, "terminal replay allowed: " + terminal);
Reject(() => OnboardingSignStateGuard.Resolve(terminal, ElectronicSignStatus.PendingEmployeeSign), "terminal rollback rejected: " + terminal);
}
foreach (var backward in new[] { ElectronicSignStatus.PendingEmployeeSign, ElectronicSignStatus.Draft, ElectronicSignStatus.Creating, ElectronicSignStatus.Failed })
Reject(() => OnboardingSignStateGuard.Resolve(ElectronicSignStatus.PendingCompanySeal, backward), "signed progress cannot regress: " + backward);
// Exercises the real async gate with a fake external lease. Does NOT claim MySQL GET_LOCK integration.
var leases = new FakeLeases();
Task<int> Gate(string key, Func<Task<int>> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var first = Gate("task:1", async () => { entered.SetResult(); await release.Task; return 1; });
await entered.Task;
var contenders = Enumerable.Range(0, 64).Select(async _ => {
try { await Gate("task:1", () => Task.FromResult(2)); return false; }
catch (InvalidOperationException) { return true; }
}).ToArray();
Check((await Task.WhenAll(contenders)).All(x => x), "64 independent same-task operations rejected while held");
Check(await Gate("task:2", () => Task.FromResult(7)) == 7, "different tasks proceed independently");
release.SetResult();
await first;
Check(await Gate("task:1", () => Task.FromResult(3)) == 3, "lock released after completion");
var before = leases.AcquisitionCount;
Check(await Gate("nested", () => Gate("nested", () => Gate("identity", () => Gate("nested", () => Task.FromResult(9))))) == 9,
"nested task/snapshot call is reentrant");
Check(leases.AcquisitionCount - before == 2, "reentrant gate acquires each resource only once");
try { await Gate("failure", () => throw new ApplicationException("synthetic fault")); } catch (ApplicationException) {}
Check(await Gate("failure", () => Task.FromResult(4)) == 4, "lock released on exception");
Check(leases.ActiveCount == 0, "no leaked leases or caller async context");
Console.WriteLine("ALL " + passed + " CHECKS PASSED (offline; no business database or provider contacted)");
[SugarTable("test_row")]
public class TestRow { public long Id { get; set; } public string Name { get; set; } }
public sealed class FakeLeases {
private readonly ConcurrentDictionary<string, byte> held = new();
private int acquisitions;
public int AcquisitionCount => acquisitions;
public int ActiveCount => held.Count;
public Task<IAsyncDisposable> Acquire(string key) {
if (!held.TryAdd(key, 0)) throw new InvalidOperationException("busy");
Interlocked.Increment(ref acquisitions);
return Task.FromResult<IAsyncDisposable>(new Lease(() => held.TryRemove(key, out _)));
}
private sealed class Lease(Action release) : IAsyncDisposable {
public ValueTask DisposeAsync() { release(); return ValueTask.CompletedTask; }
}
}
+34
View File
@@ -0,0 +1,34 @@
# 第 2、5 项安全修复离线回归
本测试直接链接生产代码的 BasePageInput、OnboardingOperationGate、OnboardingSignStateGuard 和状态常量。
不启动业务程序、不连接业务数据库、不联系契约锁,也不查询在线漏洞库。
SqlSugar 只生成 SQL。互斥测试使用模拟的外部租约,不代表 MySQL 集成测试。
## 运行
先在后端构建相应 net6.0 项目,确保 SimpleAdmin.Web.Entry/bin/Debug/net6.0 下已有 SqlSugar.dll 和 Newtonsoft.Json.dll。
测试需要本机已安装 .NET 8 SDK/运行时。
在后端目录执行:
```powershell
dotnet run --project tests/SecurityRegression/SecurityRegression.csproj -p:NuGetAudit=false -p:UseSharedCompilation=false
```
测试使用本地 empty-feed,不向外网发送依赖信息。无新增 NuGet 包。
框架校验属性和友好异常由 Stubs.cs 替代;核心排序、异步重入和状态迁移逻辑直接引用源文件。
## 覆盖
- SQL 排序表达式、注释、分号、制表符、换行、未知字段被拒绝。
- asc/desc/ascend/descend、大小写及空排序方向兼容。
- 安全排序由本地 ORM 生成 SQL。
- 签约终态、防回退、归档状态保持。
- 64 个竞争调用与同任务互斥;不同任务互不阻塞。
- 同调用链嵌套任务锁可重入;正常/异常结束释放锁;调用方上下文不泄漏。
## 仍需测试环境验证
MySQL GET_LOCK/RELEASE_LOCK 的多会话、多实例互斥、实际业务事务回滚、
提交与审核/撤销竞争、转正与编辑竞争、重复/乱序契约锁回调、企业盖章网络断开及中断恢复。
不得将这套离线用例当成真实平台压测或数据库集成测试结果。
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks></TargetFrameworks><OutputType>Exe</OutputType><TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><NuGetAudit>false</NuGetAudit>
<RestoreSources>$(MSBuildThisFileDirectory)empty-feed</RestoreSources>
</PropertyGroup>
<ItemGroup>
<Reference Include="SqlSugar"><HintPath>../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/SqlSugar.dll</HintPath></Reference>
<Reference Include="Newtonsoft.Json"><HintPath>../../SimpleAdmin.Web.Entry/bin/Debug/net6.0/Newtonsoft.Json.dll</HintPath></Reference>
<Compile Include="../../SimpleAdmin.Core/BaseInput/BasePageInput.cs" Link="BasePageInput.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/Common/OnboardingOperationGate.cs" Link="OnboardingOperationGate.cs"/>
<Compile Include="../../SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingSignStateGuard.cs" Link="OnboardingSignStateGuard.cs"/>
<Compile Include="../../SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs" Link="OnboardingStatus.cs"/>
</ItemGroup>
</Project>
+10
View File
@@ -0,0 +1,10 @@
global using System.ComponentModel.DataAnnotations;
namespace SimpleAdmin.Core {
[AttributeUsage(AttributeTargets.Property)]
public sealed class DataValidationAttribute : Attribute { public DataValidationAttribute(ValidationTypes type) {} }
public enum ValidationTypes { Numeric }
public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); }
}
namespace SimpleAdmin.Application {
public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); }
}