Skip to content

Commit 9fb463d

Browse files
fix(parsing): guard unsafe string parsing in filter parsers (#58)
- Add validation before Substring operations to prevent index exceptions - Replace int.Parse with TryParse for group indices in logical group parsing - Add ILogger? parameter to parser methods for warning on malformed input - Malformed filter keys are logged and skipped instead of throwing - Add 12 regression tests for malformed input handling
1 parent 75eb978 commit 9fb463d

6 files changed

Lines changed: 338 additions & 43 deletions

File tree

CLAUDE.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ dotnet pack JsonApiToolkit/JsonApiToolkit.csproj -p:PackageVersion=VERSION -c Re
3939
```bash
4040
# Format code with CSharpier
4141
dotnet tool restore
42-
dotnet csharpier .
42+
dotnet csharpier format .
4343

44-
# Check formatting
45-
dotnet csharpier . --check
44+
# Check formatting (CI uses this)
45+
dotnet csharpier check .
4646
```
4747

4848
### Documentation
@@ -98,6 +98,7 @@ Enable detailed logging for query processing and troubleshooting:
9898

9999
8. **Helpers** (`Helpers/`)
100100
- `EfIncludePathHelper`: Utilities for building EF Core Include expressions
101+
- `ReflectionMethodCache`: Cached reflection method lookups with defensive checks and clear error messages
101102

102103
### Key Patterns
103104

@@ -109,6 +110,7 @@ Enable detailed logging for query processing and troubleshooting:
109110
- **JSON column detection**: Collections and complex objects without ID properties are automatically mapped as JSON attributes instead of relationships (useful for EF Core owned entities stored as JSON columns)
110111
- **Pagination safety**: Invalid page numbers are automatically clamped to valid ranges (page 1 for negative/zero, last page for overflow)
111112
- **Include whitelisting**: Use `AllowedIncludesAttribute` on controller actions to restrict which relationships can be included, preventing unauthorized data exposure
113+
- **Graceful error handling**: Malformed query parameters are logged and skipped rather than throwing exceptions
112114

113115
### Service Registration
114116

@@ -140,10 +142,14 @@ JsonApiToolkit provides a comprehensive error handling system with standardized
140142

141143
Tests are organized by component:
142144
- `Controllers/`: Controller behavior tests
143-
- `Extensions/`: Query extension tests
145+
- `Extensions/`: Query extension tests (filtering, sorting, pagination, includes)
144146
- `Mapping/`: Entity mapping tests
145147
- `Models/`: Model validation tests
146-
- `Parsing/`: Query parser tests
148+
- `Parsing/`: Query parser tests (including malformed input handling)
149+
- `Helpers/`: Helper class tests (ReflectionMethodCache, etc.)
150+
- `Integration/`: Full pipeline integration tests
151+
- `Filters/`: Exception filter tests
152+
- `Attributes/`: Attribute behavior tests (AllowedIncludes, etc.)
147153

148154
## Development Guidelines
149155

@@ -166,4 +172,13 @@ Tests are organized by component:
166172

167173
## Package Publication
168174

169-
The project publishes to GitHub Packages. Use semantic versioning for releases. The CI/CD pipeline automatically builds, tests, and publishes on GitHub releases.
175+
The project publishes to GitHub Packages. Use semantic versioning for releases. The CI/CD pipeline automatically builds, tests, and publishes on GitHub releases.
176+
177+
## Refactoring Roadmap
178+
179+
The project has a structured refactoring plan in `.claude/`:
180+
- `REFACTORING_ROADMAP.md` - Phase-by-phase plan with checklists
181+
- `CODEBASE_ANALYSIS.md` - Analysis of issues found in the codebase
182+
- `REPO_IMPROVEMENTS.md` - Repository setup tasks (CI/CD, branch protection)
183+
184+
Use `/roadmap` command to get current status and next tasks.

JsonApiToolkit.Tests/Parsing/JsonApiQueryParserTests.cs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,184 @@ public void Parse_WithOrChainMixedSyntax_CorrectlyIdentifiesFilterTypes()
373373
var includeFilter = orGroup.Filters.First(f => f.Field == "comments.status");
374374
Assert.True(includeFilter.IsIncludeFilter);
375375
}
376+
377+
[Fact]
378+
public void Parse_WithMalformedFilterKey_TooShort_IgnoresFilter()
379+
{
380+
// Arrange - filter key too short to be valid
381+
var httpContext = new DefaultHttpContext();
382+
httpContext.Request.Query = new QueryCollection(
383+
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
384+
{
385+
["filter["] = "value", // Too short, missing closing bracket
386+
["filter[name]"] = "valid", // Valid filter to ensure parsing continues
387+
}
388+
);
389+
390+
// Act
391+
QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request);
392+
393+
// Assert - should only have the valid filter
394+
Assert.NotNull(parameters.Filter);
395+
Assert.Single(parameters.Filter.Filters);
396+
Assert.Equal("name", parameters.Filter.Filters[0].Field);
397+
}
398+
399+
[Fact]
400+
public void Parse_WithMalformedFilterKey_MissingClosingBracket_IgnoresFilter()
401+
{
402+
// Arrange - filter key without closing bracket
403+
var httpContext = new DefaultHttpContext();
404+
httpContext.Request.Query = new QueryCollection(
405+
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
406+
{
407+
["filter[name"] = "value", // Missing closing bracket
408+
["filter[age]"] = "25", // Valid filter
409+
}
410+
);
411+
412+
// Act
413+
QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request);
414+
415+
// Assert - should only have the valid filter
416+
Assert.NotNull(parameters.Filter);
417+
Assert.Single(parameters.Filter.Filters);
418+
Assert.Equal("age", parameters.Filter.Filters[0].Field);
419+
}
420+
421+
[Fact]
422+
public void Parse_WithMalformedOrGroupIndex_NonNumeric_IgnoresFilter()
423+
{
424+
// Arrange - OR group with non-numeric index
425+
var httpContext = new DefaultHttpContext();
426+
httpContext.Request.Query = new QueryCollection(
427+
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
428+
{
429+
["filter[or][abc][name][eq]"] = "value", // Non-numeric index
430+
["filter[or][0][age][eq]"] = "25", // Valid OR filter
431+
}
432+
);
433+
434+
// Act
435+
QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request);
436+
437+
// Assert - should only have the valid OR filter
438+
Assert.NotNull(parameters.Filter);
439+
Assert.Single(parameters.Filter.Groups);
440+
Assert.Single(parameters.Filter.Groups[0].Filters);
441+
Assert.Equal("age", parameters.Filter.Groups[0].Filters[0].Field);
442+
}
443+
444+
[Fact]
445+
public void Parse_WithMalformedOrGroupKey_MissingParts_IgnoresFilter()
446+
{
447+
// Arrange - OR group key with missing parts
448+
var httpContext = new DefaultHttpContext();
449+
httpContext.Request.Query = new QueryCollection(
450+
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
451+
{
452+
["filter[or][0]"] = "value", // Too short, missing field parts
453+
["filter[or][1][name][eq]"] = "test", // Valid OR filter
454+
}
455+
);
456+
457+
// Act
458+
QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request);
459+
460+
// Assert - should only have the valid OR filter
461+
Assert.NotNull(parameters.Filter);
462+
Assert.Single(parameters.Filter.Groups);
463+
Assert.Single(parameters.Filter.Groups[0].Filters);
464+
Assert.Equal("name", parameters.Filter.Groups[0].Filters[0].Field);
465+
}
466+
467+
[Fact]
468+
public void Parse_WithAllMalformedFilters_ReturnsEmptyFilterGroup()
469+
{
470+
// Arrange - all filters are malformed
471+
var httpContext = new DefaultHttpContext();
472+
httpContext.Request.Query = new QueryCollection(
473+
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
474+
{
475+
["filter["] = "value",
476+
["filter[name"] = "test",
477+
["filter"] = "invalid",
478+
}
479+
);
480+
481+
// Act
482+
QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request);
483+
484+
// Assert - should have no filters
485+
Assert.Null(parameters.Filter);
486+
}
487+
}
488+
489+
public class JsonApiFilterParserMalformedInputTests
490+
{
491+
[Theory]
492+
[InlineData("filter[")] // Too short
493+
[InlineData("filter[x")] // Missing closing bracket
494+
[InlineData("filter")] // No brackets at all
495+
[InlineData("filte[x]")] // Wrong prefix
496+
[InlineData("")] // Empty string
497+
public void ParseComplexFilter_WithMalformedKey_IgnoresFilter(string key)
498+
{
499+
// Arrange
500+
var group = new FilterGroup();
501+
502+
// Act
503+
JsonApiFilterParser.ParseComplexFilter(key, "value", group);
504+
505+
// Assert - should not add any filter
506+
Assert.Empty(group.Filters);
507+
}
508+
509+
[Fact]
510+
public void ParseComplexFilter_WithValidKey_AddsFilter()
511+
{
512+
// Arrange
513+
var group = new FilterGroup();
514+
515+
// Act
516+
JsonApiFilterParser.ParseComplexFilter("filter[name][eq]", "value", group);
517+
518+
// Assert
519+
Assert.Single(group.Filters);
520+
Assert.Equal("name", group.Filters[0].Field);
521+
}
522+
523+
[Fact]
524+
public void ParseLogicalGroup_WithMalformedIndices_SkipsInvalidFilters()
525+
{
526+
// Arrange
527+
var httpContext = new DefaultHttpContext();
528+
httpContext.Request.Query = new QueryCollection(
529+
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
530+
{
531+
["filter[or][invalid][name][eq]"] = "bad", // Non-numeric index
532+
["filter[or][-1][name][eq]"] = "negative", // Negative index (valid int, but unusual)
533+
["filter[or][0][status][eq]"] = "active", // Valid
534+
}
535+
);
536+
537+
var parentGroup = new FilterGroup();
538+
539+
// Act
540+
JsonApiFilterParser.ParseLogicalGroup(
541+
httpContext.Request,
542+
"or",
543+
LogicalOperator.Or,
544+
parentGroup
545+
);
546+
547+
// Assert - should have filters from valid indices only
548+
Assert.Single(parentGroup.Groups);
549+
var orGroup = parentGroup.Groups[0];
550+
551+
// -1 is a valid integer, so it should be parsed (just unusual)
552+
// "invalid" should be skipped
553+
Assert.True(orGroup.Filters.Count >= 1); // At least the valid one
554+
Assert.Contains(orGroup.Filters, f => f.Field == "status");
555+
}
376556
}

JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ public override void OnActionExecuting(ActionExecutingContext context)
5757
}
5858

5959
var request = context.HttpContext.Request;
60-
var queryParams = JsonApiQueryParser.Parse(request);
60+
var logger =
61+
context.HttpContext.RequestServices.GetService(
62+
typeof(ILogger<AllowedIncludesAttribute>)
63+
) as ILogger<AllowedIncludesAttribute>;
64+
var queryParams = JsonApiQueryParser.Parse(request, logger);
6165

6266
if (queryParams.Include == null || queryParams.Include.Count == 0)
6367
{
@@ -72,7 +76,8 @@ public override void OnActionExecuting(ActionExecutingContext context)
7276
queryParams.Include,
7377
queryParams.Include,
7478
_allowedIncludes,
75-
context
79+
context,
80+
logger
7681
);
7782
return;
7883
}
@@ -88,25 +93,22 @@ public override void OnActionExecuting(ActionExecutingContext context)
8893
queryParams.Include,
8994
validationResult.ForbiddenIncludes,
9095
_allowedIncludes,
91-
context
96+
context,
97+
logger
9298
);
9399
}
94100

95101
base.OnActionExecuting(context);
96102
}
97103

98-
private void ThrowForbiddenException(
104+
private static void ThrowForbiddenException(
99105
List<string> requestedIncludes,
100106
List<string> forbiddenIncludes,
101107
string[] allowedIncludes,
102-
ActionExecutingContext context
108+
ActionExecutingContext context,
109+
ILogger? logger
103110
)
104111
{
105-
var logger =
106-
context.HttpContext.RequestServices.GetService(
107-
typeof(ILogger<AllowedIncludesAttribute>)
108-
) as ILogger<AllowedIncludesAttribute>;
109-
110112
if (logger != null && forbiddenIncludes.Count > 0)
111113
{
112114
logger.LogWarning(

0 commit comments

Comments
 (0)