SQL Server · Create

Catch an SQL injection attempt on create

Catching an SQL injection attempt on create, in four scenarios. ARX refuses a name (table or attribute) that carries SQL with its own code, ARX1017 SuspectedInjection, before anything is sent to the database, so you can log it as a security event instead of a bug. Values are never refused: they are parameters, and SQL in a value is just text.

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).

1 A field name from a form carries SQL

A form whose field names are copied straight into attributes (the mistake), and an attacker who adds a field named with SQL. The whole create is refused with ARX1017; a plain typo in a field name is ARX1003.

Already in scope: service (ArxMsSqlDbService)

C#
var posted = new Dictionary<string, object>
{
    ["first_name"] = "Mallory",
    ["last_name"] = "Injection-C1",
    ["notes\") VALUES ('x'); DROP TABLE contact; --"] = "x",
};

var typo = new Dictionary<string, object>
{
    ["first_name"] = "Ted",
    ["last name"] = "Typo-C1",
};

foreach (var form in new[] { posted, typo })
{
    var contact = new ArxEntity("contact");

    foreach (var field in form)
    {
        contact[field.Key] = field.Value;
    }

    try
    {
        await service.CreateAsync(contact);
    }
    catch (ArxValidationException error) when (error.Code is ArxErrorCode.SuspectedInjection or ArxErrorCode.InvalidIdentifier)
    {
        Report(error);
    }
}
Used above

Defined once in this sample.

