Skip to content

Commit 405dbd2

Browse files
Bart Koelmanbkoelman
Bart Koelman
authored andcommitted
Added input validation for version in URL and request body
1 parent 36e1f16 commit 405dbd2

13 files changed

+1887
-4
lines changed

src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs

+35
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
6262

6363
SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request);
6464

65+
if (!await ValidateVersionAsync(request, httpContext, options.SerializerWriteOptions))
66+
{
67+
return;
68+
}
69+
6570
httpContext.RegisterJsonApiRequest();
6671
}
6772
else if (IsRouteForOperations(routeValues))
@@ -192,6 +197,36 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a
192197
return true;
193198
}
194199

200+
private static async Task<bool> ValidateVersionAsync(IJsonApiRequest request, HttpContext httpContext, JsonSerializerOptions serializerOptions)
201+
{
202+
if (!request.IsReadOnly)
203+
{
204+
if (request.PrimaryResourceType!.IsVersioned && request.WriteOperation != WriteOperationKind.CreateResource && request.PrimaryVersion == null)
205+
{
206+
await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest)
207+
{
208+
Title = "The 'version' parameter is required at this endpoint.",
209+
Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' require the version to be specified."
210+
});
211+
212+
return false;
213+
}
214+
215+
if (!request.PrimaryResourceType.IsVersioned && request.PrimaryVersion != null)
216+
{
217+
await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest)
218+
{
219+
Title = "The 'version' parameter is not supported at this endpoint.",
220+
Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' are not versioned."
221+
});
222+
223+
return false;
224+
}
225+
}
226+
227+
return true;
228+
}
229+
195230
private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error)
196231
{
197232
httpResponse.ContentType = HeaderConstants.MediaType;

src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs

+2
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen
141141
IdConstraint = refRequirements.IdConstraint,
142142
IdValue = refResult.Resource.StringId,
143143
LidValue = refResult.Resource.LocalId,
144+
VersionConstraint = !refResult.ResourceType.IsVersioned ? JsonElementConstraint.Forbidden : null,
145+
VersionValue = refResult.Resource.GetVersion(),
144146
RelationshipName = refResult.Relationship?.PublicName
145147
};
146148
}

src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterSt
6767
{
6868
ResourceType = state.Request.PrimaryResourceType,
6969
IdConstraint = idConstraint,
70-
IdValue = state.Request.PrimaryId
70+
IdValue = state.Request.PrimaryId,
71+
VersionConstraint = state.Request.PrimaryResourceType!.IsVersioned && state.Request.WriteOperation != WriteOperationKind.CreateResource
72+
? JsonElementConstraint.Required
73+
: JsonElementConstraint.Forbidden,
74+
VersionValue = state.Request.PrimaryVersion
7175
};
7276

7377
return requirements;

src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections;
2+
using JsonApiDotNetCore.Middleware;
23
using JsonApiDotNetCore.Resources;
34
using JsonApiDotNetCore.Resources.Annotations;
45
using JsonApiDotNetCore.Serialization.Objects;
@@ -73,6 +74,8 @@ private static SingleOrManyData<ResourceIdentifierObject> ToIdentifierData(Singl
7374
{
7475
ResourceType = relationship.RightType,
7576
IdConstraint = JsonElementConstraint.Required,
77+
VersionConstraint = !relationship.RightType.IsVersioned ? JsonElementConstraint.Forbidden :
78+
state.Request.Kind == EndpointKind.AtomicOperations ? null : JsonElementConstraint.Required,
7679
RelationshipName = relationship.PublicName
7780
};
7881

src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs

+42-3
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory
3333
ArgumentGuard.NotNull(state, nameof(state));
3434

3535
ResourceType resourceType = ResolveType(identity, requirements, state);
36-
IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state);
36+
IIdentifiable resource = CreateResource(identity, requirements, resourceType, state);
3737

3838
return (resource, resourceType);
3939
}
@@ -93,7 +93,8 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource
9393
}
9494
}
9595

96-
private IIdentifiable CreateResource(IResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state)
96+
private IIdentifiable CreateResource(IResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType,
97+
RequestAdapterState state)
9798
{
9899
if (state.Request.Kind != EndpointKind.AtomicOperations)
99100
{
@@ -111,10 +112,20 @@ private IIdentifiable CreateResource(IResourceIdentity identity, ResourceIdentit
111112
AssertHasNoId(identity, state);
112113
}
113114

115+
if (requirements.VersionConstraint == JsonElementConstraint.Required)
116+
{
117+
AssertHasVersion(identity, state);
118+
}
119+
else if (!resourceType.IsVersioned || requirements.VersionConstraint == JsonElementConstraint.Forbidden)
120+
{
121+
AssertHasNoVersion(identity, state);
122+
}
123+
114124
AssertSameIdValue(identity, requirements.IdValue, state);
115125
AssertSameLidValue(identity, requirements.LidValue, state);
126+
AssertSameVersionValue(identity, requirements.VersionValue, state);
116127

117-
IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType);
128+
IIdentifiable resource = _resourceFactory.CreateInstance(resourceType.ClrType);
118129
AssignStringId(identity, resource, state);
119130
resource.LocalId = identity.Lid;
120131
resource.SetVersion(identity.Version);
@@ -171,6 +182,23 @@ private static void AssertHasNoId(IResourceIdentity identity, RequestAdapterStat
171182
}
172183
}
173184

