154 lines
6.3 KiB
C#
154 lines
6.3 KiB
C#
using BuildFlow.Shared.Domain.Common;
|
||
using BuildFlow.Shared.Domain.Errors;
|
||
using BuildFlow.Shared.Domain.Exceptions;
|
||
using FluentAssertions;
|
||
using MediatR;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using NSubstitute;
|
||
using Scripting.Application.Services;
|
||
using Scripting.Domain.Entities.ScriptFunction;
|
||
using Scripting.Domain.Enums;
|
||
using Scripting.Infrastructure.Persistence;
|
||
using Xunit;
|
||
|
||
namespace BuildFlow.Scripting.Tests;
|
||
|
||
/// <summary>
|
||
/// 沙箱语义白名单测试:验证文本正则黑名单覆盖不到的绕过路径被语义分析拦截
|
||
/// (已导入命名空间下裸写类型名、反射成员入口、精确禁用类型),
|
||
/// 并验证正常语法糖(插值字符串、await、LINQ、JSON)不被误伤。
|
||
/// </summary>
|
||
public class SandboxSemanticWhitelistTests
|
||
{
|
||
private static (ScriptingDbContext db, ScriptExecutionService svc) CreateService()
|
||
{
|
||
var db = ScriptingTestDbFactory.Create();
|
||
var sender = Substitute.For<ISender>();
|
||
var currentUser = Substitute.For<ICurrentUserContext>();
|
||
currentUser.UserId.Returns("test-user");
|
||
currentUser.IPAddress.Returns("127.0.0.1");
|
||
var svc = new ScriptExecutionService(db, sender, currentUser);
|
||
return (db, svc);
|
||
}
|
||
|
||
private static ScriptFunctionEntity SeedScript(ScriptingDbContext db, string code)
|
||
{
|
||
var script = new ScriptFunctionEntity
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
Name = $"sandbox-{Guid.NewGuid():N}",
|
||
DisplayName = "sandbox-test",
|
||
Code = code,
|
||
Status = ScriptStatusEnum.Published,
|
||
TimeoutMs = 5000,
|
||
CreatedBy = "test",
|
||
UpdatedBy = "test",
|
||
OperatorIP = "127.0.0.1",
|
||
};
|
||
db.Scripts.Add(script);
|
||
db.SaveChanges();
|
||
return script;
|
||
}
|
||
|
||
// ── 绕过路径:全部应被语义白名单拦截 ──────────────────────────────────
|
||
|
||
[Theory]
|
||
[InlineData("""Environment.GetEnvironmentVariable("PATH"); return 1;""", "System.Environment")]
|
||
[InlineData("var x = System.Environment.MachineName; return 1;", "System.Environment")]
|
||
[InlineData("_ = new Thread(() => { }); return 1;", "System.Threading.Thread")]
|
||
[InlineData("\"abc\".GetType(); return 1;", "GetType")]
|
||
[InlineData("var t = typeof(string); return 1;", "System.Type")]
|
||
[InlineData("var x = System.AppDomain.CurrentDomain.FriendlyName; return 1;", "System.AppDomain")]
|
||
[InlineData("var x = Activator.CreateInstance(typeof(int)); return 1;", "Activator")]
|
||
public async Task Execute_BypassAttempt_IsRejectedBySemanticWhitelist(string code, string expectedFragment)
|
||
{
|
||
var (db, svc) = CreateService();
|
||
var script = SeedScript(db, code);
|
||
await using var _ = db;
|
||
|
||
var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None);
|
||
|
||
var exception = (await act.Should().ThrowAsync<BusinessException>())
|
||
.WithMessage("*禁用 API*")
|
||
.Which;
|
||
exception.Code.Should().Be(ErrorCode.Business);
|
||
exception.Message.Should().Contain(expectedFragment);
|
||
}
|
||
|
||
// ── 正常语法糖:不应误伤 ─────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public async Task Execute_InterpolatedString_Succeeds()
|
||
{
|
||
var (db, svc) = CreateService();
|
||
// 插值字符串编译期依赖 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler,
|
||
// 白名单必须放行该命名空间(Unsafe 已单独精确封禁)
|
||
var script = SeedScript(db, """return $"x={Variables["a"]}"; """);
|
||
await using var _ = db;
|
||
|
||
var result = await svc.ExecuteByIdAsync(script.Id, """{"a":7}""", CancellationToken.None);
|
||
|
||
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
|
||
result.ResultJson.Should().Be("\"x=7\"");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_AwaitAndLinq_Succeeds()
|
||
{
|
||
var (db, svc) = CreateService();
|
||
// await 编译期依赖 CompilerServices.TaskAwaiter;LINQ 依赖 System.Linq —— 均应在白名单内
|
||
var script = SeedScript(db,
|
||
"await Task.Delay(1, CancellationToken); var sum = new[] { 1L, 2L, 3L }.Sum(); return sum;");
|
||
await using var _ = db;
|
||
|
||
var result = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None);
|
||
|
||
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
|
||
result.ResultJson.Should().Be("6");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_JsonProcessing_Succeeds()
|
||
{
|
||
var (db, svc) = CreateService();
|
||
var script = SeedScript(db,
|
||
"""var doc = JsonDocument.Parse(InputJson); return doc.RootElement.GetProperty("k").GetString();""");
|
||
await using var _ = db;
|
||
|
||
var result = await svc.ExecuteByIdAsync(script.Id, """{"k":"v"}""", CancellationToken.None);
|
||
|
||
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
|
||
result.ResultJson.Should().Be("\"v\"");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Execute_ScriptDefinedLocalClass_Succeeds()
|
||
{
|
||
var (db, svc) = CreateService();
|
||
// 脚本自定义类型位于全局命名空间,必须放行
|
||
var script = SeedScript(db, "class P { public long V { get; set; } } return new P { V = 5 }.V;");
|
||
await using var _ = db;
|
||
|
||
var result = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None);
|
||
|
||
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
|
||
result.ResultJson.Should().Be("5");
|
||
}
|
||
|
||
// ── 同一代码只校验一次(缓存命中后仍保持拦截语义由缓存键保证) ────────
|
||
|
||
[Fact]
|
||
public async Task Execute_ForbiddenCodeTwice_SecondAlsoRejected()
|
||
{
|
||
var (db, svc) = CreateService();
|
||
var script = SeedScript(db, "\"abc\".GetType(); return 1;");
|
||
await using var _ = db;
|
||
|
||
await FluentActions.Awaiting(() => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None))
|
||
.Should().ThrowAsync<BusinessException>();
|
||
await FluentActions.Awaiting(() => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None))
|
||
.Should().ThrowAsync<BusinessException>()
|
||
.WithMessage("*禁用 API*");
|
||
}
|
||
}
|