Skip to content

Commit c7d3a6e

Browse files
authored
fix: ensure crucial security measure is implemented per https://rxdb.info/replication.html#security (#82)
1 parent 5edef13 commit c7d3a6e

File tree

9 files changed

+276
-57
lines changed

9 files changed

+276
-57
lines changed

README.md

+18-11
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
11
# RxDBDotNet
22

3-
<p align="left">
4-
<a href="https://www.nuget.org/packages/RxDBDotNet/" style="text-decoration:none;">
5-
<img src="https://img.shields.io/nuget/v/RxDBDotNet.svg" alt="NuGet Version" style="margin-right: 10px;">
6-
</a>
7-
<a href="https://www.nuget.org/packages/RxDBDotNet/" style="text-decoration:none;">
8-
<img src="https://img.shields.io/nuget/dt/RxDBDotNet.svg" alt="NuGet Downloads" style="margin-right: 10px;">
9-
</a>
10-
<a href="https://codecov.io/github/Ziptility/RxDBDotNet" style="text-decoration:none;">
11-
<img src="https://codecov.io/github/Ziptility/RxDBDotNet/graph/badge.svg?token=VvuBJEsIHT" alt="codecov">
12-
</a>
13-
</p>
3+
[![NuGet Version](https://img.shields.io/nuget/v/RxDBDotNet.svg)](https://www.nuget.org/packages/RxDBDotNet/)
4+
[![NuGet Downloads](https://img.shields.io/nuget/dt/RxDBDotNet.svg)](https://www.nuget.org/packages/RxDBDotNet/)
5+
[![codecov](https://codecov.io/github/Ziptility/RxDBDotNet/graph/badge.svg?token=VvuBJEsIHT)](https://codecov.io/github/Ziptility/RxDBDotNet)
146

157
RxDBDotNet is a powerful .NET library that implements the [RxDB replication protocol](https://rxdb.info/replication.html), enabling real-time data synchronization between RxDB clients and .NET servers using GraphQL and Hot Chocolate. It extends the standard RxDB replication protocol with .NET-specific enhancements.
168

@@ -37,6 +29,7 @@ Ready to dive in? [Get started](#getting-started) or [contribute](#contributing)
3729
- [Sample Implementation](#sample-implementation)
3830
- [RxDB Replication Protocol Details](#rxdb-replication-protocol-details)
3931
- [Advanced Features](#advanced-features)
32+
- [Security Considerations](#security-considerations)
4033
- [Contributing](#contributing)
4134
- [Code of Conduct](#code-of-conduct)
4235
- [License](#license)
@@ -642,6 +635,20 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
642635

643636
This feature allows for more robust and flexible authentication scenarios, particularly in environments where signing keys may change dynamically or where you're integrating with external OIDC providers like IdentityServer.
644637

638+
## Security Considerations
639+
640+
### Server-Side Timestamp Overwriting
641+
642+
RxDBDotNet implements a [crucial security measure](https://rxdb.info/replication.html#security) to prevent potential issues with untrusted client-side clocks. When the server receives a document creation or update request, it always overwrites the `UpdatedAt` timestamp with its own server-side timestamp. This approach ensures that:
643+
644+
1. The integrity of the document's timeline is maintained.
645+
2. Potential time-based attacks or inconsistencies due to client clock discrepancies are mitigated.
646+
3. The server maintains authoritative control over the timestamp for all document changes.
647+
648+
This security measure is implemented in the `MutationResolver<TDocument>` class, which handles document push operations. Developers using RxDBDotNet should be aware that any client-provided `UpdatedAt` value will be ignored and replaced with the server's timestamp.
649+
650+
Important: While the `IReplicatedDocument` interface defines `UpdatedAt` with both a getter and a setter, developers should not manually set this property in their application code. Always rely on the server to set the correct `UpdatedAt` value during replication operations. The setter is present solely to allow the server to overwrite the timestamp as a security measure.
651+
645652
## Contributing
646653

647654
We welcome contributions to RxDBDotNet! Here's how you can contribute:

example/LiveDocs.GraphQLApi/Models/Replication/ReplicatedDocument.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace LiveDocs.GraphQLApi.Models.Replication;
1010
public abstract record ReplicatedDocument : IReplicatedDocument
1111
{
1212
private readonly List<string>? _topics;
13-
private readonly DateTimeOffset _updatedAt;
13+
private DateTimeOffset _updatedAt;
1414

1515
/// <inheritdoc />
1616
[Required]
@@ -27,7 +27,7 @@ public abstract record ReplicatedDocument : IReplicatedDocument
2727
public required DateTimeOffset UpdatedAt
2828
{
2929
get => _updatedAt;
30-
init =>
30+
set =>
3131
// Strip microseconds by setting the ticks to zero, keeping only up to milliseconds.
3232
// Doing this because microseconds are not supported by Hot Chocolate's DateTime serializer.
3333
// Now Equals() and GetHashCode() will work correctly.

src/RxDBDotNet/Documents/IReplicatedDocument.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ public interface IReplicatedDocument
2323
/// <remarks>
2424
/// This property is crucial for conflict resolution and determining the most recent version of a document.
2525
/// It should be updated every time the document is modified.
26+
/// The server will always overwrite this value with its own timestamp to ensure security and consistency.
2627
/// </remarks>
27-
DateTimeOffset UpdatedAt { get; }
28+
DateTimeOffset UpdatedAt { get; set; }
2829

2930
/// <summary>
3031
/// A value indicating whether the document has been marked as deleted.

src/RxDBDotNet/Resolvers/MutationResolver.cs

+11
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ private static async Task<List<TDocument>> ApplyChangesAsync(
198198
await AuthorizeOperationAsync(authorizationHelper, currentUser, securityOptions, Operation.Create)
199199
.ConfigureAwait(false);
200200

201+
// Set the server timestamp to ensure data integrity and security
202+
// This overrides any client-provided timestamp, as client-side clocks cannot be trusted
203+
// It's crucial for maintaining a reliable timeline of document changes and preventing potential exploits
204+
create.UpdatedAt = DateTimeOffset.UtcNow;
201205
await documentService.CreateDocumentAsync(create, cancellationToken)
202206
.ConfigureAwait(false);
203207
}
@@ -236,6 +240,13 @@ private static async Task HandleDocumentUpdateAsync(
236240
SecurityOptions<TDocument>? securityOptions,
237241
CancellationToken cancellationToken)
238242
{
243+
// Set the server timestamp for updates
244+
// This is a critical security measure that ensures:
245+
// 1. The integrity of the document's timeline is maintained
246+
// 2. Potential time-based attacks or inconsistencies due to client clock discrepancies are mitigated
247+
// 3. The server has the authoritative timestamp for all document changes
248+
update.UpdatedAt = DateTimeOffset.UtcNow;
249+
239250
if (update.IsDeleted)
240251
{
241252
await AuthorizeOperationAsync(authorizationHelper, currentUser, securityOptions, Operation.Delete)

tests/RxDBDotNet.Tests/AdditionalAuthorizationTests.cs

+17-13
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ public async Task SystemAdmin_ShouldHaveFullAccessToWorkspace()
204204
var systemAdmin = await TestContext.CreateUserAsync(workspace, UserRole.SystemAdmin, TestContext.CancellationToken);
205205

206206
// Act & Assert - Create
207-
var newWorkspace = new WorkspaceInputGql
207+
var newWorkspaceInput = new WorkspaceInputGql
208208
{
209209
Id = Provider.Sql.Create(),
210210
Name = Strings.CreateString(),
@@ -220,7 +220,7 @@ public async Task SystemAdmin_ShouldHaveFullAccessToWorkspace()
220220
var createWorkspaceInputPushRowGql = new WorkspaceInputPushRowGql
221221
{
222222
AssumedMasterState = null,
223-
NewDocumentState = newWorkspace,
223+
NewDocumentState = newWorkspaceInput,
224224
};
225225

226226
var createWorkspaceInputGql = new PushWorkspaceInputGql
@@ -253,22 +253,24 @@ public async Task SystemAdmin_ShouldHaveFullAccessToWorkspace()
253253
readResponse.Data.PullWorkspace.Should()
254254
.NotBeNull();
255255
readResponse.Data.PullWorkspace?.Documents.Should()
256-
.Contain(w => w.Name == newWorkspace.Name.Value);
256+
.Contain(w => w.Name == newWorkspaceInput.Name.Value);
257+
258+
var newWorkspace = await TestContext.HttpClient.GetWorkspaceByIdAsync(newWorkspaceInput.Id, TestContext.CancellationToken, systemAdmin.JwtAccessToken);
257259

258260
// Act & Assert - Update
259-
var updatedWorkspace = new WorkspaceInputGql
261+
var updatedWorkspaceInput = new WorkspaceInputGql
260262
{
261-
Id = newWorkspace.Id,
263+
Id = newWorkspaceInput.Id,
262264
Name = Strings.CreateString(),
263-
IsDeleted = newWorkspace.IsDeleted,
265+
IsDeleted = newWorkspaceInput.IsDeleted,
264266
UpdatedAt = DateTimeOffset.UtcNow,
265-
Topics = newWorkspace.Topics,
267+
Topics = newWorkspaceInput.Topics,
266268
};
267269

268270
var updateWorkspaceInputPushRowGql = new WorkspaceInputPushRowGql
269271
{
270-
AssumedMasterState = newWorkspace,
271-
NewDocumentState = updatedWorkspace,
272+
AssumedMasterState = newWorkspace.ToWorkspaceInputGql(),
273+
NewDocumentState = updatedWorkspaceInput,
272274
};
273275

274276
var updateWorkspaceInputGql = new PushWorkspaceInputGql
@@ -293,18 +295,20 @@ public async Task SystemAdmin_ShouldHaveFullAccessToWorkspace()
293295
.BeNullOrEmpty();
294296

295297
// Act & Assert - Delete
298+
var updatedWorkspace = await TestContext.HttpClient.GetWorkspaceByIdAsync(newWorkspaceInput.Id, TestContext.CancellationToken, systemAdmin.JwtAccessToken);
299+
296300
var deleteWorkspace = new WorkspaceInputGql
297301
{
298-
Id = updatedWorkspace.Id,
299-
Name = updatedWorkspace.Name,
302+
Id = updatedWorkspaceInput.Id,
303+
Name = updatedWorkspaceInput.Name,
300304
IsDeleted = true,
301305
UpdatedAt = DateTimeOffset.UtcNow,
302-
Topics = updatedWorkspace.Topics,
306+
Topics = updatedWorkspaceInput.Topics,
303307
};
304308

305309
var deleteWorkspaceInputPushRowGql = new WorkspaceInputPushRowGql
306310
{
307-
AssumedMasterState = updatedWorkspace,
311+
AssumedMasterState = updatedWorkspace.ToWorkspaceInputGql(),
308312
NewDocumentState = deleteWorkspace,
309313
};
310314

tests/RxDBDotNet.Tests/PushDocumentsTests.cs

+58
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,64 @@ public async Task DisposeAsync()
1818
await TestContext.DisposeAsync();
1919
}
2020

21+
[Fact]
22+
public async Task PushDocuments_ShouldOverwriteClientProvidedTimestamp()
23+
{
24+
// Arrange
25+
TestContext = new TestScenarioBuilder().Build();
26+
var workspaceId = Provider.Sql.Create();
27+
var clientProvidedTimestamp = DateTimeOffset.UtcNow.AddDays(-1); // Simulate an old timestamp
28+
29+
var newWorkspace = new WorkspaceInputGql
30+
{
31+
Id = workspaceId,
32+
Name = Strings.CreateString(),
33+
UpdatedAt = clientProvidedTimestamp, // Use the old timestamp
34+
IsDeleted = false,
35+
Topics = new List<string>
36+
{
37+
workspaceId.ToString(),
38+
},
39+
};
40+
41+
var workspaceInputPushRowGql = new WorkspaceInputPushRowGql
42+
{
43+
AssumedMasterState = null,
44+
NewDocumentState = newWorkspace,
45+
};
46+
47+
var pushWorkspaceInputGql = new PushWorkspaceInputGql
48+
{
49+
WorkspacePushRow = new List<WorkspaceInputPushRowGql?>
50+
{
51+
workspaceInputPushRowGql,
52+
},
53+
};
54+
55+
var createWorkspace =
56+
new MutationQueryBuilderGql().WithPushWorkspace(new PushWorkspacePayloadQueryBuilderGql().WithAllFields(), pushWorkspaceInputGql);
57+
58+
// Act
59+
var pushResponse = await TestContext.HttpClient.PostGqlMutationAsync(createWorkspace, TestContext.CancellationToken);
60+
61+
// Assert push response
62+
pushResponse.Errors.Should().BeNullOrEmpty();
63+
pushResponse.Data.PushWorkspace?.Errors.Should().BeNullOrEmpty();
64+
pushResponse.Data.PushWorkspace?.Workspace.Should().BeNullOrEmpty();
65+
66+
// Verify the created workspace
67+
var createdWorkspace = await TestContext.HttpClient.GetWorkspaceByIdAsync(workspaceId, TestContext.CancellationToken);
68+
69+
createdWorkspace.Should().NotBeNull();
70+
createdWorkspace.Id.Should().Be(workspaceId);
71+
createdWorkspace.Name.Should().Be(newWorkspace.Name.Value);
72+
createdWorkspace.IsDeleted.Should().Be(newWorkspace.IsDeleted);
73+
74+
// Check if the timestamp was overwritten by the server
75+
createdWorkspace.UpdatedAt.Should().BeAfter(clientProvidedTimestamp);
76+
createdWorkspace.UpdatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1));
77+
}
78+
2179
[Fact]
2280
public async Task PushDocuments_WithNullDocumentList_ShouldReturnEmptyResult()
2381
{

tests/RxDBDotNet.Tests/SecurityTests.cs

+85-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using RxDBDotNet.Tests.Model;
22
using RxDBDotNet.Tests.Utils;
3+
using static RxDBDotNet.Tests.Setup.Strings;
34

45
namespace RxDBDotNet.Tests;
56

@@ -28,13 +29,45 @@ public async Task AWorkspaceAdminShouldBeAbleToCreateAWorkspace()
2829
.Build();
2930

3031
var workspace = await TestContext.CreateWorkspaceAsync(TestContext.CancellationToken);
31-
var admin = await TestContext.CreateUserAsync(workspace, UserRole.WorkspaceAdmin, TestContext.CancellationToken);
32+
var workspaceAdmin = await TestContext.CreateUserAsync(workspace, UserRole.WorkspaceAdmin, TestContext.CancellationToken);
33+
34+
var workspaceId = Provider.Sql.Create();
35+
36+
var workspaceInputGql = new WorkspaceInputGql
37+
{
38+
Id = workspaceId,
39+
Name = CreateString(),
40+
UpdatedAt = DateTimeOffset.UtcNow,
41+
IsDeleted = false,
42+
Topics = new List<string>
43+
{
44+
workspaceId.ToString(),
45+
},
46+
};
47+
48+
var workspaceInputPushRowGql = new WorkspaceInputPushRowGql
49+
{
50+
AssumedMasterState = null,
51+
NewDocumentState = workspaceInputGql,
52+
};
53+
54+
var pushWorkspaceInputGql = new PushWorkspaceInputGql
55+
{
56+
WorkspacePushRow = new List<WorkspaceInputPushRowGql?>
57+
{
58+
workspaceInputPushRowGql,
59+
},
60+
};
61+
62+
var createWorkspace = new MutationQueryBuilderGql().WithPushWorkspace(new PushWorkspacePayloadQueryBuilderGql().WithAllFields()
63+
.WithErrors(new PushWorkspaceErrorQueryBuilderGql().WithAuthenticationErrorFragment(
64+
new AuthenticationErrorQueryBuilderGql().WithAllFields())), pushWorkspaceInputGql);
3265

3366
// Act
34-
var response = await TestContext.HttpClient.CreateWorkspaceAsync(TestContext.CancellationToken, admin.JwtAccessToken);
67+
await TestContext.HttpClient.PostGqlMutationAsync(createWorkspace, TestContext.CancellationToken, workspaceAdmin.JwtAccessToken);
3568

3669
// Assert
37-
await TestContext.HttpClient.VerifyWorkspaceAsync(response.workspaceInputGql, TestContext.CancellationToken);
70+
await TestContext.HttpClient.VerifyWorkspaceAsync(workspaceInputGql, TestContext.CancellationToken);
3871
}
3972

4073
[Fact]
@@ -47,10 +80,46 @@ public async Task AStandardUserShouldNotBeAbleToCreateAWorkspace()
4780
.Build();
4881

4982
var workspace = await TestContext.CreateWorkspaceAsync(TestContext.CancellationToken);
83+
5084
var standardUser = await TestContext.CreateUserAsync(workspace, UserRole.StandardUser, TestContext.CancellationToken);
5185

86+
var workspaceId = Provider.Sql.Create();
87+
88+
var newWorkspace = new WorkspaceInputGql
89+
{
90+
Id = workspaceId,
91+
Name = CreateString(),
92+
UpdatedAt = DateTimeOffset.UtcNow,
93+
IsDeleted = false,
94+
Topics = new List<string>
95+
{
96+
workspaceId.ToString(),
97+
},
98+
};
99+
100+
var workspaceInputPushRowGql = new WorkspaceInputPushRowGql
101+
{
102+
AssumedMasterState = null,
103+
NewDocumentState = newWorkspace,
104+
};
105+
106+
var pushWorkspaceInputGql = new PushWorkspaceInputGql
107+
{
108+
WorkspacePushRow = new List<WorkspaceInputPushRowGql?>
109+
{
110+
workspaceInputPushRowGql,
111+
},
112+
};
113+
114+
var createWorkspace = new MutationQueryBuilderGql().WithPushWorkspace(new PushWorkspacePayloadQueryBuilderGql().WithAllFields()
115+
.WithErrors(new PushWorkspaceErrorQueryBuilderGql().WithAuthenticationErrorFragment(
116+
new AuthenticationErrorQueryBuilderGql().WithAllFields())), pushWorkspaceInputGql);
117+
52118
// Act
53-
var (_, response) = await TestContext.HttpClient.CreateWorkspaceAsync(TestContext.CancellationToken, standardUser.JwtAccessToken);
119+
var response = await TestContext.HttpClient.PostGqlMutationAsync(
120+
createWorkspace,
121+
TestContext.CancellationToken,
122+
standardUser.JwtAccessToken);
54123

55124
// Assert
56125
response.Data.PushWorkspace?.Workspace.Should()
@@ -123,7 +192,7 @@ public async Task AWorkspaceAdminShouldBeAbleToUpdateAWorkspace()
123192
.Build();
124193

125194
var workspace = await TestContext.CreateWorkspaceAsync(TestContext.CancellationToken);
126-
var admin = await TestContext.CreateUserAsync(workspace, UserRole.WorkspaceAdmin, TestContext.CancellationToken);
195+
var workspaceAdmin = await TestContext.CreateUserAsync(workspace, UserRole.WorkspaceAdmin, TestContext.CancellationToken);
127196

128197
// Act
129198
var workspaceToUpdate = new WorkspaceInputGql
@@ -161,15 +230,19 @@ public async Task AWorkspaceAdminShouldBeAbleToUpdateAWorkspace()
161230
var updateWorkspace =
162231
new MutationQueryBuilderGql().WithPushWorkspace(new PushWorkspacePayloadQueryBuilderGql().WithAllFields(), pushWorkspaceInputGql);
163232

164-
var response = await TestContext.HttpClient.PostGqlMutationAsync(updateWorkspace, TestContext.CancellationToken, admin.JwtAccessToken);
233+
var response = await TestContext.HttpClient.PostGqlMutationAsync(updateWorkspace, TestContext.CancellationToken, workspaceAdmin.JwtAccessToken);
165234

166235
// Assert
167236
response.Errors.Should()
168237
.BeNullOrEmpty();
169238
response.Data.PushWorkspace?.Workspace.Should()
170239
.BeNullOrEmpty();
171240

172-
var updatedWorkspace = await TestContext.HttpClient.GetWorkspaceByIdAsync(workspace.ReplicatedDocumentId, TestContext.CancellationToken);
241+
var updatedWorkspace = await TestContext.HttpClient.GetWorkspaceByIdAsync(
242+
workspace.ReplicatedDocumentId,
243+
TestContext.CancellationToken,
244+
workspaceAdmin.JwtAccessToken);
245+
173246
updatedWorkspace.Name.Should()
174247
.Be(workspaceToUpdate.Name.Value);
175248
}
@@ -290,7 +363,11 @@ public async Task ASystemAdminShouldBeAbleToDeleteAWorkspace()
290363
response.Data.PushWorkspace?.Workspace.Should()
291364
.BeNullOrEmpty();
292365

293-
var deletedWorkspace = await TestContext.HttpClient.GetWorkspaceByIdAsync(workspace.ReplicatedDocumentId, TestContext.CancellationToken);
366+
var deletedWorkspace = await TestContext.HttpClient.GetWorkspaceByIdAsync(
367+
workspace.ReplicatedDocumentId,
368+
TestContext.CancellationToken,
369+
systemAdmin.JwtAccessToken);
370+
294371
deletedWorkspace.IsDeleted.Should()
295372
.BeTrue();
296373
}

0 commit comments

Comments
 (0)