diff --git a/.gitignore b/.gitignore index 2566d39..ee2a514 100644 --- a/.gitignore +++ b/.gitignore @@ -412,3 +412,6 @@ docs/markdown-cheat-sheet.md # DocFX _site api + +# Claude +.claude diff --git a/JsonApiToolkit.Tests/Attributes/AllowedIncludesAttributeTests.cs b/JsonApiToolkit.Tests/Attributes/AllowedIncludesAttributeTests.cs new file mode 100644 index 0000000..e0d53f9 --- /dev/null +++ b/JsonApiToolkit.Tests/Attributes/AllowedIncludesAttributeTests.cs @@ -0,0 +1,282 @@ +using JsonApiToolkit.Attributes; +using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Models.Querying; +using JsonApiToolkit.Parsing; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace JsonApiToolkit.Tests.Attributes; + +public class AllowedIncludesAttributeTests +{ + private ActionExecutingContext CreateContext( + string? includeQueryParam = null, + string? actionName = null + ) + { + var httpContext = new DefaultHttpContext(); + var services = new ServiceCollection(); + services.AddSingleton>( + Mock.Of>() + ); + httpContext.RequestServices = services.BuildServiceProvider(); + + if (includeQueryParam != null) + { + httpContext.Request.QueryString = new QueryString($"?include={includeQueryParam}"); + var queryCollection = new Dictionary + { + { "include", includeQueryParam }, + }; + httpContext.Request.Query = new QueryCollection(queryCollection); + } + + var actionContext = new ActionContext( + httpContext, + new RouteData(), + new ActionDescriptor { DisplayName = actionName ?? "TestAction" } + ); + + var context = new ActionExecutingContext( + actionContext, + new List(), + new Dictionary(), + controller: new object() + ); + + return context; + } + + [Fact] + public void Constructor_WithNullArray_TreatsAsEmpty() + { + var attribute = new AllowedIncludesAttribute(null!); + Assert.Empty(attribute.AllowedIncludes); + } + + [Fact] + public void Constructor_WithEmptyArray_StoresEmpty() + { + var attribute = new AllowedIncludesAttribute(); + Assert.Empty(attribute.AllowedIncludes); + } + + [Fact] + public void Constructor_WithIncludes_StoresIncludes() + { + var attribute = new AllowedIncludesAttribute("author", "posts"); + Assert.Equal(2, attribute.AllowedIncludes.Length); + Assert.Contains("author", attribute.AllowedIncludes); + Assert.Contains("posts", attribute.AllowedIncludes); + } + + [Fact] + public void OnActionExecuting_NoIncludeParam_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("author"); + var context = CreateContext(); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_EmptyIncludeParam_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("author"); + var context = CreateContext(""); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_AllowedInclude_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("author", "posts"); + var context = CreateContext("author"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_MultipleAllowedIncludes_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("author", "posts", "comments"); + var context = CreateContext("author,posts"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_ForbiddenInclude_ReturnsForbidden() + { + var attribute = new AllowedIncludesAttribute("author"); + var context = CreateContext("posts"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + Assert.Equal(403, objectResult.StatusCode); + + var errorResponse = Assert.IsType(objectResult.Value); + Assert.Single(errorResponse.Errors); + Assert.Equal("403", errorResponse.Errors[0].Status); + Assert.Contains("posts", errorResponse.Errors[0].Detail); + } + + [Fact] + public void OnActionExecuting_EmptyAllowedArray_ForbidsAllIncludes() + { + var attribute = new AllowedIncludesAttribute(); + var context = CreateContext("author"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + Assert.Equal(403, objectResult.StatusCode); + } + + [Fact] + public void OnActionExecuting_CaseInsensitive_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("Author"); + var context = CreateContext("author"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_CaseInsensitiveReverse_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("author"); + var context = CreateContext("Author"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_PartialPath_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("author.posts"); + var context = CreateContext("author"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_FullPath_AllowsRequest() + { + var attribute = new AllowedIncludesAttribute("author.posts"); + var context = CreateContext("author.posts"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_ExtendedPath_ForbidsRequest() + { + var attribute = new AllowedIncludesAttribute("author.posts"); + var context = CreateContext("author.posts.comments"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + Assert.Equal(403, objectResult.StatusCode); + } + + [Fact] + public void OnActionExecuting_JsonApiCreatedAction_SkipsValidation() + { + var attribute = new AllowedIncludesAttribute("author"); + var context = CreateContext("forbidden", "JsonApiCreated"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_MultipleForbidden_ReturnsAllInError() + { + var attribute = new AllowedIncludesAttribute("author"); + var context = CreateContext("posts,comments,tags"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + var errorResponse = Assert.IsType(objectResult.Value); + + var errorWithMeta = errorResponse.Errors[0] as JsonApiErrorWithMeta; + Assert.NotNull(errorWithMeta); + var meta = errorWithMeta.Meta; + Assert.NotNull(meta); + + var forbiddenIncludes = meta["forbiddenIncludes"] as List; + Assert.NotNull(forbiddenIncludes); + Assert.Equal(3, forbiddenIncludes.Count); + Assert.Contains("posts", forbiddenIncludes); + Assert.Contains("comments", forbiddenIncludes); + Assert.Contains("tags", forbiddenIncludes); + } + + [Fact] + public void OnActionExecuting_ErrorMetadata_ContainsCorrectInfo() + { + var attribute = new AllowedIncludesAttribute("author", "posts"); + var context = CreateContext("author,forbidden"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + var errorResponse = Assert.IsType(objectResult.Value); + + var errorWithMeta = errorResponse.Errors[0] as JsonApiErrorWithMeta; + Assert.NotNull(errorWithMeta); + var meta = errorWithMeta.Meta; + Assert.NotNull(meta); + + var requestedIncludes = meta["requestedIncludes"] as List; + Assert.NotNull(requestedIncludes); + Assert.Equal(2, requestedIncludes.Count); + Assert.Contains("author", requestedIncludes); + Assert.Contains("forbidden", requestedIncludes); + + var forbiddenIncludes = meta["forbiddenIncludes"] as List; + Assert.NotNull(forbiddenIncludes); + Assert.Single(forbiddenIncludes); + Assert.Contains("forbidden", forbiddenIncludes); + + var allowedIncludes = meta["allowedIncludes"] as string[]; + Assert.NotNull(allowedIncludes); + Assert.Equal(2, allowedIncludes.Length); + Assert.Contains("author", allowedIncludes); + Assert.Contains("posts", allowedIncludes); + } +} diff --git a/JsonApiToolkit.Tests/Attributes/AllowedIncludesAttributeWildcardTests.cs b/JsonApiToolkit.Tests/Attributes/AllowedIncludesAttributeWildcardTests.cs new file mode 100644 index 0000000..d8a0136 --- /dev/null +++ b/JsonApiToolkit.Tests/Attributes/AllowedIncludesAttributeWildcardTests.cs @@ -0,0 +1,203 @@ +using JsonApiToolkit.Attributes; +using JsonApiToolkit.Models.Errors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace JsonApiToolkit.Tests.Attributes; + +public class AllowedIncludesAttributeWildcardTests +{ + private ActionExecutingContext CreateContext(string? includeQueryParam = null) + { + var httpContext = new DefaultHttpContext(); + var services = new ServiceCollection(); + services.AddSingleton>( + Mock.Of>() + ); + httpContext.RequestServices = services.BuildServiceProvider(); + + if (includeQueryParam != null) + { + httpContext.Request.QueryString = new QueryString($"?include={includeQueryParam}"); + var queryCollection = new Dictionary + { + { "include", includeQueryParam }, + }; + httpContext.Request.Query = new QueryCollection(queryCollection); + } + + var actionContext = new ActionContext( + httpContext, + new RouteData(), + new ActionDescriptor { DisplayName = "TestAction" } + ); + + var context = new ActionExecutingContext( + actionContext, + new List(), + new Dictionary(), + controller: new object() + ); + + return context; + } + + [Fact] + public void OnActionExecuting_TopLevelWildcard_AllowsTopLevel() + { + var attribute = new AllowedIncludesAttribute("*"); + var context = CreateContext("author"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_TopLevelWildcard_ForbidsNested() + { + var attribute = new AllowedIncludesAttribute("*"); + var context = CreateContext("author.posts"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + Assert.Equal(403, objectResult.StatusCode); + } + + [Fact] + public void OnActionExecuting_SingleLevelWildcard_AllowsPrefix() + { + var attribute = new AllowedIncludesAttribute("author.*"); + var context = CreateContext("author"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_SingleLevelWildcard_AllowsSingleLevel() + { + var attribute = new AllowedIncludesAttribute("author.*"); + var context = CreateContext("author.posts"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_SingleLevelWildcard_ForbidsDeeperNesting() + { + var attribute = new AllowedIncludesAttribute("author.*"); + var context = CreateContext("author.posts.comments"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + Assert.Equal(403, objectResult.StatusCode); + } + + [Fact] + public void OnActionExecuting_MixedPatterns_AllowsMatching() + { + var attribute = new AllowedIncludesAttribute("author.*", "posts", "comments.replies"); + var context = CreateContext("author.name,posts,comments"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_MixedPatterns_ForbidsNonMatching() + { + var attribute = new AllowedIncludesAttribute("author.*", "posts"); + var context = CreateContext("author.posts,tags"); + + attribute.OnActionExecuting(context); + + Assert.NotNull(context.Result); + var objectResult = Assert.IsType(context.Result); + Assert.Equal(403, objectResult.StatusCode); + + var errorResponse = Assert.IsType(objectResult.Value); + var errorWithMeta = errorResponse.Errors[0] as JsonApiErrorWithMeta; + Assert.NotNull(errorWithMeta); + + var forbiddenIncludes = errorWithMeta.Meta?["forbiddenIncludes"] as List; + Assert.NotNull(forbiddenIncludes); + Assert.Single(forbiddenIncludes); + Assert.Contains("tags", forbiddenIncludes); + } + + [Fact] + public void OnActionExecuting_WildcardCaseInsensitive() + { + var attribute = new AllowedIncludesAttribute("Author.*"); + var context = CreateContext("author.POSTS"); + + attribute.OnActionExecuting(context); + + Assert.Null(context.Result); + } + + [Fact] + public void OnActionExecuting_RealWorldExample_CVE() + { + var attribute = new AllowedIncludesAttribute("epss", "vulncheckkevs", "cve.*"); + + // Allowed includes + var context1 = CreateContext("epss,vulncheckkevs"); + attribute.OnActionExecuting(context1); + Assert.Null(context1.Result); + + // Allowed with wildcard + var context2 = CreateContext("cve.description"); + attribute.OnActionExecuting(context2); + Assert.Null(context2.Result); + + // Forbidden + var context3 = CreateContext("vulnerabilities"); + attribute.OnActionExecuting(context3); + Assert.NotNull(context3.Result); + var objectResult = Assert.IsType(context3.Result); + Assert.Equal(403, objectResult.StatusCode); + } + + [Fact] + public void OnActionExecuting_MultipleWildcards() + { + var attribute = new AllowedIncludesAttribute("author.*", "posts.*", "*"); + + // Top level + var context1 = CreateContext("tags"); + attribute.OnActionExecuting(context1); + Assert.Null(context1.Result); + + // Author wildcard + var context2 = CreateContext("author.profile"); + attribute.OnActionExecuting(context2); + Assert.Null(context2.Result); + + // Posts wildcard + var context3 = CreateContext("posts.comments"); + attribute.OnActionExecuting(context3); + Assert.Null(context3.Result); + + // Forbidden nested under top-level + var context4 = CreateContext("tags.items"); + attribute.OnActionExecuting(context4); + Assert.NotNull(context4.Result); + } +} diff --git a/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs b/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs new file mode 100644 index 0000000..55a9c07 --- /dev/null +++ b/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Text.Json; +using JsonApiToolkit.Attributes; +using JsonApiToolkit.Controllers; +using JsonApiToolkit.Extensions; +using JsonApiToolkit.Models.Errors; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace JsonApiToolkit.Tests.Integration; + +public class AllowedIncludesIntegrationTests : IDisposable +{ + private readonly IHost _host; + private readonly HttpClient _client; + + public AllowedIncludesIntegrationTests() + { + _host = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddDbContext(options => + options.UseInMemoryDatabase("TestDb") + ); + services.AddControllers(); + services.AddJsonApiToolkit(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + }); + }) + .Build(); + + _host.Start(); + _client = _host.GetTestClient(); + } + + [Fact] + public async Task GetWithAllowedInclude_ReturnsOkAsync() + { + var response = await _client.GetAsync("/api/test/with-allowed?include=author"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetWithForbiddenInclude_ReturnsForbiddenAsync() + { + var response = await _client.GetAsync("/api/test/with-allowed?include=forbidden"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonSerializer.Deserialize( + content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + Assert.NotNull(errorResponse); + Assert.NotEmpty(errorResponse.Errors); + Assert.Equal("403", errorResponse.Errors[0].Status); + } + + [Fact] + public async Task GetWithWildcard_AllowsMatchingPatternAsync() + { + var response = await _client.GetAsync("/api/test/with-wildcard?include=author.posts"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetWithWildcard_ForbidsDeeperNestingAsync() + { + var response = await _client.GetAsync( + "/api/test/with-wildcard?include=author.posts.comments" + ); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetWithoutAttribute_AllowsAnyIncludeAsync() + { + var response = await _client.GetAsync( + "/api/test/without-attribute?include=anything.deeply.nested" + ); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetWithEmptyAttribute_ForbidsAllIncludesAsync() + { + var response = await _client.GetAsync("/api/test/with-empty?include=author"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + public void Dispose() + { + _client?.Dispose(); + _host?.Dispose(); + } +} + +// Test controller for integration tests +[ApiController] +[Route("api/test")] +public class TestIntegrationController : JsonApiController +{ + [HttpGet("with-allowed")] + [AllowedIncludes("author", "posts")] + public IActionResult GetWithAllowed() + { + return Ok(new { data = new { type = "test", id = "1" } }); + } + + [HttpGet("with-wildcard")] + [AllowedIncludes("author.*", "posts")] + public IActionResult GetWithWildcard() + { + return Ok(new { data = new { type = "test", id = "1" } }); + } + + [HttpGet("without-attribute")] + public IActionResult GetWithoutAttribute() + { + return Ok(new { data = new { type = "test", id = "1" } }); + } + + [HttpGet("with-empty")] + [AllowedIncludes()] + public IActionResult GetWithEmpty() + { + return Ok(new { data = new { type = "test", id = "1" } }); + } +} + +// Test DbContext for integration tests +public class TestDbContext : DbContext +{ + public TestDbContext(DbContextOptions options) + : base(options) { } + + public DbSet TestEntities { get; set; } +} + +// Test entity +public class TestEntity +{ + public int Id { get; set; } + public string Name { get; set; } = ""; +} diff --git a/JsonApiToolkit.Tests/JsonApiToolkit.Tests.csproj b/JsonApiToolkit.Tests/JsonApiToolkit.Tests.csproj index e82f6e3..145e308 100644 --- a/JsonApiToolkit.Tests/JsonApiToolkit.Tests.csproj +++ b/JsonApiToolkit.Tests/JsonApiToolkit.Tests.csproj @@ -9,6 +9,8 @@ + + @@ -19,8 +21,8 @@ - - + + diff --git a/JsonApiToolkit.Tests/Validation/IncludePatternTests.cs b/JsonApiToolkit.Tests/Validation/IncludePatternTests.cs new file mode 100644 index 0000000..0894ee7 --- /dev/null +++ b/JsonApiToolkit.Tests/Validation/IncludePatternTests.cs @@ -0,0 +1,94 @@ +using JsonApiToolkit.Models.Validation; + +namespace JsonApiToolkit.Tests.Validation; + +public class IncludePatternTests +{ + [Theory] + [InlineData("author", "author", true)] + [InlineData("author", "Author", true)] + [InlineData("author", "AUTHOR", true)] + [InlineData("author", "posts", false)] + public void Matches_ExactPattern_CaseInsensitive(string pattern, string include, bool expected) + { + var includePattern = new IncludePattern(pattern); + Assert.Equal(expected, includePattern.Matches(include)); + } + + [Theory] + [InlineData("author.posts", "author", true)] + [InlineData("author.posts", "author.posts", true)] + [InlineData("author.posts", "author.posts.comments", false)] + [InlineData("author.posts.comments", "author", true)] + [InlineData("author.posts.comments", "author.posts", true)] + public void Matches_PartialPath(string pattern, string include, bool expected) + { + var includePattern = new IncludePattern(pattern); + Assert.Equal(expected, includePattern.Matches(include)); + } + + [Theory] + [InlineData("*", "author", true)] + [InlineData("*", "posts", true)] + [InlineData("*", "author.posts", false)] + [InlineData("*", "author.posts.comments", false)] + public void Matches_TopLevelWildcard(string pattern, string include, bool expected) + { + var includePattern = new IncludePattern(pattern); + Assert.Equal(expected, includePattern.Matches(include)); + Assert.Equal(PatternType.TopLevelWildcard, includePattern.Type); + } + + [Theory] + [InlineData("author.*", "author", true)] + [InlineData("author.*", "author.posts", true)] + [InlineData("author.*", "author.comments", true)] + [InlineData("author.*", "author.posts.comments", false)] + [InlineData("author.*", "posts", false)] + [InlineData("author.*", "posts.author", false)] + public void Matches_SingleLevelWildcard(string pattern, string include, bool expected) + { + var includePattern = new IncludePattern(pattern); + Assert.Equal(expected, includePattern.Matches(include)); + Assert.Equal(PatternType.SingleLevelWildcard, includePattern.Type); + } + + [Theory] + [InlineData("author.*", "Author", true)] + [InlineData("author.*", "AUTHOR.POSTS", true)] + [InlineData("Author.*", "author.posts", true)] + [InlineData("AUTHOR.*", "author.POSTS", true)] + public void Matches_WildcardCaseInsensitive(string pattern, string include, bool expected) + { + var includePattern = new IncludePattern(pattern); + Assert.Equal(expected, includePattern.Matches(include)); + } + + [Fact] + public void Constructor_NullPattern_ThrowsException() + { + Assert.Throws(() => new IncludePattern(null!)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Matches_EmptyInclude_ReturnsFalse(string? include) + { + var pattern = new IncludePattern("author"); + Assert.False(pattern.Matches(include!)); + } + + [Theory] + [InlineData("posts.*", "posts", true)] + [InlineData("posts.*", "posts.author", true)] + [InlineData("posts.*", "posts.author.name", false)] + [InlineData("cve.*", "cve", true)] + [InlineData("cve.*", "cve.epss", true)] + [InlineData("cve.*", "CVE.EPSS", true)] + public void Matches_RealWorldExamples(string pattern, string include, bool expected) + { + var includePattern = new IncludePattern(pattern); + Assert.Equal(expected, includePattern.Matches(include)); + } +} diff --git a/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs b/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs new file mode 100644 index 0000000..8705983 --- /dev/null +++ b/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs @@ -0,0 +1,145 @@ +using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Models.Validation; +using JsonApiToolkit.Parsing; +using JsonApiToolkit.Validation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace JsonApiToolkit.Attributes; + +/// +/// Action filter attribute that restricts which relationships can be included in JSON:API responses. +/// +/// +/// This attribute validates the 'include' query parameter against a whitelist of allowed includes. +/// If a client requests an include that is not in the whitelist, a 403 Forbidden error is returned. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class AllowedIncludesAttribute : ActionFilterAttribute +{ + private readonly string[] _allowedIncludes; + private readonly Dictionary _compiledPatterns; + + /// + /// Gets the list of allowed include patterns. + /// + public string[] AllowedIncludes => _allowedIncludes; + + /// + /// Initializes a new instance of the class. + /// + /// The list of allowed include patterns. If empty, no includes are allowed. + public AllowedIncludesAttribute(params string[] allowedIncludes) + { + _allowedIncludes = allowedIncludes ?? []; + _compiledPatterns = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + + foreach (var pattern in _allowedIncludes) + { + _compiledPatterns[pattern] = new IncludePattern(pattern); + } + } + + /// + /// Validates the include query parameters before the action executes. + /// + /// The action executing context. + public override void OnActionExecuting(ActionExecutingContext context) + { + // Skip validation for JsonApiCreated methods + var actionName = context.ActionDescriptor.DisplayName; + if ( + actionName != null + && actionName.Contains("JsonApiCreated", StringComparison.OrdinalIgnoreCase) + ) + { + base.OnActionExecuting(context); + return; + } + + var request = context.HttpContext.Request; + var queryParams = JsonApiQueryParser.Parse(request); + + if (queryParams.Include == null || queryParams.Include.Count == 0) + { + base.OnActionExecuting(context); + return; + } + + // Empty array means no includes allowed + if (_allowedIncludes.Length == 0) + { + ThrowForbiddenException(queryParams.Include, [], _allowedIncludes, context); + return; + } + + var validationResult = IncludeValidator.ValidateIncludes( + queryParams.Include, + _allowedIncludes + ); + + if (!validationResult.IsValid) + { + ThrowForbiddenException( + queryParams.Include, + validationResult.ForbiddenIncludes, + _allowedIncludes, + context + ); + } + + base.OnActionExecuting(context); + } + + private void ThrowForbiddenException( + List requestedIncludes, + List forbiddenIncludes, + string[] allowedIncludes, + ActionExecutingContext context + ) + { + var logger = + context.HttpContext.RequestServices.GetService( + typeof(ILogger) + ) as ILogger; + + if (logger != null && forbiddenIncludes.Count > 0) + { + logger.LogWarning( + "Forbidden includes requested: {ForbiddenIncludes}. Allowed includes: {AllowedIncludes}", + string.Join(", ", forbiddenIncludes), + string.Join(", ", allowedIncludes) + ); + } + + var errorDetail = + forbiddenIncludes.Count == 1 + ? $"The requested include '{forbiddenIncludes[0]}' is not allowed for this endpoint" + : $"The requested includes '{string.Join(", ", forbiddenIncludes)}' are not allowed for this endpoint"; + + var error = new JsonApiErrorWithMeta + { + Status = "403", + Title = "Forbidden Include", + Detail = errorDetail, + Meta = new Dictionary + { + ["requestedIncludes"] = requestedIncludes, + ["forbiddenIncludes"] = forbiddenIncludes, + ["allowedIncludes"] = + allowedIncludes.Length > 0 ? allowedIncludes : Array.Empty(), + }, + }; + + var errorResponse = new JsonApiErrorResponse { Errors = [error] }; + + context.Result = new ObjectResult(errorResponse) + { + StatusCode = 403, + ContentTypes = { "application/vnd.api+json" }, + }; + } +} diff --git a/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs b/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs index 74ea66e..9da380a 100644 --- a/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs +++ b/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs @@ -1,9 +1,12 @@ using System.Text.Json; using System.Text.Json.Serialization; using JsonApiToolkit.Filters; +using JsonApiToolkit.Validation; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace JsonApiToolkit.Extensions { @@ -63,6 +66,11 @@ public static IServiceCollection AddJsonApiToolkit(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + // Register include pattern validator for startup validation + services.TryAddEnumerable( + ServiceDescriptor.Transient() + ); + return services; } } diff --git a/JsonApiToolkit/Models/Errors/JsonApiErrorWithMeta.cs b/JsonApiToolkit/Models/Errors/JsonApiErrorWithMeta.cs new file mode 100644 index 0000000..a8f1caf --- /dev/null +++ b/JsonApiToolkit/Models/Errors/JsonApiErrorWithMeta.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace JsonApiToolkit.Models.Errors; + +/// +/// Extends JsonApiError to include metadata support. +/// +public class JsonApiErrorWithMeta : JsonApiError +{ + /// + /// A meta object containing non-standard meta-information about the error. + /// + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } +} diff --git a/JsonApiToolkit/Models/Validation/IncludePattern.cs b/JsonApiToolkit/Models/Validation/IncludePattern.cs new file mode 100644 index 0000000..1c9abf7 --- /dev/null +++ b/JsonApiToolkit/Models/Validation/IncludePattern.cs @@ -0,0 +1,178 @@ +using System.Text.RegularExpressions; + +namespace JsonApiToolkit.Models.Validation; + +/// +/// Represents a compiled include pattern for efficient matching. +/// +public class IncludePattern +{ + /// + /// Gets the original pattern string. + /// + public string OriginalPattern { get; } + + /// + /// Gets whether this pattern contains wildcards. + /// + public bool IsWildcard { get; } + + /// + /// Gets the type of pattern. + /// + public PatternType Type { get; } + + /// + /// Gets the compiled regex for wildcard patterns. + /// + public Regex? CompiledRegex { get; } + + /// + /// Gets the pattern parts for non-wildcard patterns. + /// + public string[]? PatternParts { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The include pattern string. + public IncludePattern(string pattern) + { + OriginalPattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); + + if (pattern.Contains('*')) + { + IsWildcard = true; + Type = DetermineWildcardType(pattern); + CompiledRegex = CompileWildcardPattern(pattern); + } + else + { + IsWildcard = false; + Type = PatternType.Exact; + PatternParts = pattern.Split('.'); + } + } + + private PatternType DetermineWildcardType(string pattern) + { + if (pattern == "*") + return PatternType.TopLevelWildcard; + + if (pattern.EndsWith(".*")) + return PatternType.SingleLevelWildcard; + + return PatternType.ComplexWildcard; + } + + private Regex CompileWildcardPattern(string pattern) + { + // Escape special regex characters except * + var escapedPattern = Regex.Escape(pattern).Replace("\\*", ".*"); + + if (pattern == "*") + { + // Top-level wildcard: match only single segments (no dots) + return new Regex("^[^.]+$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + + if (pattern.EndsWith(".*")) + { + // Single-level wildcard: "author.*" matches "author.posts" but not "author.posts.comments" + var prefix = pattern[..^2]; // Remove ".*" + var escapedPrefix = Regex.Escape(prefix); + // Match prefix.something but not prefix.something.else + return new Regex( + $"^{escapedPrefix}\\.[^.]+$", + RegexOptions.IgnoreCase | RegexOptions.Compiled + ); + } + + // Complex wildcard (future use) + return new Regex($"^{escapedPattern}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + + /// + /// Checks if the given include matches this pattern. + /// + /// The include to check. + /// True if the include matches, false otherwise. + public bool Matches(string include) + { + if (string.IsNullOrEmpty(include)) + return false; + + if (IsWildcard && CompiledRegex != null) + { + // For wildcard patterns, also check if it's a partial path + if (Type == PatternType.SingleLevelWildcard) + { + var prefix = OriginalPattern[..^2]; // Remove ".*" + + // Exact match with prefix (e.g., "author" matches "author.*") + if (string.Equals(include, prefix, StringComparison.OrdinalIgnoreCase)) + return true; + + // Full wildcard match (e.g., "author.posts" matches "author.*") + return CompiledRegex.IsMatch(include); + } + + return CompiledRegex.IsMatch(include); + } + + // Non-wildcard exact match (case-insensitive) + if (string.Equals(include, OriginalPattern, StringComparison.OrdinalIgnoreCase)) + return true; + + // Partial path matching for non-wildcard patterns + if (PatternParts != null && PatternParts.Length > 0) + { + var includeParts = include.Split('.'); + + // Check if include is a prefix of the pattern + if (includeParts.Length < PatternParts.Length) + { + for (int i = 0; i < includeParts.Length; i++) + { + if ( + !string.Equals( + includeParts[i], + PatternParts[i], + StringComparison.OrdinalIgnoreCase + ) + ) + return false; + } + return true; + } + } + + return false; + } +} + +/// +/// Specifies the type of include pattern. +/// +public enum PatternType +{ + /// + /// Exact string match pattern. + /// + Exact, + + /// + /// Top-level wildcard (*) that matches any single segment. + /// + TopLevelWildcard, + + /// + /// Single-level wildcard (e.g., author.*) that matches one level deep. + /// + SingleLevelWildcard, + + /// + /// Complex wildcard pattern (reserved for future use). + /// + ComplexWildcard, +} diff --git a/JsonApiToolkit/Validation/IncludePatternValidator.cs b/JsonApiToolkit/Validation/IncludePatternValidator.cs new file mode 100644 index 0000000..cb54959 --- /dev/null +++ b/JsonApiToolkit/Validation/IncludePatternValidator.cs @@ -0,0 +1,132 @@ +using System.Reflection; +using JsonApiToolkit.Attributes; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.Extensions.Logging; + +namespace JsonApiToolkit.Validation; + +/// +/// Provides startup validation for include patterns in AllowedIncludesAttribute. +/// +public class IncludePatternValidator : IApplicationModelProvider +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public IncludePatternValidator(ILogger logger) + { + _logger = logger; + } + + /// + /// Gets the order in which providers are executed. + /// + public int Order => -1000; + + /// + /// Validates include patterns during application startup. + /// + /// The application model provider context. + public void OnProvidersExecuting(ApplicationModelProviderContext context) + { + foreach (var controller in context.Result.Controllers) + { + foreach (var action in controller.Actions) + { + var allowedIncludesAttribute = action + .Attributes.OfType() + .FirstOrDefault(); + + if (allowedIncludesAttribute != null) + { + ValidatePatterns( + allowedIncludesAttribute.AllowedIncludes, + controller.ControllerName, + action.ActionName + ); + } + } + } + } + + /// + /// Called after providers have executed. + /// + /// The application model provider context. + public void OnProvidersExecuted(ApplicationModelProviderContext context) + { + // No action needed + } + + private void ValidatePatterns(string[] patterns, string controllerName, string actionName) + { + var warnings = new List(); + + foreach (var pattern in patterns) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + warnings.Add($"Empty or whitespace pattern found"); + continue; + } + + // Check for potentially problematic patterns + if (pattern.Contains("**")) + { + warnings.Add( + $"Pattern '{pattern}' contains '**' which is not supported. Use single '*' for wildcards." + ); + } + + if (pattern.StartsWith(".") || pattern.EndsWith(".")) + { + warnings.Add( + $"Pattern '{pattern}' starts or ends with '.', which may not work as expected." + ); + } + + if (pattern.Count(c => c == '*') > 1 && !pattern.Contains(".*")) + { + warnings.Add( + $"Pattern '{pattern}' contains multiple wildcards. Only '.*' wildcard pattern is fully supported." + ); + } + + // Check for regex special characters that might cause issues + var specialChars = new[] + { + '[', + ']', + '(', + ')', + '{', + '}', + '\\', + '^', + '$', + '|', + '?', + '+', + }; + if (specialChars.Any(c => pattern.Contains(c))) + { + warnings.Add( + $"Pattern '{pattern}' contains special regex characters that may not work as expected." + ); + } + } + + if (warnings.Count > 0) + { + _logger.LogWarning( + "AllowedIncludesAttribute validation warnings for {Controller}.{Action}: {Warnings}", + controllerName, + actionName, + string.Join("; ", warnings) + ); + } + } +} diff --git a/JsonApiToolkit/Validation/IncludeValidator.cs b/JsonApiToolkit/Validation/IncludeValidator.cs new file mode 100644 index 0000000..73db3ef --- /dev/null +++ b/JsonApiToolkit/Validation/IncludeValidator.cs @@ -0,0 +1,75 @@ +using JsonApiToolkit.Models.Validation; + +namespace JsonApiToolkit.Validation; + +/// +/// Provides validation logic for JSON:API include parameters. +/// +public static class IncludeValidator +{ + /// + /// Validates requested includes against allowed patterns. + /// + /// The includes requested by the client. + /// The allowed include patterns. + /// A validation result containing any forbidden includes. + public static ValidationResult ValidateIncludes( + IEnumerable requestedIncludes, + IEnumerable allowedPatterns + ) + { + var patterns = allowedPatterns.Select(p => new IncludePattern(p)).ToList(); + var forbidden = new List(); + + foreach (var requested in requestedIncludes) + { + if (!IsIncludeAllowed(requested, patterns)) + { + forbidden.Add(requested); + } + } + + return new ValidationResult + { + IsValid = forbidden.Count == 0, + ForbiddenIncludes = forbidden, + }; + } + + /// + /// Checks if a specific include is allowed by any of the patterns. + /// + /// The include to check. + /// The allowed patterns. + /// True if the include is allowed, false otherwise. + public static bool IsIncludeAllowed(string include, IEnumerable patterns) + { + return patterns.Any(pattern => pattern.Matches(include)); + } + + /// + /// Compiles pattern strings into IncludePattern objects for efficient matching. + /// + /// The pattern strings to compile. + /// A collection of compiled patterns. + public static IEnumerable CompilePatterns(IEnumerable patternStrings) + { + return patternStrings.Select(p => new IncludePattern(p)); + } +} + +/// +/// Represents the result of include validation. +/// +public class ValidationResult +{ + /// + /// Gets or sets whether all requested includes are valid. + /// + public bool IsValid { get; set; } + + /// + /// Gets or sets the list of forbidden includes. + /// + public List ForbiddenIncludes { get; set; } = new(); +} diff --git a/docs/docs/api-controller-examples.md b/docs/docs/api-controller-examples.md index 3722d44..8be42c4 100644 --- a/docs/docs/api-controller-examples.md +++ b/docs/docs/api-controller-examples.md @@ -207,10 +207,43 @@ public class Book } ``` +## Security with AllowedIncludes + +Control which relationships can be included to prevent exposure of sensitive data: + +```csharp +[HttpGet("users")] +[AllowedIncludes("profile", "posts.*", "settings")] +public async Task GetUsers() +{ + return await JsonApiOkAsync(_context.Users, "user"); +} + +[HttpGet("sensitive-data")] +[AllowedIncludes("publicInfo")] +public async Task GetSensitiveData() +{ + return await JsonApiOkAsync(_context.SensitiveEntities, "sensitiveEntity"); +} + +[HttpGet("public-only")] +[AllowedIncludes()] // No includes allowed +public async Task GetPublicOnly() +{ + return await JsonApiOkAsync(_context.PublicData, "publicData"); +} +``` + +**Supported requests:** +- `GET /api/users?include=profile` ✅ Allowed +- `GET /api/users?include=posts.comments` ✅ Allowed (wildcard) +- `GET /api/users?include=posts.comments.author` ❌ Forbidden (too deep) +- `GET /api/sensitive-data?include=secrets` ❌ Forbidden + ## Pro Tips 1. **Always use exception types** instead of returning error ActionResults - the filter handles conversion automatically 2. **Use JsonApiOkAsync for collections** - it provides full query parameter support 3. **Use JsonApiOk for single resources** - simpler and faster for individual entities -4. **Include relationships judiciously** - only expose what clients actually need +4. **Use AllowedIncludes** - restrict relationship access for security and performance diff --git a/docs/docs/security.md b/docs/docs/security.md new file mode 100644 index 0000000..b0c8b28 --- /dev/null +++ b/docs/docs/security.md @@ -0,0 +1,80 @@ +# Security + +JsonApiToolkit provides security features to control which relationships can be included in JSON:API responses. + +## AllowedIncludes Attribute + +The `[AllowedIncludes]` attribute restricts which relationships clients can request via the `include` query parameter. This prevents exposure of sensitive relationships and protects against potentially expensive queries. + +### Basic Usage + +Apply the attribute to controller actions: + +```csharp +[HttpGet("users")] +[AllowedIncludes("profile", "posts")] +public async Task GetUsers() +{ + return await JsonApiOkAsync(_context.Users, "user"); +} +``` + +### Wildcard Patterns + +Use wildcards to allow nested includes at specific levels: + +```csharp +[HttpGet("posts")] +[AllowedIncludes("author.*", "comments")] +public async Task GetPosts() +{ + return await JsonApiOkAsync(_context.Posts, "post"); +} +``` + +**Wildcard Rules:** +- `author.*` allows `author` and `author.profile` but not `author.profile.settings` +- `*` allows all top-level includes but no nested ones + +### Configuration Options + +**Empty array** - No includes allowed: +```csharp +[AllowedIncludes()] +``` + +**No attribute** - All includes allowed (default behavior) + +### Error Responses + +When forbidden includes are requested, a 403 Forbidden response is returned: + +```json +{ + "errors": [{ + "status": "403", + "title": "Forbidden Include", + "detail": "The requested include 'sensitive' is not allowed for this endpoint", + "meta": { + "requestedIncludes": ["profile", "sensitive"], + "forbiddenIncludes": ["sensitive"], + "allowedIncludes": ["profile", "posts"] + } + }] +} +``` + +### Case Sensitivity + +All matching is case-insensitive: +- `Author` matches `author` +- `author.*` matches `Author.Posts` + +### Pattern Validation + +Invalid patterns are logged as warnings during application startup: + +``` +AllowedIncludesAttribute validation warnings for UsersController.GetUsers: +Pattern 'user.**' contains '**' which is not supported. Use single '*' for wildcards. +``` \ No newline at end of file diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index 538a740..c9afe62 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -6,6 +6,8 @@ href: querying.md - name: Enhanced Error Handling href: enhanced-error-handling.md +- name: Security + href: security.md - name: API Controller Examples href: api-controller-examples.md - name: Integrations