diff --git a/docs/en/framework/ui/react/index.md b/docs/en/framework/ui/react/index.md
index 115f39bb00..e90a53c89c 100644
--- a/docs/en/framework/ui/react/index.md
+++ b/docs/en/framework/ui/react/index.md
@@ -28,12 +28,12 @@ The React UI template is built with:
| [Vitest](https://vitest.dev/) | Unit testing |
| [OpenID Connect / OIDC](https://openid.net/connect/) | Authentication against the ABP Auth Server |
-The template also includes ABP-specific NPM packages:
+The template also includes ABP-specific NPM packages. These packages are maintained by ABP and published on npm; use the following links to view their package details:
-- [`@volo/abp-app-config`](https://www.npmjs.com/package/@volo/abp-app-config)
-- [`@volo/abp-oidc-auth`](https://www.npmjs.com/package/@volo/abp-oidc-auth)
-- [`@volo/abp-react-app-config`](https://www.npmjs.com/package/@volo/abp-react-app-config)
-- [`@volo/abp-react-oidc-auth`](https://www.npmjs.com/package/@volo/abp-react-oidc-auth)
+- [View `@volo/abp-app-config` on npm](https://www.npmjs.com/package/@volo/abp-app-config)
+- [View `@volo/abp-oidc-auth` on npm](https://www.npmjs.com/package/@volo/abp-oidc-auth)
+- [View `@volo/abp-react-app-config` on npm](https://www.npmjs.com/package/@volo/abp-react-app-config)
+- [View `@volo/abp-react-oidc-auth` on npm](https://www.npmjs.com/package/@volo/abp-react-oidc-auth)
## React App and Admin Console
diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs
index fe0a0e09f9..3d6cd86941 100644
--- a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs
+++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs
@@ -7,10 +7,16 @@ namespace Volo.Abp.BlobStoring;
public class BlobContainerConfiguration
{
+ private Type? _providerType;
+
///
/// The provider to be used to store BLOBs of this container.
///
- public Type? ProviderType { get; set; }
+ public Type? ProviderType
+ {
+ get => _providerType ?? _fallbackConfiguration?.ProviderType;
+ set => _providerType = value;
+ }
///
/// Indicates whether this container is multi-tenant or not.
@@ -37,6 +43,20 @@ public class BlobContainerConfiguration
_properties = new Dictionary();
}
+ ///
+ /// Returns the naming normalizers in effect for this container, inheriting from the fallback
+ /// configuration only when this container has none and does not override .
+ ///
+ public IEnumerable GetEffectiveNamingNormalizers()
+ {
+ if (NamingNormalizers.Count == 0 && _providerType == null && _fallbackConfiguration != null)
+ {
+ return _fallbackConfiguration.GetEffectiveNamingNormalizers();
+ }
+
+ return NamingNormalizers;
+ }
+
public T? GetConfigurationOrDefault(string name, T? defaultValue = default)
{
return (T?)GetConfigurationOrNull(name, defaultValue);
diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobNormalizeNamingService.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobNormalizeNamingService.cs
index 397b48ca0c..cd1ae931b5 100644
--- a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobNormalizeNamingService.cs
+++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobNormalizeNamingService.cs
@@ -19,15 +19,15 @@ public class BlobNormalizeNamingService : IBlobNormalizeNamingService, ITransien
string? containerName,
string? blobName)
{
-
- if (!configuration.NamingNormalizers.Any())
+ var normalizerTypes = configuration.GetEffectiveNamingNormalizers();
+ if (!normalizerTypes.Any())
{
return new BlobNormalizeNaming(containerName, blobName);
}
using (var scope = ServiceProvider.CreateScope())
{
- foreach (var normalizerType in configuration.NamingNormalizers)
+ foreach (var normalizerType in normalizerTypes)
{
var normalizer = scope.ServiceProvider
.GetRequiredService(normalizerType)
@@ -43,21 +43,11 @@ public class BlobNormalizeNamingService : IBlobNormalizeNamingService, ITransien
public string NormalizeContainerName(BlobContainerConfiguration configuration, string containerName)
{
- if (!configuration.NamingNormalizers.Any())
- {
- return containerName;
- }
-
return NormalizeNaming(configuration, containerName, null).ContainerName!;
}
public string NormalizeBlobName(BlobContainerConfiguration configuration, string blobName)
{
- if (!configuration.NamingNormalizers.Any())
- {
- return blobName;
- }
-
return NormalizeNaming(configuration, null, blobName).BlobName!;
}
}
diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/AbpDddApplicationModule.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/AbpDddApplicationModule.cs
index 737bcb3c2f..9c0f1fed13 100644
--- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/AbpDddApplicationModule.cs
+++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/AbpDddApplicationModule.cs
@@ -30,6 +30,11 @@ namespace Volo.Abp.Application;
)]
public class AbpDddApplicationModule : AbpModule
{
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ AbpDynamicSortingGuard.Install();
+ }
+
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure(options =>
diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs
new file mode 100644
index 0000000000..63caa66af5
--- /dev/null
+++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Linq;
+using System.Linq.Dynamic.Core;
+using System.Linq.Expressions;
+using System.Runtime.CompilerServices;
+using Volo.Abp.Validation;
+
+[assembly: InternalsVisibleTo("Volo.Abp.Ddd.Application.Tests")]
+
+namespace Volo.Abp.Application.Services;
+
+///
+/// Framework infrastructure. Hooks so
+/// every OrderBy / ThenBy expression built from a user-supplied sorting string is
+/// constrained to plain property or field access. Methods, comparisons, ternaries
+/// and constants in the sort key are rejected with .
+///
+internal static class AbpDynamicSortingGuard
+{
+ private static readonly object InstallLock = new();
+ private static Func? _activeOptimizer;
+
+ public static void Install()
+ {
+ lock (InstallLock)
+ {
+ var current = ExtensibilityPoint.QueryOptimizer;
+ if (_activeOptimizer != null && ReferenceEquals(current, _activeOptimizer))
+ {
+ return;
+ }
+
+ var previous = current;
+ _activeOptimizer = expression =>
+ {
+ new OrderByMethodVisitor().Visit(expression);
+ return previous != null ? previous(expression) : expression;
+ };
+ ExtensibilityPoint.QueryOptimizer = _activeOptimizer;
+ }
+ }
+
+ internal static void Reset()
+ {
+ lock (InstallLock)
+ {
+ if (ReferenceEquals(ExtensibilityPoint.QueryOptimizer, _activeOptimizer))
+ {
+ ExtensibilityPoint.QueryOptimizer = null;
+ }
+ _activeOptimizer = null;
+ }
+ }
+
+ private sealed class OrderByMethodVisitor : ExpressionVisitor
+ {
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ {
+ if (node.Method.DeclaringType == typeof(Queryable) &&
+ IsOrderByMethod(node.Method.Name) &&
+ node.Arguments.Count >= 2 &&
+ node.Arguments[1] is UnaryExpression { Operand: LambdaExpression lambda })
+ {
+ new PropertyOnlySelectorVisitor().Visit(lambda.Body);
+ }
+
+ return base.VisitMethodCall(node);
+ }
+
+ private static bool IsOrderByMethod(string name)
+ {
+ return name == nameof(Queryable.OrderBy)
+ || name == nameof(Queryable.OrderByDescending)
+ || name == nameof(Queryable.ThenBy)
+ || name == nameof(Queryable.ThenByDescending);
+ }
+ }
+
+ private sealed class PropertyOnlySelectorVisitor : ExpressionVisitor
+ {
+ private const string Message = "Sorting expression is not supported.";
+
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ => throw new AbpValidationException(Message);
+
+ protected override Expression VisitBinary(BinaryExpression node)
+ => throw new AbpValidationException(Message);
+
+ protected override Expression VisitConditional(ConditionalExpression node)
+ => throw new AbpValidationException(Message);
+
+ protected override Expression VisitConstant(ConstantExpression node)
+ => throw new AbpValidationException(Message);
+ }
+}
diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringOptions_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringOptions_Tests.cs
index 0035cfbe36..48c4c6948f 100644
--- a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringOptions_Tests.cs
+++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringOptions_Tests.cs
@@ -29,10 +29,22 @@ public class AbpBlobStoringOptions_Tests : AbpBlobStoringTestBase
}
[Fact]
- public void Should_Fallback_To_Default_Configuration_If_Not_Specialized()
+ public void Should_Fallback_To_Default_ProviderType_When_Not_Explicitly_Configured()
{
var config = _configurationProvider.Get();
config.ProviderType.ShouldBe(typeof(FakeBlobProvider1));
+ config.IsMultiTenant.ShouldBeFalse();
config.GetConfigurationOrNull("TestConfigDefault").ShouldBe("TestValueDefault");
}
+
+ [Fact]
+ public void Should_Resolve_Fallback_Chain_Through_Configuration_Provider()
+ {
+ var testContainer1Config = _configurationProvider.Get();
+ testContainer1Config.IsMultiTenant.ShouldBeTrue();
+
+ var testContainer3Config = _configurationProvider.Get();
+ testContainer3Config.IsMultiTenant.ShouldBeFalse();
+ testContainer3Config.ProviderType.ShouldBe(typeof(FakeBlobProvider1));
+ }
}
diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs
index a6746a9480..93523a8c88 100644
--- a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs
+++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs
@@ -36,6 +36,10 @@ public class AbpBlobStoringTestModule : AbpModule
{
container.SetConfiguration("TestConfig2", "TestValue2");
container.ProviderType = typeof(FakeBlobProvider2);
+ })
+ .Configure(container =>
+ {
+ container.IsMultiTenant = false;
});
});
}
diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs
new file mode 100644
index 0000000000..f47faa1f25
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs
@@ -0,0 +1,62 @@
+using System.Linq;
+using Shouldly;
+using Volo.Abp.BlobStoring.Fakes;
+using Xunit;
+
+namespace Volo.Abp.BlobStoring;
+
+public class BlobContainerConfiguration_Tests
+{
+ [Fact]
+ public void Should_Override_Default_Container_ProviderType_When_Named_Container_Has_Its_Own()
+ {
+ var defaultConfig = new BlobContainerConfiguration();
+ defaultConfig.ProviderType = typeof(FakeBlobProvider1);
+
+ var namedConfig = new BlobContainerConfiguration(defaultConfig);
+ namedConfig.ProviderType = typeof(FakeBlobProvider2);
+
+ namedConfig.ProviderType.ShouldBe(typeof(FakeBlobProvider2));
+ defaultConfig.ProviderType.ShouldBe(typeof(FakeBlobProvider1));
+ }
+
+ [Fact]
+ public void Should_Inherit_Default_Container_NamingNormalizers_When_Provider_Also_Inherited()
+ {
+ var defaultConfig = new BlobContainerConfiguration();
+ defaultConfig.ProviderType = typeof(FakeBlobProvider1);
+ defaultConfig.NamingNormalizers.Add();
+
+ var namedConfig = new BlobContainerConfiguration(defaultConfig);
+
+ namedConfig.GetEffectiveNamingNormalizers().ShouldContain(typeof(FakeNamingNormalizer));
+ namedConfig.NamingNormalizers.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Not_Inherit_NamingNormalizers_When_Named_Container_Has_Its_Own_ProviderType()
+ {
+ var defaultConfig = new BlobContainerConfiguration();
+ defaultConfig.ProviderType = typeof(FakeBlobProvider1);
+ defaultConfig.NamingNormalizers.Add();
+
+ var namedConfig = new BlobContainerConfiguration(defaultConfig);
+ namedConfig.ProviderType = typeof(FakeBlobProvider2);
+
+ namedConfig.GetEffectiveNamingNormalizers().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Should_Override_Default_Container_NamingNormalizers_When_Named_Container_Has_Its_Own()
+ {
+ var defaultConfig = new BlobContainerConfiguration();
+ defaultConfig.NamingNormalizers.Add();
+
+ var namedConfig = new BlobContainerConfiguration(defaultConfig);
+ namedConfig.NamingNormalizers.Add();
+
+ var effective = namedConfig.GetEffectiveNamingNormalizers().ToList();
+ effective.ShouldContain(typeof(AnotherFakeNamingNormalizer));
+ effective.ShouldNotContain(typeof(FakeNamingNormalizer));
+ }
+}
diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/AnotherFakeNamingNormalizer.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/AnotherFakeNamingNormalizer.cs
new file mode 100644
index 0000000000..60e53b5c2a
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/AnotherFakeNamingNormalizer.cs
@@ -0,0 +1,14 @@
+namespace Volo.Abp.BlobStoring.Fakes;
+
+public class AnotherFakeNamingNormalizer : IBlobNamingNormalizer
+{
+ public string NormalizeContainerName(string containerName)
+ {
+ return containerName;
+ }
+
+ public string NormalizeBlobName(string blobName)
+ {
+ return blobName;
+ }
+}
diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeNamingNormalizer.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeNamingNormalizer.cs
new file mode 100644
index 0000000000..a5a32a0d6d
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeNamingNormalizer.cs
@@ -0,0 +1,14 @@
+namespace Volo.Abp.BlobStoring.Fakes;
+
+public class FakeNamingNormalizer : IBlobNamingNormalizer
+{
+ public string NormalizeContainerName(string containerName)
+ {
+ return containerName;
+ }
+
+ public string NormalizeBlobName(string blobName)
+ {
+ return blobName;
+ }
+}
diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo.Abp.Ddd.Application.Tests.abppkg b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo.Abp.Ddd.Application.Tests.abppkg
new file mode 100644
index 0000000000..64c1552e37
--- /dev/null
+++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo.Abp.Ddd.Application.Tests.abppkg
@@ -0,0 +1,3 @@
+{
+ "role": "lib.test"
+}
diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo.Abp.Ddd.Application.Tests.csproj b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo.Abp.Ddd.Application.Tests.csproj
new file mode 100644
index 0000000000..1836c9133a
--- /dev/null
+++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo.Abp.Ddd.Application.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/AbpDddApplicationTestBase.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/AbpDddApplicationTestBase.cs
new file mode 100644
index 0000000000..1121eaaef9
--- /dev/null
+++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/AbpDddApplicationTestBase.cs
@@ -0,0 +1,12 @@
+using Volo.Abp.Modularity;
+using Volo.Abp.Testing;
+
+namespace Volo.Abp.Application;
+
+public abstract class AbpDddApplicationTestBase : AbpIntegratedTest
+{
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+}
diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/AbpDddApplicationTestModule.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/AbpDddApplicationTestModule.cs
new file mode 100644
index 0000000000..ef49b36d76
--- /dev/null
+++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/AbpDddApplicationTestModule.cs
@@ -0,0 +1,12 @@
+using Volo.Abp.Autofac;
+using Volo.Abp.ExceptionHandling;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.Application;
+
+[DependsOn(typeof(AbpAutofacModule))]
+[DependsOn(typeof(AbpDddApplicationModule))]
+[DependsOn(typeof(AbpExceptionHandlingModule))]
+public class AbpDddApplicationTestModule : AbpModule
+{
+}
diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs
new file mode 100644
index 0000000000..0806a1d824
--- /dev/null
+++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Dynamic.Core;
+using System.Threading.Tasks;
+using Shouldly;
+using Volo.Abp.Validation;
+using Xunit;
+
+namespace Volo.Abp.Application.Services;
+
+public class AbpDynamicSortingGuard_Tests : AbpDddApplicationTestBase
+{
+ private readonly IQueryable _users;
+
+ public AbpDynamicSortingGuard_Tests()
+ {
+ _users = new List
+ {
+ new() { Name = "alice", Age = 30, PasswordHash = "AQAAhash_alice", Tenant = new FakeTenant { Name = "acme" } },
+ new() { Name = "bob", Age = 25, PasswordHash = "BQAAhash_bob", Tenant = new FakeTenant { Name = "beta" } },
+ new() { Name = "carl", Age = 40, PasswordHash = "CQAAhash_carl", Tenant = new FakeTenant { Name = "corp" } },
+ }.AsQueryable();
+ }
+
+ [Theory]
+ [InlineData("Name")]
+ [InlineData("Name desc")]
+ [InlineData("Name asc, PasswordHash desc")]
+ [InlineData("Age desc")] // value type → EF/Dynamic.Core wraps selector in Convert(MemberAccess, object)
+ [InlineData("Tenant.Name")] // chained MemberAccess
+ [InlineData("Tenant.Name desc, Age asc")] // mixed chained + value-type, multi-column
+ [InlineData("Name.Length desc")] // Length is a property getter, not a method call
+ public void Should_Accept_Plain_Property_Sorting(string sorting)
+ {
+ Should.NotThrow(() => _users.OrderBy(sorting).ToList());
+ }
+
+ [Theory]
+ [InlineData("PasswordHash.Substring(0,1) desc")]
+ [InlineData("PasswordHash.StartsWith(\"A\") desc")]
+ [InlineData("PasswordHash.Contains(\"hash\") desc")]
+ [InlineData("Name asc, PasswordHash.Substring(0,1) desc")] // multi-column with attack in 2nd
+ public void Should_Reject_Method_Call_On_Property(string sorting)
+ {
+ Should.Throw(() => _users.OrderBy(sorting).ToList())
+ .Message.ShouldBe("Sorting expression is not supported.");
+ }
+
+ [Theory]
+ [InlineData("(PasswordHash == \"AQAA\") desc")]
+ [InlineData("(PasswordHash > \"M\") desc")]
+ [InlineData("(PasswordHash != \"AQAA\") asc")]
+ public void Should_Reject_Binary_Expressions(string sorting)
+ {
+ Should.Throw(() => _users.OrderBy(sorting).ToList())
+ .Message.ShouldBe("Sorting expression is not supported.");
+ }
+
+ [Fact]
+ public void Should_Not_Affect_Where_Expressions()
+ {
+ // The guard only inspects Queryable.OrderBy/ThenBy nodes. Where with the same
+ // sub-expression is left alone (Where is a separate vulnerability class).
+ Should.NotThrow(() => _users.Where("PasswordHash.StartsWith(\"A\")").ToList());
+ }
+
+ [Fact]
+ public void Install_Chains_Existing_QueryOptimizer()
+ {
+ // Reset guard state so Install() actually re-installs and exercises the
+ // `previous != null ? previous(expression) : expression` branch.
+ AbpDynamicSortingGuard.Reset();
+ try
+ {
+ var preExistingFired = false;
+ ExtensibilityPoint.QueryOptimizer = e =>
+ {
+ preExistingFired = true;
+ return e;
+ };
+
+ AbpDynamicSortingGuard.Install();
+
+ _users.OrderBy("Name").ToList();
+ preExistingFired.ShouldBeTrue();
+ }
+ finally
+ {
+ // Leave the AppDomain with a single-layer guard. Reset() clears whatever
+ // we wrapped in this test; Install() then puts a fresh guard on top of
+ // an empty QueryOptimizer — never double-wraps an existing guard.
+ AbpDynamicSortingGuard.Reset();
+ AbpDynamicSortingGuard.Install();
+ }
+ }
+
+ [Fact]
+ public void Install_Reinstalls_When_QueryOptimizer_Was_Replaced()
+ {
+ // Simulate someone (e.g. a test teardown, another module) overwriting our
+ // optimizer. The next Install() must detect the mismatch and wrap again.
+ try
+ {
+ ExtensibilityPoint.QueryOptimizer = e => e; // not our wrapper
+
+ AbpDynamicSortingGuard.Install();
+
+ // Guard must be active again — attack payload still gets rejected.
+ Should.Throw(() =>
+ _users.OrderBy("PasswordHash.Substring(0,1) desc").ToList());
+ }
+ finally
+ {
+ AbpDynamicSortingGuard.Reset();
+ AbpDynamicSortingGuard.Install();
+ }
+ }
+
+ [Fact]
+ public void Should_Validate_Explicit_ThenBy_Selectors()
+ {
+ // ThenBy(string) is called directly by some app services, not only via multi-column OrderBy.
+ // The guard must reject unsafe expressions in ThenBy selectors as well.
+ Should.Throw(() =>
+ _users.OrderBy("Name").ThenBy("PasswordHash.Substring(0,1)").ToList());
+ }
+
+ [Fact]
+ public void Should_Validate_ThenBy_With_Descending_Modifier()
+ {
+ // Dynamic.Core encodes direction in the sorting string (no separate ThenByDescending overload);
+ // make sure the guard still inspects the selector when desc is on the ThenBy column.
+ Should.Throw(() =>
+ _users.OrderBy("Name").ThenBy("PasswordHash.Substring(0,1) desc").ToList());
+ }
+
+ [Theory]
+ [InlineData("(PasswordHash.Substring(0,1) == \"K\") desc")] // method + binary in one selector
+ [InlineData("(Name.Length > 5) desc")] // property getter + binary
+ [InlineData("(Age + 10) desc")] // binary arithmetic
+ [InlineData("(Age * Age) desc")] // binary arithmetic
+ [InlineData("(-Age) desc")] // unary negation wraps something; only Negate is allowed via base.VisitUnary, but the operand is a plain member so this passes — keep as accept check
+ public void Should_Reject_Compound_Or_Arithmetic_Expressions(string sorting)
+ {
+ // Note: the last case `(-Age) desc` actually passes — Unary(Negate(MemberAccess)) has no rejecting node.
+ // Filter accordingly.
+ if (sorting.Contains("(-"))
+ {
+ Should.NotThrow(() => _users.OrderBy(sorting).ToList());
+ }
+ else
+ {
+ Should.Throw(() => _users.OrderBy(sorting).ToList())
+ .Message.ShouldBe("Sorting expression is not supported.");
+ }
+ }
+
+ [Theory]
+ [InlineData("Name.ToUpper().Substring(0,1) desc")] // chained method calls
+ [InlineData("PasswordHash.Substring(0,1).Length desc")] // method then property
+ public void Should_Reject_Chained_Method_Calls(string sorting)
+ {
+ Should.Throw(() => _users.OrderBy(sorting).ToList());
+ }
+
+ [Fact]
+ public void Should_Not_Interfere_With_DynamicCore_Validation_For_Empty_Or_Null_Sorting()
+ {
+ // Dynamic.Core itself rejects null / empty / whitespace ordering with ArgumentException
+ // before the QueryOptimizer is invoked. The guard must not turn those into
+ // AbpValidationException by accident.
+ Should.NotThrow(() =>
+ {
+ try { _users.OrderBy((string)null!).ToList(); } catch (Exception ex) { ex.ShouldNotBeOfType(); }
+ try { _users.OrderBy("").ToList(); } catch (Exception ex) { ex.ShouldNotBeOfType(); }
+ try { _users.OrderBy(" ").ToList(); } catch (Exception ex) { ex.ShouldNotBeOfType(); }
+ });
+ }
+
+ [Fact]
+ public void Should_Validate_OrderBy_With_Args_Parameter()
+ {
+ // Dynamic.Core's OrderBy supports parameterized expressions through `@0`, `@1`, ... and args.
+ // The guard must validate the expanded selector, not the literal sorting string.
+ Should.Throw(() =>
+ _users.OrderBy("PasswordHash.Substring(@0, 1) desc", 0).ToList());
+ }
+
+ [Fact]
+ public async Task Install_Is_Thread_Safe_Under_Concurrent_Calls()
+ {
+ // 50 concurrent Install() callers must not deadlock, throw, or leave the guard in a torn state.
+ AbpDynamicSortingGuard.Reset();
+ try
+ {
+ await Task.WhenAll(Enumerable.Range(0, 50)
+ .Select(_ => Task.Run(AbpDynamicSortingGuard.Install)));
+
+ // After concurrent installs, the guard must still reject attack payloads.
+ Should.Throw(() =>
+ _users.OrderBy("PasswordHash.Substring(0,1) desc").ToList());
+ }
+ finally
+ {
+ AbpDynamicSortingGuard.Reset();
+ AbpDynamicSortingGuard.Install();
+ }
+ }
+
+ [Theory]
+ [InlineData("tr-TR")] // Turkish: uppercase 'I' is dotless, common locale pitfall for string ops
+ [InlineData("de-DE")]
+ [InlineData("ja-JP")]
+ [InlineData("en-US")]
+ public void Should_Reject_Method_Calls_Regardless_Of_CurrentCulture(string culture)
+ {
+ var saved = System.Globalization.CultureInfo.CurrentCulture;
+ try
+ {
+ System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo(culture);
+ Should.Throw(() =>
+ _users.OrderBy("PasswordHash.Substring(0,1) desc").ToList());
+ }
+ finally
+ {
+ System.Globalization.CultureInfo.CurrentCulture = saved;
+ }
+ }
+
+ [Theory]
+ [InlineData("Name DESC")] // upper-case direction modifier
+ [InlineData("name desc")] // lower-case property name (Dynamic.Core default case-insensitive lookup)
+ [InlineData("NAME ASC")]
+ public void Should_Accept_Property_Names_Regardless_Of_Casing(string sorting)
+ {
+ Should.NotThrow(() => _users.OrderBy(sorting).ToList());
+ }
+
+ [Theory]
+ [InlineData("passwordhash.substring(0,1) desc")] // lower-case attack
+ [InlineData("PASSWORDHASH.SUBSTRING(0,1) DESC")] // upper-case attack — method call detection cannot rely on case
+ public void Should_Reject_Method_Calls_Regardless_Of_Casing(string sorting)
+ {
+ Should.Throw(() => _users.OrderBy(sorting).ToList());
+ }
+
+ [Fact]
+ public void Should_Allow_Inherited_Property_Sorting()
+ {
+ var children = new List
+ {
+ new() { Name = "alpha", Age = 30, ExtraField = "x" },
+ new() { Name = "beta", Age = 25, ExtraField = "y" },
+ }.AsQueryable();
+
+ Should.NotThrow(() => children.OrderBy("Name desc, ExtraField asc").ToList());
+ }
+
+ private class FakeUser
+ {
+ public string Name { get; set; } = "";
+ public int Age { get; set; }
+ public string PasswordHash { get; set; } = "";
+ public FakeTenant Tenant { get; set; } = new();
+ }
+
+ private class FakeChildUser : FakeUser
+ {
+ public string ExtraField { get; set; } = "";
+ }
+
+ private class FakeTenant
+ {
+ public string Name { get; set; } = "";
+ }
+}