PostgreSQL · Delete

Catch an SQL injection attempt on delete

Catching an SQL injection attempt on delete, in four scenarios. A table or match column that carries SQL is refused with ARX1017 SuspectedInjection before the database. SQL in the value to delete by, the classic ' OR '1'='1 that would empty a table when concatenated, is a parameter: it deletes nothing.

Set up for this page

The scenarios build on the sample application: 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).

Before scenario 1 the sample runs this, so the service and the rows the scenarios use are there:

Set up
var accounts = new List<ArxEntity>();
foreach (var number in new[] { "ACC-I301", "ACC-I302", "ACC-I303" })
{
    var account = new ArxEntity("account");
    account["name"] = "Injection: keep me";
    account["account_number"] = number;
    accounts.Add(account);
}
var ids = await service.CreateMultipleAsync(accounts);

1 A table name from the request carries SQL

DELETE /api/{type}/{id} with the type used as the table: the SQL in it is refused before the database.

Already in scope: service (ArxPgDbService), id (ArxRecordId)

C#
var type = "account WHERE 1=1; --";

try
{
    await service.DeleteAsync(type, id);
}
catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
{
    SecurityLog(error);
}
Used above

Defined once in this sample.

private static void SecurityLog(ArxValidationException error) => Console.WriteLine($"SECURITY: {error.Message}");

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT count(*)
FROM account
WHERE account_number IN ('ACC-I301', 'ACC-I302', 'ACC-I303');

Expect: 3: nothing was deleted.

2 SQL in the value to delete by deletes nothing

The classic: "delete the account with this number", and the user types ACC-I301' OR '1'='1. Concatenated into SQL it would delete every account; as a parameter it is an account number nobody has.

Already in scope: service (ArxPgDbService)

C#
var target = new ArxEntity("account");
target["account_number"] = "ACC-I301' OR '1'='1";

int deleted = await service.DeleteAsync(target, new ArxDeleteOptions { Match = ArxMatch.Key("account_number") });

Console.WriteLine($"{deleted} rows deleted");

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT count(*)
FROM account
WHERE account_number IN ('ACC-I301', 'ACC-I302', 'ACC-I303');

Expect: 3: the text was compared as a value, so it matched no account.

3 The column to delete by, from the request, carries SQL

The column to delete by comes from the request: the SQL in it is refused; only allow columns from a fixed list.

Already in scope: service (ArxPgDbService)

C#
var by = "name = name OR account_number";

var target = new ArxEntity("account");
target[by] = "x";

try
{
    await service.DeleteAsync(target, new ArxDeleteOptions { Match = ArxMatch.Columns(by) });
}
catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
{
    SecurityLog(error);
}
Used above

Defined once in this sample.

private static void SecurityLog(ArxValidationException error) => Console.WriteLine($"SECURITY: {error.Message}");

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT count(*)
FROM account
WHERE name = 'Injection: keep me';

Expect: 3.

4 DeleteMultiple with a table name carrying SQL

DeleteMultiple of the three ids with a table name carrying SQL: refused before the database, all three kept.

Already in scope: service (ArxPgDbService), ids (IReadOnlyList<ArxRecordId>)

C#
try
{
    await service.DeleteMultipleAsync("account\"; TRUNCATE account; --", ids.ToList());
}
catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
{
    SecurityLog(error);
}
Used above

Defined once in this sample.

private static void SecurityLog(ArxValidationException error) => Console.WriteLine($"SECURITY: {error.Message}");

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT count(*)
FROM account
WHERE name = 'Injection: keep me';

Expect: 3: every account is still there.

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

  1. .NET SDK: .NET SDK 10.0.100 or later 10.0.x (target net10.0).
  2. Create the project:
    Terminal
    dotnet new console -n ArxSample -f net10.0
    cd ArxSample
  3. Install the packages:
    Terminal
    dotnet add package ARX.SDK.PostgreSql --version 1.0.0-rc.1
  4. Database: a PostgreSQL 16 (tested with 16.15) database with the sample schema.
  5. Configuration: set ARX_SAMPLES_POSTGRESQL to its connection string. Nothing is hard-coded.
  6. Source code: add the files of the sample application this sample uses (SampleContext.cs, SampleOutput.cs, SampleContext.Application.cs, SampleCatalog.cs), then replace Program.cs and add the other file below, with the file names shown.
  7. Run it:
    Terminal
    dotnet run
  8. Expected output: below.

The files

Program.cs

Runs the sample. Generated for this page; it reads the connection string from the environment.

using ArxSamples.PostgreSql;
using ArxSamples.PostgreSql.Operations.Delete.RealCases;

// The connection string comes from the environment, never from the code (see "Configure the connection").
var connectionString = Environment.GetEnvironmentVariable("ARX_SAMPLES_POSTGRESQL")
    ?? throw new InvalidOperationException("Set the ARX_SAMPLES_POSTGRESQL environment variable to your PostgreSQL connection string.");

await new RealCase5_CatchAnInjectionAttempt().RunAsync(new SampleContext(connectionString));
RealCase5_CatchAnInjectionAttempt.cs

The sample, as it is in the repository.

using ARX.SDK;
using ARX.SDK.Services;
using static ArxSamples.PostgreSql.SampleOutput;

namespace ArxSamples.PostgreSql.Operations.Delete.RealCases;

