MongoDB · Update
A security check around updates: batches, allowed fields, false positives
A security check around updates (SecurityGuard, in the project root), in four scenarios: an API batch of changes passes through; an attack hidden in one item refuses the whole batch and is logged; a list of allowed fields stops unknown names before ARX; and a false positive from your own configuration is reported as a bug, not an attack, so nobody is blocked.
Set up for this page
Besides the sample itself, it uses SampleContext.cs (the dbContext every sample is given); SampleOutput.cs (Step and Show, which print); SampleContext.Application.cs (dbContext.Service, the application's ARX service); SampleCatalog.cs (the tables' ids, keys, lookups and display names); SampleContext.Direct.cs (dbContext.Direct); MongoDirectHelper.cs (the checks behind dbContext.Direct); SecurityGuard.cs (the security log and strikes of the attack samples), in the files below.
dbContext.Direct is the samples' own checking code on the plain driver, not part of ARX: leave it out of your application.
Before scenario 1 the sample runs this, so the service and the rows the scenarios use are there:
var guard = new SecurityGuard(blockAfter: 3);
var accounts = new List<ArxEntity>();
foreach (var number in new[] { "ACC-G401", "ACC-G402", "ACC-G403" })
{
var account = new ArxEntity("account");
account["name"] = $"Guarded {number}";
account["account_number"] = number;
account["employee_count"] = 10;
accounts.Add(account);
}
var ids = await service.CreateMultipleAsync(accounts);1 A batch of changes passes through
A batch of two changes goes through the guard untouched.
Already in scope: service (ArxMongoDbService), guard (SecurityGuard), ids (IReadOnlyList<ArxRecordId>)
var result = await PatchAccountsAsync(service, guard, "ana", new[]
{
(ids[0], new Dictionary<string, object> { ["employee_count"] = 11 }),
(ids[1], new Dictionary<string, object> { ["employee_count"] = 12 }),
});
Console.WriteLine($"ana: {result.Outcome}, {result.Value} accounts changed");Defined once in this sample.
private static Task<GuardResult<int>> PatchAccountsAsync(ArxMongoDbService service, SecurityGuard guard, string user, IReadOnlyList<(ArxRecordId Id, Dictionary<string, object> Fields)> items)
{
var changes = new List<ArxEntity>();
foreach (var (id, fields) in items)
{
var change = new ArxEntity("account");
change.RecordId = id;
foreach (var field in fields)
{
change[field.Key] = field.Value;
}
changes.Add(change);
}
return guard.RunAsync(user, "patch accounts", NameSource.UserInput, () => service.UpdateMultipleAsync(changes));
}Check it in the database
Run it in mongosh connected to the database, after the code has run.
db.account.find({ account_number: { $in: ["ACC-G401", "ACC-G402"] } }, { account_number: 1, employee_count: 1 }).sort({ account_number: 1 })Expect: 2 documents: ACC-G401 11, ACC-G402 12.
2 An attack in one item refuses the whole batch
One item of three carries SQL in a field name: ARX refuses the whole UpdateMultiple, the guard logs it.
Already in scope: service (ArxMongoDbService), guard (SecurityGuard), ids (IReadOnlyList<ArxRecordId>)
var result = await PatchAccountsAsync(service, guard, "mallory", new[]
{
(ids[0], new Dictionary<string, object> { ["employee_count"] = 0 }),
(ids[1], new Dictionary<string, object> { ["employee_count\" = 0, \"name"] = "pwned" }),
(ids[2], new Dictionary<string, object> { ["employee_count"] = 0 }),
});
Console.WriteLine($"mallory sees: \"{result.ForTheUser}\"; log: {guard.Events[^1].Kind}");Defined once in this sample.
private static Task<GuardResult<int>> PatchAccountsAsync(ArxMongoDbService service, SecurityGuard guard, string user, IReadOnlyList<(ArxRecordId Id, Dictionary<string, object> Fields)> items)
{
var changes = new List<ArxEntity>();
foreach (var (id, fields) in items)
{
var change = new ArxEntity("account");
change.RecordId = id;
foreach (var field in fields)
{
change[field.Key] = field.Value;
}
changes.Add(change);
}
return guard.RunAsync(user, "patch accounts", NameSource.UserInput, () => service.UpdateMultipleAsync(changes));
}Check it in the database
Run it in mongosh connected to the database, after the code has run.
db.account.countDocuments({ account_number: { $in: ["ACC-G401", "ACC-G402", "ACC-G403"] }, employee_count: 0 })Expect: 0: not even the two harmless items were stored.
3 Allowed fields stop unknown names before ARX
Defence in depth: the endpoint checks the field names against its allowed list before calling ARX. An unknown field is a client error (a 400), not a security event, and ARX never sees it.
Already in scope: service (ArxMongoDbService), guard (SecurityGuard), ids (IReadOnlyList<ArxRecordId>)
var posted = new Dictionary<string, object> { ["credit_limit"] = 1_000_000m, ["employee_count"] = 13 };
var unknown = posted.Keys.Where(x => !AllowedFields.Contains(x)).ToList();
if (unknown.Count > 0)
{
Console.WriteLine($"400 Bad Request: fields not allowed: {string.Join(", ", unknown)}; security log still has {guard.Events.Count} entries");
}
else
{
await PatchAccountsAsync(service, guard, "ana", new[] { (ids[2], posted) });
}Defined once in this sample.
private static readonly HashSet<string> AllowedFields = new(StringComparer.Ordinal) { "name", "website", "employee_count" };
private static Task<GuardResult<int>> PatchAccountsAsync(ArxMongoDbService service, SecurityGuard guard, string user, IReadOnlyList<(ArxRecordId Id, Dictionary<string, object> Fields)> items)
{
var changes = new List<ArxEntity>();
foreach (var (id, fields) in items)
{
var change = new ArxEntity("account");
change.RecordId = id;
foreach (var field in fields)
{
change[field.Key] = field.Value;
}
changes.Add(change);
}
return guard.RunAsync(user, "patch accounts", NameSource.UserInput, () => service.UpdateMultipleAsync(changes));
}Check it in the database
Run it in mongosh connected to the database, after the code has run.
db.account.find({ account_number: "ACC-G403" }, { credit_limit: 1, employee_count: 1 })Expect: 1 document: no credit_limit field, employee_count 10 (the request was refused before ARX).
4 A false positive from your own configuration
A false positive from your own configuration: a nightly job reads the column to reset from appsettings, and someone pasted it with a trailing ";". ARX refuses it as SQL (ARX1017), but the names came from code (Code), so the guard logs a bug, blocks nobody, and the job's owner fixes the setting.
Already in scope: service (ArxMongoDbService), guard (SecurityGuard), ids (IReadOnlyList<ArxRecordId>)
var columnFromSettings = "website;";
foreach (var column in new[] { columnFromSettings, columnFromSettings.TrimEnd(';') })
{
var reset = new ArxEntity("account");
reset.RecordId = ids[2];
reset[column] = "https://example.com/guarded";
var result = await guard.RunAsync("nightly-job", "reset website", NameSource.Code, () => service.UpdateAsync(reset));
Console.WriteLine($"'{column}': {result.Outcome}" + (result.Outcome == GuardOutcome.Refused ? $", logged as a {guard.Events[^1].Kind}; job blocked: {guard.IsBlocked("nightly-job")}" : ""));
}Check it in the database
Run it in mongosh connected to the database, after the code has run.
db.account.find({ account_number: "ACC-G403" }, { website: 1 })Expect: 1 document: https://example.com/guarded (written once the setting was fixed).
Run the complete sample
The code above comes from one program, built in a fresh project and run against the database. To run it yourself, here is every file and step.
The program, its files and how to run it
Run it locally
- .NET SDK: .NET SDK 10.0.100 or later 10.0.x (target
net10.0). - Create the project:
Terminal dotnet new console -n ArxSample -f net10.0 cd ArxSample - Install the packages:
Terminal dotnet add package ARX.SDK.MongoDb --version 1.0.0-rc.1 - Database: a MongoDB 7.0, a replica set (tested with 7.0.45, one node) database with the sample schema.
- Configuration: set
ARX_SAMPLES_MONGODBto its connection string. Nothing is hard-coded. - Source code: replace
Program.csand add the 8 other files below, with the file names shown. - Run it:
Terminal dotnet run - Expected output: below.
The files
Runs the sample. Generated for this page; it reads the connection string from the environment and first remakes the sample database from sample-schema.json.
using ArxSamples.MongoDb;
using ArxSamples.MongoDb.Operations.Update.AdvancedCases;
// The connection string comes from the environment, never from the code (see "Configure the connection").
var connectionString = Environment.GetEnvironmentVariable("ARX_SAMPLES_MONGODB")
?? throw new InvalidOperationException("Set the ARX_SAMPLES_MONGODB environment variable to your MongoDB connection string.");
var dbContext = new SampleContext(connectionString);
// Every run starts from the sample data: this deletes and makes again what sample-schema.json lists
// (save the file in the project folder, see Get started).
await dbContext.Direct.ResetAsync();
await new Advanced2_GuardAgainstAttacks().RunAsync(dbContext);
The sample, as it is in the repository.
using ARX.SDK.Services;
using static ArxSamples.MongoDb.SampleOutput;
namespace ArxSamples.MongoDb.Operations.Update.AdvancedCases;
/// <summary>
/// A security check around updates (<see cref="SecurityGuard"/>, in the project root), in four scenarios: an API batch
/// of changes passes through; an attack hidden in one item refuses the whole batch and is logged; a list of allowed
/// fields stops unknown names before ARX; and a false positive from your own configuration is reported as a bug, not
/// an attack, so nobody is blocked.
/// </summary>
public sealed class Advanced2_GuardAgainstAttacks : ISample
{
public string Name => "update-advanced-2-guard-against-attacks";
public string Title => "A security check around updates: batches, allowed fields, false positives";
/// <summary>The fields an API client may change. Checking them first is defence in depth: ARX would refuse SQL in a name anyway.</summary>
private static readonly HashSet<string> AllowedFields = new(StringComparer.Ordinal) { "name", "website", "employee_count" };
public async Task RunAsync(SampleContext dbContext)
{
var guard = new SecurityGuard(blockAfter: 3);
var accounts = new List<ArxEntity>();
foreach (var number in new[] { "ACC-G401", "ACC-G402", "ACC-G403" })
{
var account = new ArxEntity("account");
account["name"] = $"Guarded {number}";
account["account_number"] = number;
account["employee_count"] = 10;
accounts.Add(account);
}
var ids = await dbContext.Service.CreateMultipleAsync(accounts);
await Scenario1_ABatchOfChangesPassesThroughAsync(dbContext, guard, ids);
await Scenario2_AnAttackInOneItemRefusesTheBatchAsync(dbContext, guard, ids);
await Scenario3_AllowedFieldsStopUnknownNamesFirstAsync(dbContext, guard, ids);
await Scenario4_AFalsePositiveFromYourOwnConfigurationAsync(dbContext, guard, ids);
}
/// <summary>The "PATCH /api/accounts" endpoint: each item is an id and the fields to change, as the client sent them.</summary>
private static Task<GuardResult<int>> PatchAccountsAsync(SampleContext dbContext, SecurityGuard guard, string user, IReadOnlyList<(ArxRecordId Id, Dictionary<string, object> Fields)> items)
{
var changes = new List<ArxEntity>();
foreach (var (id, fields) in items)
{
var change = new ArxEntity("account");
change.RecordId = id;
foreach (var field in fields)
{
change[field.Key] = field.Value;
}
changes.Add(change);
}
return guard.RunAsync(user, "patch accounts", NameSource.UserInput, () => dbContext.Service.UpdateMultipleAsync(changes));
}
/// <summary>1. A batch of two changes goes through the guard untouched.</summary>
private static async Task Scenario1_ABatchOfChangesPassesThroughAsync(SampleContext dbContext, SecurityGuard guard, IReadOnlyList<ArxRecordId> ids)
{
Step("1. A batch of changes passes through");
var result = await PatchAccountsAsync(dbContext, guard, "ana", new[]
{
(ids[0], new Dictionary<string, object> { ["employee_count"] = 11 }),
(ids[1], new Dictionary<string, object> { ["employee_count"] = 12 }),
});
Show($"ana: {result.Outcome}, {result.Value} accounts changed");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* db.account.find({ account_number: { $in: ["ACC-G401", "ACC-G402"] } }, { account_number: 1, employee_count: 1 }).sort({ account_number: 1 })
* -- Expect: 2 documents: ACC-G401 11, ACC-G402 12.
* ===========================================================
*/
}
/// <summary>2. One item of three carries SQL in a field name: ARX refuses the whole UpdateMultiple, the guard logs it.</summary>
private static async Task Scenario2_AnAttackInOneItemRefusesTheBatchAsync(SampleContext dbContext, SecurityGuard guard, IReadOnlyList<ArxRecordId> ids)
{
Step("2. An attack in one item refuses the whole batch");
var result = await PatchAccountsAsync(dbContext, guard, "mallory", new[]
{
(ids[0], new Dictionary<string, object> { ["employee_count"] = 0 }),
(ids[1], new Dictionary<string, object> { ["employee_count\" = 0, \"name"] = "pwned" }),
(ids[2], new Dictionary<string, object> { ["employee_count"] = 0 }),
});
Show($"mallory sees: \"{result.ForTheUser}\"; log: {guard.Events[^1].Kind}");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* db.account.countDocuments({ account_number: { $in: ["ACC-G401", "ACC-G402", "ACC-G403"] }, employee_count: 0 })
* -- Expect: 0: not even the two harmless items were stored.
* ===========================================================
*/
}
/// <summary>
/// 3. Defence in depth: the endpoint checks the field names against its allowed list before calling ARX. An unknown
/// field is a client error (a 400), not a security event, and ARX never sees it.
/// </summary>
private static async Task Scenario3_AllowedFieldsStopUnknownNamesFirstAsync(SampleContext dbContext, SecurityGuard guard, IReadOnlyList<ArxRecordId> ids)
{
Step("3. Allowed fields stop unknown names before ARX");
var posted = new Dictionary<string, object> { ["credit_limit"] = 1_000_000m, ["employee_count"] = 13 };
var unknown = posted.Keys.Where(x => !AllowedFields.Contains(x)).ToList();
if (unknown.Count > 0)
{
Show($"400 Bad Request: fields not allowed: {string.Join(", ", unknown)}; security log still has {guard.Events.Count} entries");
}
else
{
await PatchAccountsAsync(dbContext, guard, "ana", new[] { (ids[2], posted) });
}
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* db.account.find({ account_number: "ACC-G403" }, { credit_limit: 1, employee_count: 1 })
* -- Expect: 1 document: no credit_limit field, employee_count 10 (the request was refused before ARX).
* ===========================================================
*/
}
/// <summary>
/// 4. A false positive from your own configuration: a nightly job reads the column to reset from appsettings, and
/// someone pasted it with a trailing ";". ARX refuses it as SQL (ARX1017), but the names came from code
/// (<see cref="NameSource.Code"/>), so the guard logs a bug, blocks nobody, and the job's owner fixes the setting.
/// </summary>
private static async Task Scenario4_AFalsePositiveFromYourOwnConfigurationAsync(SampleContext dbContext, SecurityGuard guard, IReadOnlyList<ArxRecordId> ids)
{
Step("4. A false positive from your own configuration");
var columnFromSettings = "website;";
foreach (var column in new[] { columnFromSettings, columnFromSettings.TrimEnd(';') })
{
var reset = new ArxEntity("account");
reset.RecordId = ids[2];
reset[column] = "https://example.com/guarded";
var result = await guard.RunAsync("nightly-job", "reset website", NameSource.Code, () => dbContext.Service.UpdateAsync(reset));
Show($"'{column}': {result.Outcome}" + (result.Outcome == GuardOutcome.Refused ? $", logged as a {guard.Events[^1].Kind}; job blocked: {guard.IsBlocked("nightly-job")}" : ""));
}
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* db.account.find({ account_number: "ACC-G403" }, { website: 1 })
* -- Expect: 1 document: https://example.com/guarded (written once the setting was fixed).
* ===========================================================
*/
}
}
What every sample is given: the connection string (and the ISample interface the samples implement).
namespace ArxSamples.MongoDb;
/// <summary>One documentation sample: a scenario a real application has, shown end to end.</summary>
public interface ISample
{
/// <summary>The name to run it alone: <c>dotnet run -- <name></c>.</summary>
string Name { get; }
/// <summary>One line on what it shows.</summary>
string Title { get; }
Task RunAsync(SampleContext dbContext);
}
/// <summary>
/// What a sample is given. Every sample gets the connection string; the application's service and catalog
/// (SampleContext.Application.cs) and the plain MongoDB driver read-back helper (SampleContext.Direct.cs) live in their own
/// files, so a sample needs only the files it uses.
/// </summary>
public sealed partial class SampleContext
{
public SampleContext(string connectionString)
{
ConnectionString = connectionString;
}
public string ConnectionString { get; }
}
How the samples print: Step for each scenario, Show for what it did, ShowError for an error caught on purpose.
using ARX.SDK;
namespace ArxSamples.MongoDb;
/// <summary>How the samples print: a numbered step for each scenario, then what it did.</summary>
public static class SampleOutput
{
/// <summary>The start of a scenario: <c>Step("1. By the id Create returned")</c>.</summary>
public static void Step(string text) => Console.WriteLine($" > {text}");
/// <summary>What the scenario did.</summary>
public static void Show(string text) => Console.WriteLine($" {text}");
/// <summary>An ARX error the scenario caught on purpose.</summary>
public static void ShowError(ArxException exception)
=> Console.WriteLine($" caught {exception.GetType().Name}: {exception.Message}");
}
The application's ARX service and its schema catalog, created once and shared by the samples.
using ARX.SDK.MongoDb.Services;
using ARX.SDK.Services;
namespace ArxSamples.MongoDb;
/// <summary>The application's ARX service and its schema catalog, shared by the real and advanced samples.</summary>
public sealed partial class SampleContext
{
private ArxSchemaCatalog? _catalog;
private ArxMongoDbService? _service;
/// <summary>The application's schema catalog: collection ids, keys, lookups and selection sets (SampleCatalog.cs).</summary>
public ArxSchemaCatalog Catalog => _catalog ??= SampleCatalog.Build();
/// <summary>
/// One service for the whole application. It shares one MongoDB client per connection string (a client is a
/// connection pool), so make it once and keep it.
/// </summary>
public ArxMongoDbService Service => _service ??= new ArxMongoDbService(ConnectionString, Catalog);
}
The application's schema catalog: every table's id column and keys, and its selection sets.
using ARX.SDK.Services;
namespace ArxSamples.MongoDb;
/// <summary>An account's industry. Stored in account.industry as the enum's number.</summary>
public enum Industry
{
Retail = 1,
Manufacturing = 2,
Healthcare = 3,
Finance = 4,
Technology = 5
}
/// <summary>
/// The application's schema catalog: every collection's id and keys, the lookups between collections, and every
/// picklist (selection set), defined once.
/// </summary>
/// <remarks>
/// <para>
/// <b>Ids.</b> On MongoDB the declared id is the document's <c>_id</c>, named in your code by the column you declare
/// (<c>account["account_id"]</c>). A generated string id is an ObjectId ARX makes before the insert and gives back as its
/// 24-character text; a generated Guid is one ARX makes; a supplied id is yours (a country code, a SKU). MongoDB has no
/// sequences, so a generated number isn't offered, and every document has an <c>_id</c>, so there is no NoIdColumn.
/// </para>
/// <para>
/// <b>Keys.</b> Business keys (an account number; a region and a SKU together) let Update, Delete and Retrieve find
/// documents by them. MongoDB enforces them with unique indexes: the sample database has one for each.
/// </para>
/// <para>
/// <b>Lookups.</b> MongoDB has no foreign keys, so the catalog says which field refers to which collection
/// (<c>.Lookup("contact", "account_id", "account")</c>). ARX stores the referenced id as it is stored there (an ObjectId)
/// and reads the field back as an <see cref="ArxEntityReference"/> with the referenced record's display name.
/// </para>
/// <para>
/// <b>Selection sets.</b> A field that only accepts a fixed list of codes: an account's status, a contact's preferred
/// channels (several codes, stored as an array). ARX checks every <see cref="ArxSelectionSetValue"/> against the catalog
/// before anything is sent and stores the codes themselves.
/// </para>
/// </remarks>
public static class SampleCatalog
{
public static ArxSchemaCatalog Build() => ArxSchemaCatalog.Create()
.Table("account", ArxIdColumn.Generated<string>("account_id"))
.Key("account", "account_number")
.DisplayName("account", "name")
.Table("contact", ArxIdColumn.Generated<string>("contact_id"))
.Key("contact", "email")
.Lookup("contact", "account_id", "account")
.DisplayName("contact", new[] { "first_name", "last_name" }, " ")
.Table("country", ArxIdColumn.Supplied<string>("code"))
.DisplayName("country", "name")
.Table("product", ArxIdColumn.Supplied<string>("sku"))
.DisplayName("product", "name")
.Table("price_list", ArxIdColumn.Generated<string>("price_id"))
.Key("price_list", "region", "sku")
.Lookup("price_list", "region", "country")
.Table("sales_order", ArxIdColumn.Generated<Guid>("order_id"))
.Key("sales_order", "order_number")
.Lookup("sales_order", "account_id", "account")
.Table("activity_log", ArxIdColumn.Generated<string>("log_id"))
.AddEnum<Industry>("Industry")
.Add("AccountStatus", ArxSelectionSetKind.String, ("ACTIVE", "Active"), ("ON_HOLD", "On hold"), ("CLOSED", "Closed"))
.Add("ContactChannels", ArxSelectionSetKind.String, ArxSelectionSetMultiplicity.Array, ("E", "Email"), ("P", "Phone"), ("S", "SMS"))
.Build();
}
The plain MongoDB.Driver read-back helper, for the samples that show what MongoDB stored.
namespace ArxSamples.MongoDb;
/// <summary>The plain MongoDB driver outside the SDK, for the samples that show what MongoDB stored.</summary>
public sealed partial class SampleContext
{
private MongoDirectHelper? _direct;
/// <summary>The plain MongoDB driver outside the SDK, used only to reset the database and to show what was stored.</summary>
public MongoDirectHelper Direct => _direct ??= new MongoDirectHelper(ConnectionString);
}
Plain MongoDB.Driver, not part of ARX: reads rows back so a sample can show what MongoDB stored.
using MongoDB.Bson;
using MongoDB.Driver;
namespace ArxSamples.MongoDb;
/// <summary>
/// The plain MongoDB driver, outside the SDK: it resets the sample database from Schema/sample-schema.json and shows
/// what was stored. Samples never write through it.
/// </summary>
public sealed class MongoDirectHelper
{
private readonly IMongoDatabase _database;
public MongoDirectHelper(string connectionString)
{
var url = MongoUrl.Create(connectionString);
_database = new MongoClient(url).GetDatabase(url.DatabaseName ?? "arx_samples");
}
/// <summary>
/// The sample schema: Schema/sample-schema.json next to the program (the samples' own project), or sample-schema.json
/// in the project folder (a sample copied from its page, as Get started says).
/// </summary>
private static string SchemaPath()
{
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Schema", "sample-schema.json"),
Path.Combine(Directory.GetCurrentDirectory(), "sample-schema.json"),
Path.Combine(Directory.GetCurrentDirectory(), "Schema", "sample-schema.json")
};
return candidates.FirstOrDefault(File.Exists)
?? throw new FileNotFoundException("sample-schema.json isn't in the project folder: download it from the Get started page and save it next to Program.cs.");
}
/// <summary>Drops the sample database and makes its collections, indexes and validators again.</summary>
public async Task ResetAsync()
{
var schema = BsonDocument.Parse(await File.ReadAllTextAsync(SchemaPath()));
foreach (var collection in schema["collections"].AsBsonArray.Select(x => x.AsBsonDocument))
{
var name = collection["name"].AsString;
// Only the collections the schema lists are dropped and made again: anything else in the database is left alone.
await _database.DropCollectionAsync(name);
var options = new CreateCollectionOptions<BsonDocument>();
if (collection.TryGetValue("validator", out var validator))
{
options.Validator = validator.AsBsonDocument;
}
await _database.CreateCollectionAsync(name, options);
foreach (var index in collection.GetValue("indexes", new BsonArray()).AsBsonArray.Select(x => x.AsBsonDocument))
{
var keys = index["keys"].AsBsonDocument;
var indexOptions = new CreateIndexOptions<BsonDocument> { Unique = index.GetValue("unique", false).AsBoolean };
if (index.GetValue("partial", false).AsBoolean)
{
indexOptions.PartialFilterExpression = new BsonDocument(keys.Names.Select(x => new BsonElement(x, new BsonDocument("$exists", true))));
}
await _database.GetCollection<BsonDocument>(name).Indexes.CreateOneAsync(new CreateIndexModel<BsonDocument>(keys, indexOptions));
}
}
}
/// <summary>The stored documents a filter finds, as MongoDB's JSON, one line each.</summary>
public async Task<IReadOnlyList<string>> FindAsync(string collection, string filterJson)
{
var documents = await _database.GetCollection<BsonDocument>(collection).Find(BsonDocument.Parse(filterJson)).ToListAsync();
return documents.Select(x => x.ToJson()).ToList();
}
}
A security check around ARX calls: suspected injection logged as a security event, strikes, blocking.
using ARX.SDK;
namespace ArxSamples.MongoDb;
/// <summary>Where the names (tables, attributes, match columns) in a call come from. It decides what an ARX1017 means.</summary>
public enum NameSource
{
/// <summary>A name built from what a user sent (a form field, a file header, a query string): ARX1017 is a suspected attack.</summary>
UserInput,
/// <summary>A name from your code or configuration: ARX1017 is a bug in your code, not an attack. Nobody is blocked.</summary>
Code,
}
/// <summary>What the guard did with a call.</summary>
public enum GuardOutcome
{
/// <summary>The call ran.</summary>
Done,
/// <summary>ARX refused the call with ARX1017; the event is logged and the user got a plain message.</summary>
Refused,
/// <summary>The user was already blocked: the call wasn't attempted.</summary>
Blocked,
}
/// <summary>One entry of the security log: a suspected attack, or a bug when the names came from code.</summary>
public sealed record SecurityEvent(DateTimeOffset At, string User, string Action, string Kind, string Detail);
/// <summary>The outcome, the call's result when it ran, and the message to show the user (never the detection details).</summary>
public sealed record GuardResult<T>(GuardOutcome Outcome, T? Value, string ForTheUser);
/// <summary>
/// A security check around ARX calls: the pattern to copy into an application, kept small so it can be read in one
/// go. It lets every ARX error through except <c>ARX1017 SuspectedInjection</c>, which it turns into:
/// <list type="bullet">
/// <item>an entry in the security log (<see cref="Events"/>), with the user, the action and ARX's message (the name is
/// escaped and cut to 64 characters by ARX, so it is safe to log);</item>
/// <item>a plain message for the user that doesn't say what was detected;</item>
/// <item>a strike against the user. After <c>blockAfter</c> strikes the user is blocked and their calls aren't attempted.
/// One strike never blocks anyone, so a single false positive only costs one refused request;</item>
/// <item>no strike when the names came from your own code (<see cref="NameSource.Code"/>): that's a bug to fix, logged
/// as one.</item>
/// </list>
/// A false positive (a harmless name such as a file header <c>E-mail (work)</c> flagged as SQL) is handled by triage:
/// read the event, fix the mapping that let the name through, and <see cref="Unblock"/> the user if needed.
/// In a real application the log goes to your SIEM or security monitoring and the strikes live in a shared store
/// (Redis, the database), not in memory.
/// </summary>
public sealed class SecurityGuard
{
private const string ATTACK = "suspected attack";
private const string BUG = "bug";
private readonly object _gate = new();
private readonly int _blockAfter;
private readonly Dictionary<string, int> _strikes = new(StringComparer.Ordinal);
private readonly List<SecurityEvent> _events = new();
public SecurityGuard(int blockAfter = 3)
{
_blockAfter = blockAfter;
}
/// <summary>The security log, oldest first.</summary>
public IReadOnlyList<SecurityEvent> Events
{
get
{
lock (_gate)
{
return _events.ToArray();
}
}
}
public bool IsBlocked(string user)
{
lock (_gate)
{
return _strikes.TryGetValue(user, out var strikes) && strikes >= _blockAfter;
}
}
/// <summary>After triage found the events were a false positive: clears the user's strikes (the log is kept).</summary>
public void Unblock(string user)
{
lock (_gate)
{
_strikes.Remove(user);
}
}
/// <summary>Runs <paramref name="call"/> for <paramref name="user"/> unless they are blocked, and handles ARX1017 as described above.</summary>
public async Task<GuardResult<T>> RunAsync<T>(string user, string action, NameSource names, Func<Task<T>> call)
{
if (IsBlocked(user))
{
return new GuardResult<T>(GuardOutcome.Blocked, default, "Your access is suspended. Contact support.");
}
try
{
return new GuardResult<T>(GuardOutcome.Done, await call(), "Done.");
}
catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
{
lock (_gate)
{
if (names == NameSource.Code)
{
_events.Add(new SecurityEvent(DateTimeOffset.UtcNow, user, action, BUG, error.Message));
return new GuardResult<T>(GuardOutcome.Refused, default, "Something went wrong on our side. It has been reported.");
}
_strikes[user] = _strikes.GetValueOrDefault(user) + 1;
_events.Add(new SecurityEvent(DateTimeOffset.UtcNow, user, action, ATTACK, error.Message));
}
return new GuardResult<T>(GuardOutcome.Refused, default, "The request couldn't be processed.");
}
}
}
Expected output
What dotnet run printed when the sample was verified, on a freshly created sample schema. Ids differ when the tables already hold rows.
> 1. A batch of changes passes through
ana: Done, 2 accounts changed
> 2. An attack in one item refuses the whole batch
mallory sees: "The request couldn't be processed."; log: suspected attack
> 3. Allowed fields stop unknown names before ARX
400 Bad Request: fields not allowed: credit_limit; security log still has 1 entries
> 4. A false positive from your own configuration
'website;': Refused, logged as a bug; job blocked: False
'website': Done