185+
private static void AssertHasVersion(IResourceIdentity identity, RequestAdapterState state)
186+
{
187+
if (identity.Version == null)
188+
{
189+
throw new ModelConversionException(state.Position, "The 'version' element is required.", null);
190+
}
191+
}
192+
193+
private static void AssertHasNoVersion(IResourceIdentity identity, RequestAdapterState state)
194+
{
195+
if (identity.Version != null)
196+
{
197+
using IDisposable _ = state.Position.PushElement("version");
198+
throw new ModelConversionException(state.Position, "Unexpected 'version' element.", null);
199+
}
200+
}
201+
174202
private static void AssertSameIdValue(IResourceIdentity identity, string? expected, RequestAdapterState state)
175203
{
176204
if (expected != null && identity.Id != expected)
@@ -193,6 +221,17 @@ private static void AssertSameLidValue(IResourceIdentity identity, string? expec
193221
}
194222
}
195223

224+
private static void AssertSameVersionValue(IResourceIdentity identity, string? expected, RequestAdapterState state)
225+
{
226+
if (expected != null && identity.Version != expected)
227+
{
228+
using IDisposable _ = state.Position.PushElement("version");
229+
230+
throw new ModelConversionException(state.Position, "Conflicting 'version' values found.", $"Expected '{expected}' instead of '{identity.Version}'.",
231+
HttpStatusCode.Conflict);
232+
}
233+
}
234+
196235
private void AssignStringId(IResourceIdentity identity, IIdentifiable resource, RequestAdapterState state)
197236
{
198237
if (identity.Id != null)

src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs

+10
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ public sealed class ResourceIdentityRequirements
3030
/// </summary>
3131
public string? LidValue { get; init; }
3232

33+
/// <summary>
34+
/// When not null, indicates the presence or absence of the "version" element.
35+
/// </summary>
36+
public JsonElementConstraint? VersionConstraint { get; init; }
37+
38+
/// <summary>
39+
/// When not null, indicates what the value of the "version" element must be.
40+
/// </summary>
41+
public string? VersionValue { get; init; }
42+
3343
/// <summary>
3444
/// When not null, indicates the name of the relationship to use in error messages.
3545
/// </summary>

test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyDbContext.cs

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public sealed class ConcurrencyDbContext : DbContext
1515
public DbSet<WebImage> WebImages => Set<WebImage>();
1616
public DbSet<PageFooter> PageFooters => Set<PageFooter>();
1717
public DbSet<WebLink> WebLinks => Set<WebLink>();
18+
public DbSet<DeploymentJob> DeploymentJobs => Set<DeploymentJob>();
1819

1920
public ConcurrencyDbContext(DbContextOptions<ConcurrencyDbContext> options)
2021
: base(options)

test/JsonApiDotNetCoreTests/IntegrationTests/OptimisticConcurrency/ConcurrencyFakers.cs

+6
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,17 @@ internal sealed class ConcurrencyFakers : FakerContainer
4747
.RuleFor(webLink => webLink.Url, faker => faker.Internet.Url())
4848
.RuleFor(webLink => webLink.OpensInNewTab, faker => faker.Random.Bool()));
4949

50+
private readonly Lazy<Faker<DeploymentJob>> _lazyDeploymentJobFaker = new(() =>
51+
new Faker<DeploymentJob>()
52+
.UseSeed(GetFakerSeed())
53+
.RuleFor(deploymentJob => deploymentJob.StartedAt, faker => faker.Date.PastOffset()));
54+
5055
public Faker<WebPage> WebPage => _lazyWebPageFaker.Value;
5156
public Faker<FriendlyUrl> FriendlyUrl => _lazyFriendlyUrlFaker.Value;
5257
public Faker<TextBlock> TextBlock => _lazyTextBlockFaker.Value;
5358
public Faker<Paragraph> Paragraph => _lazyParagraphFaker.Value;
5459
public Faker<WebImage> WebImage => _lazyWebImageFaker.Value;
5560
public Faker<PageFooter> PageFooter => _lazyPageFooterFaker.Value;
5661
public Faker<WebLink> WebLink => _lazyWebLinkFaker.Value;
62+
public Faker<DeploymentJob> DeploymentJob => _lazyDeploymentJobFaker.Value;
5763
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using JetBrains.Annotations;
3+
using JsonApiDotNetCore.Resources;
4+
using JsonApiDotNetCore.Resources.Annotations;
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency;
7+
8+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
9+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")]
10+
public sealed class DeploymentJob : Identifiable<Guid>
11+
{
12+
[Attr]
13+
[Required]
14+
public DateTimeOffset? StartedAt { get; set; }
15+
16+
[HasOne]
17+
public DeploymentJob? ParentJob { get; set; }
18+
19+
[HasMany]
20+
public IList<DeploymentJob> ChildJobs { get; set; } = new List<DeploymentJob>();
21+
}

0 commit comments

Comments
 (0)