/// <summary>
/// Catching an SQL injection attempt on delete, in four scenarios. A table or match column that carries SQL is refused
/// with ARX1017 SuspectedInjection before the database. SQL in the value to delete by, the classic
/// <c>' OR '1'='1</c> that would empty a table when concatenated, is a parameter: it deletes nothing.
/// </summary>
public sealed class RealCase5_CatchAnInjectionAttempt : ISample
{
    public string Name => "delete-real-5-catch-an-injection-attempt";

    public string Title => "Catch an SQL injection attempt on delete";

    public async Task RunAsync(SampleContext dbContext)
    {
        var accounts = new List<ArxEntity>();

        foreach (var number in new[] { "ACC-I301", "ACC-I302", "ACC-I303" })
        {
            var account = new ArxEntity("account");
            account["name"] = "Injection: keep me";
            account["account_number"] = number;
            accounts.Add(account);
        }

        var ids = await dbContext.Service.CreateMultipleAsync(accounts);

        await Scenario1_ATableNameFromTheRequestAsync(dbContext, ids[0]);

        await Scenario2_SqlInTheValueToDeleteByAsync(dbContext);

        await Scenario3_TheMatchColumnFromTheRequestAsync(dbContext);

        await Scenario4_DeleteMultipleWithABadTableAsync(dbContext, ids);
    }

    private static void SecurityLog(ArxValidationException error) => Show($"SECURITY: {error.Message}");

    /// <summary>1. <c>DELETE /api/{type}/{id}</c> with the type used as the table: the SQL in it is refused before the database.</summary>
    private static async Task Scenario1_ATableNameFromTheRequestAsync(SampleContext dbContext, ArxRecordId id)
    {
        Step("1. A table name from the request carries SQL");

        var type = "account WHERE 1=1; --";

        try
        {
            await dbContext.Service.DeleteAsync(type, id);
        }
        catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
        {
            SecurityLog(error);
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM account
         * WHERE account_number IN ('ACC-I301', 'ACC-I302', 'ACC-I303');
         * -- Expect: 3: nothing was deleted.
         * ===========================================================
         */
    }

    /// <summary>
    /// 2. The classic: "delete the account with this number", and the user types <c>ACC-I301' OR '1'='1</c>.
    /// Concatenated into SQL it would delete every account; as a parameter it is an account number nobody has.
    /// </summary>
    private static async Task Scenario2_SqlInTheValueToDeleteByAsync(SampleContext dbContext)
    {
        Step("2. SQL in the value to delete by deletes nothing");

        var target = new ArxEntity("account");
        target["account_number"] = "ACC-I301' OR '1'='1";

        int deleted = await dbContext.Service.DeleteAsync(target, new ArxDeleteOptions { Match = ArxMatch.Key("account_number") });

        Show($"{deleted} rows deleted");

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM account
         * WHERE account_number IN ('ACC-I301', 'ACC-I302', 'ACC-I303');
         * -- Expect: 3: the text was compared as a value, so it matched no account.
         * ===========================================================
         */
    }

    /// <summary>3. The column to delete by comes from the request: the SQL in it is refused; only allow columns from a fixed list.</summary>
    private static async Task Scenario3_TheMatchColumnFromTheRequestAsync(SampleContext dbContext)
    {
        Step("3. The column to delete by, from the request, carries SQL");

        var by = "name = name OR account_number";

        var target = new ArxEntity("account");
        target[by] = "x";

        try
        {
            await dbContext.Service.DeleteAsync(target, new ArxDeleteOptions { Match = ArxMatch.Columns(by) });
        }
        catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
        {
            SecurityLog(error);
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM account
         * WHERE name = 'Injection: keep me';
         * -- Expect: 3.
         * ===========================================================
         */
    }

    /// <summary>4. DeleteMultiple of the three ids with a table name carrying SQL: refused before the database, all three kept.</summary>
    private static async Task Scenario4_DeleteMultipleWithABadTableAsync(SampleContext dbContext, IReadOnlyList<ArxRecordId> ids)
    {
        Step("4. DeleteMultiple with a table name carrying SQL");

        try
        {
            await dbContext.Service.DeleteMultipleAsync("account\"; TRUNCATE account; --", ids.ToList());
        }
        catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
        {
            SecurityLog(error);
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM account
         * WHERE name = 'Injection: keep me';
         * -- Expect: 3: every account is still there.
         * ===========================================================
         */
    }
}

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.

Output
  > 1. A table name from the request carries SQL
      SECURITY: ARX1017 SuspectedInjection: Entity name 'account WHERE 1=1; --' looks like an SQL injection attempt (contains ';'). ARX refused it before sending anything to the database. Names must come from your code or a fixed list, never from user input.
  > 2. SQL in the value to delete by deletes nothing
      0 rows deleted
  > 3. The column to delete by, from the request, carries SQL
      SECURITY: ARX1017 SuspectedInjection: Attribute name 'name = name OR account_number' looks like an SQL injection attempt (contains '='). ARX refused it before sending anything to the database. Names must come from your code or a fixed list, never from user input.
  > 4. DeleteMultiple with a table name carrying SQL
      SECURITY: ARX1017 SuspectedInjection: Entity name 'account"; TRUNCATE account; --' looks like an SQL injection attempt (contains '"'). ARX refused it before sending anything to the database. Names must come from your code or a fixed list, never from user input.