private static void Report(ArxValidationException error)
{
    if (error.Code == ArxErrorCode.SuspectedInjection)
    {
        Console.WriteLine($"SECURITY: {error.Message}");
    }
    else
    {
        Console.WriteLine($"bug, not an attack: {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 contact
WHERE last_name = 'Injection-C1' OR first_name = 'Ted';

Expect: 0: neither contact was created, and the contact table is still there.

2 A table name from the request carries SQL

A table name taken from the request (?type=…). ARX refuses the SQL in it; the fix is to map the request's word to a table from a fixed list, so user input never becomes a name.

Already in scope: service (ArxMsSqlDbService)

C#
var requested = "contact; DROP TABLE account; --";

var fromTheRequest = new ArxEntity(requested);
fromTheRequest["first_name"] = "Mallory";
fromTheRequest["last_name"] = "Injection-C2";

try
{
    await service.CreateAsync(fromTheRequest);
}
catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
{
    Report(error);
}

// The fix: a fixed list. Anything else is refused by the application, before ARX.
string? table = requested switch
{
    "person" => "contact",
    "company" => "account",
    _ => null,
};

Console.WriteLine(table == null ? $"the allow-list refused type '{requested}'" : $"would create in {table}");
Used above

Defined once in this sample.

private static void Report(ArxValidationException error)
{
    if (error.Code == ArxErrorCode.SuspectedInjection)
    {
        Console.WriteLine($"SECURITY: {error.Message}");
    }
    else
    {
        Console.WriteLine($"bug, not an attack: {error.Message}");
    }
}

Check it in the database

SQL

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

SELECT CASE WHEN OBJECT_ID('dbo.account') IS NOT NULL THEN 1 ELSE 0 END AS account_exists,
       (SELECT count(*) FROM contact WHERE last_name = 'Injection-C2') AS created;

Expect: account_exists 1, created 0.

3 One bad row in an import refuses the whole CreateMultiple

An import of three contacts where one row's column header carries SQL: the whole call is refused, nothing is written.

Already in scope: service (ArxMsSqlDbService)

C#
var first = new ArxEntity("contact");
first["first_name"] = "Ann";
first["last_name"] = "Import-C3";

var bad = new ArxEntity("contact");
bad["first_name"] = "Bob";
bad["last_name\" text, \"x"] = "Import-C3";

var third = new ArxEntity("contact");
third["first_name"] = "Cy";
third["last_name"] = "Import-C3";

try
{
    await service.CreateMultipleAsync(new List<ArxEntity> { first, bad, third });
}
catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
{
    Report(error);
}
Used above

Defined once in this sample.

private static void Report(ArxValidationException error)
{
    if (error.Code == ArxErrorCode.SuspectedInjection)
    {
        Console.WriteLine($"SECURITY: {error.Message}");
    }
    else
    {
        Console.WriteLine($"bug, not an attack: {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 contact
WHERE last_name = 'Import-C3';

Expect: 0: Ann and Cy weren't created either.

4 SQL in a value is stored as plain text

SQL in a value isn't an attack on ARX: values are parameters, so it's stored exactly as typed and nothing is refused.

Already in scope: service (ArxMsSqlDbService)

C#
var contact = new ArxEntity("contact");
contact["first_name"] = "Robert'); DROP TABLE contact; --";
contact["last_name"] = "Value-C4";
var id = await service.CreateAsync(contact);

Console.WriteLine($"created contact {id}");

Check it in the database

SQL

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

SELECT first_name
FROM contact
WHERE last_name = 'Value-C4';

Expect: 1 row: Robert'); DROP TABLE contact; -- (stored as typed; the contact table 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.SQL --version 1.0.0-rc.1
  4. Database: a SQL Server 2022 (tested with 16.0.4225.2) database with the sample schema.
  5. Configuration: set ARX_SAMPLES_SQLSERVER 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.MSSQL;
using ArxSamples.MSSQL.Operations.Create.RealCases;

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

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

The sample, as it is in the repository.

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

namespace ArxSamples.MSSQL.Operations.Create.RealCases;

/// <summary>
/// Catching an SQL injection attempt on create, in four scenarios. ARX refuses a name (table or attribute) that carries
/// SQL with its own code, ARX1017 SuspectedInjection, before anything is sent to the database, so you can log it as a
/// security event instead of a bug. Values are never refused: they are parameters, and SQL in a value is just text.
/// </summary>
public sealed class RealCase8_CatchAnInjectionAttempt : ISample
{
    public string Name => "create-real-8-catch-an-injection-attempt";

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

    public async Task RunAsync(SampleContext dbContext)
    {
        await Scenario1_AFieldNameFromAFormAsync(dbContext);

        await Scenario2_ATableNameFromTheRequestAsync(dbContext);

        await Scenario3_OneBadRowInAnImportAsync(dbContext);

        await Scenario4_SqlInAValueIsJustTextAsync(dbContext);
    }

    /// <summary>What an application does with the two codes: an attack goes to the security log, a typo is a bug to fix.</summary>
    private static void Report(ArxValidationException error)
    {
        if (error.Code == ArxErrorCode.SuspectedInjection)
        {
            Show($"SECURITY: {error.Message}");
        }
        else
        {
            Show($"bug, not an attack: {error.Message}");
        }
    }

    /// <summary>
    /// 1. A form whose field names are copied straight into attributes (the mistake), and an attacker who adds a field
    /// named with SQL. The whole create is refused with ARX1017; a plain typo in a field name is ARX1003.
    /// </summary>
    private static async Task Scenario1_AFieldNameFromAFormAsync(SampleContext dbContext)
    {
        Step("1. A field name from a form carries SQL");

        var posted = new Dictionary<string, object>
        {
            ["first_name"] = "Mallory",
            ["last_name"] = "Injection-C1",
            ["notes\") VALUES ('x'); DROP TABLE contact; --"] = "x",
        };

        var typo = new Dictionary<string, object>
        {
            ["first_name"] = "Ted",
            ["last name"] = "Typo-C1",
        };

        foreach (var form in new[] { posted, typo })
        {
            var contact = new ArxEntity("contact");

            foreach (var field in form)
            {
                contact[field.Key] = field.Value;
            }

            try
            {
                await dbContext.Service.CreateAsync(contact);
            }
            catch (ArxValidationException error) when (error.Code is ArxErrorCode.SuspectedInjection or ArxErrorCode.InvalidIdentifier)
            {
                Report(error);
            }
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM contact
         * WHERE last_name = 'Injection-C1' OR first_name = 'Ted';
         * -- Expect: 0: neither contact was created, and the contact table is still there.
         * ===========================================================
         */
    }

    /// <summary>
    /// 2. A table name taken from the request (<c>?type=…</c>). ARX refuses the SQL in it; the fix is to map the request's
    /// word to a table from a fixed list, so user input never becomes a name.
    /// </summary>
    private static async Task Scenario2_ATableNameFromTheRequestAsync(SampleContext dbContext)
    {
        Step("2. A table name from the request carries SQL");

        var requested = "contact; DROP TABLE account; --";

        var fromTheRequest = new ArxEntity(requested);
        fromTheRequest["first_name"] = "Mallory";
        fromTheRequest["last_name"] = "Injection-C2";

        try
        {
            await dbContext.Service.CreateAsync(fromTheRequest);
        }
        catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
        {
            Report(error);
        }

        // The fix: a fixed list. Anything else is refused by the application, before ARX.
        string? table = requested switch
        {
            "person" => "contact",
            "company" => "account",
            _ => null,
        };

        Show(table == null ? $"the allow-list refused type '{requested}'" : $"would create in {table}");

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT CASE WHEN OBJECT_ID('dbo.account') IS NOT NULL THEN 1 ELSE 0 END AS account_exists,
         *        (SELECT count(*) FROM contact WHERE last_name = 'Injection-C2') AS created;
         * -- Expect: account_exists 1, created 0.
         * ===========================================================
         */
    }

    /// <summary>3. An import of three contacts where one row's column header carries SQL: the whole call is refused, nothing is written.</summary>
    private static async Task Scenario3_OneBadRowInAnImportAsync(SampleContext dbContext)
    {
        Step("3. One bad row in an import refuses the whole CreateMultiple");

        var first = new ArxEntity("contact");
        first["first_name"] = "Ann";
        first["last_name"] = "Import-C3";

        var bad = new ArxEntity("contact");
        bad["first_name"] = "Bob";
        bad["last_name\" text, \"x"] = "Import-C3";

        var third = new ArxEntity("contact");
        third["first_name"] = "Cy";
        third["last_name"] = "Import-C3";

        try
        {
            await dbContext.Service.CreateMultipleAsync(new List<ArxEntity> { first, bad, third });
        }
        catch (ArxValidationException error) when (error.Code == ArxErrorCode.SuspectedInjection)
        {
            Report(error);
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM contact
         * WHERE last_name = 'Import-C3';
         * -- Expect: 0: Ann and Cy weren't created either.
         * ===========================================================
         */
    }

    /// <summary>4. SQL in a value isn't an attack on ARX: values are parameters, so it's stored exactly as typed and nothing is refused.</summary>
    private static async Task Scenario4_SqlInAValueIsJustTextAsync(SampleContext dbContext)
    {
        Step("4. SQL in a value is stored as plain text");

        var contact = new ArxEntity("contact");
        contact["first_name"] = "Robert'); DROP TABLE contact; --";
        contact["last_name"] = "Value-C4";
        var id = await dbContext.Service.CreateAsync(contact);

        Show($"created contact {id}");

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT first_name
         * FROM contact
         * WHERE last_name = 'Value-C4';
         * -- Expect: 1 row: Robert'); DROP TABLE contact; -- (stored as typed; the contact table 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 field name from a form carries SQL
      SECURITY: ARX1017 SuspectedInjection: Attribute name 'notes") VALUES ('x'); DROP TABLE contact; --' 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.
      bug, not an attack: ARX1003 InvalidIdentifier: Attribute name 'last name' is not a valid identifier. Use ASCII letters, digits and underscores, starting with a letter or underscore, at most 128 characters. A column named otherwise is used through an alias declared in the schema catalog: .Alias(table, attribute, databaseName).
  > 2. A table name from the request carries SQL
      SECURITY: ARX1017 SuspectedInjection: Entity name 'contact; DROP TABLE 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.
      the allow-list refused type 'contact; DROP TABLE account; --'
  > 3. One bad row in an import refuses the whole CreateMultiple
      SECURITY: ARX1017 SuspectedInjection: Attribute name 'last_name" text, "x' 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. SQL in a value is stored as plain text
      created contact 1