1

I'm working on a .NET application that needs to execute SQL parameterized by database objects like tables or columns. The application supports both the Microsoft SQL Server and Oracle ADO.NET providers, in an environment where it cannot assume anything about the database. DB object names can come from anywhere, usually the DB itself. As far as I am aware, there is no nice way to use parameters where an identifier is expected without using PL/SQL which I'd like to avoid due to extra overhead in terms of maintenance and such.

Using the Oracle ADO.NET provider, here is a simple but somewhat contrived example of what I mean:

using (OracleCommand cmd = connection.CreateCommand())
{
    // Potential SQL injection vulnerability?
    cmd.CommandText = $"SELECT id, name FROM {tableName} WHERE name = :name";
    // Unfortunately not valid :(
    //cmd.CommandText = $"SELECT id, name FROM :tableName WHERE name = :name";
    //cmd.Parameters.Add("tableName", tableName);
    cmd.Parameters.Add("name", name);
    return cmd.ExecuteReader();
}

Here is actual code I've written using Oracle PL/SQL:

// Wish we could use this:
//const string cmdText = "ALTER SESSION SET CURRENT_SCHEMA = :schemaName"

// Or perhaps something like this:
//const string cmdText = $"ALTER SESSION SET CURRENT_SCHEMA = {Something.Validate(schemaName)}"

// Alas:
const string cmdText = @"
DECLARE
    v_schema_name VARCHAR2(128);
BEGIN
    BEGIN
        -- Try literal schema name first (handles quoted schemas)
        v_schema_name := dbms_assert.schema_name(:schemaName);
    EXCEPTION WHEN OTHERS THEN
        -- Fall back to uppercase version for unquoted schemas
        v_schema_name := dbms_assert.schema_name(UPPER(:schemaName));
    END;
    
    EXECUTE IMMEDIATE 'ALTER SESSION SET CURRENT_SCHEMA=""' || v_schema_name || '""';
END;";

using (OracleCommand cmd = connection.CreateCommand())
{
    cmd.CommandText = cmdText;
    cmd.Parameters.Add("schemaName", schemaName);
    cmd.ExecuteNonQuery();
}

A moderator on this site kindly flagged this question as a duplicate of How to Safely Parameterize Table Names in C# to prevent SQL Injection? which brought to my attention that SqlCommandBuilder.QuoteIdentifier can be used with SQL Server to somewhat safely insert identifiers dynamically, but only when the server's collation is case-insensitive. Otherwise, it seems fairly easy to validate an SQL Server identifier based on the naming rules for "regular identifiers", except for checking reserved words:

The identifier must not be a Transact-SQL reserved word. SQL Server reserves both the uppercase and lowercase versions of reserved words. [...] The words that are reserved depend on the database compatibility level. This level can be set by using the ALTER DATABASE compatibility level statement.

This question was also flagged as a duplicate of How to check for valid oracle table name using sql/plsql, again which I'd like to avoid due to the extra overhead I demonstrated earlier. Oracle identifier rules are even more complicated as they depend on the DB version, the character set the DB was installed with, and also cannot be reserved words. Naturally I could just validate the Oracle identifier without taking character encoding into account (by assuming Unicode), but then I'm worried about encoding-related vulnerabilities that are beyond my comprehension.

So the question is: Are my fears unfounded? If not, how do I properly validate (unquoted) identifiers?


Here is my preliminary and untested validation code for reference (there are probably errors but this is just to demonstrate my naive approach to identifier validation):

using System;
using System.Collections.Generic;
using System.Text;

namespace DbProviderAbstractions
{
    public abstract class DbProvider
    {
        /// <summary>
        /// Validates whether the given identifier can be used unquoted.
        /// </summary>
        /// <param name="identifier">The identifier to validate.</param>
        /// <returns>
        /// <c>true</c> if <paramref name="identifier"/> is a valid unquoted identifier,
        /// <c>false</c> if <paramref name="identifier"/> must be quoted.
        /// </returns>
        /// <remarks>
        /// If <paramref name="identifier"/> is already quoted,
        /// this method shall return <c>false</c>
        /// (an identifier containing quotes is not a valid unquoted identifier).
        /// </remarks>
        public abstract bool Validate(string identifier);

        /// <summary>
        /// Throw if a given identifier is not valid as an unquoted identifier.
        /// </summary>
        /// <param name="identifier">The identifier to validate.</param>
        /// <returns>The unmodified identifier.</returns>
        /// <exception cref="ArgumentOutOfRangeException">When the identifier contains special characters that must be quoted.</exception>
        public string Unquoted(string identifier)
        {
            return Validate(identifier) ? identifier : throw new ArgumentOutOfRangeException("Nooooooo!");
        }
    }

    public class SqlProvider : DbProvider
    {
        /// <summary>
        /// SQL Server reserved words as of SQL Server 2022.
        /// </summary>
        public static readonly ISet<string> ReservedWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "ABSOLUTE", "ACTION", "ADA", "ADD", "ADMIN", "AFTER", "AGGREGATE", "ALIAS", "ALL", "ALLOCATE", "ALTER", "AND", "ANY", "ARE", "ARRAY", "AS", "ASC", "ASENSITIVE", "ASSERTION", "ASYMMETRIC", "AT", "ATOMIC", "AUTHORIZATION", "AVG",
            "BACKUP", "BEFORE", "BEGIN", "BETWEEN", "BINARY", "BIT", "BIT_LENGTH", "BLOB", "BOOLEAN", "BOTH", "BREADTH", "BREAK", "BROWSE", "BULK", "BY",
            "CALL", "CALLED", "CARDINALITY", "CASCADE", "CASCADED", "CASE", "CAST", "CATALOG", "CHAR", "CHAR_LENGTH", "CHARACTER", "CHARACTER_LENGTH", "CHECK", "CHECKPOINT", "CLASS", "CLOB", "CLOSE", "CLUSTERED", "COALESCE", "COLLATE", "COLLATION", "COLLECT", "COLUMN", "COMMIT", "COMPLETION", "COMPUTE", "CONDITION", "CONNECT", "CONNECTION", "CONSTRAINT", "CONSTRAINTS", "CONSTRUCTOR", "CONTAINS", "CONTAINSTABLE", "CONTINUE", "CONVERT", "CORR", "CORRESPONDING", "COUNT", "COVAR_POP", "COVAR_SAMP", "CREATE", "CROSS", "CUBE", "CUME_DIST", "CURRENT", "CURRENT_CATALOG", "CURRENT_DATE", "CURRENT_DEFAULT_TRANSFORM_GROUP", "CURRENT_PATH", "CURRENT_ROLE", "CURRENT_SCHEMA", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_TRANSFORM_GROUP_FOR_TYPE", "CURRENT_USER", "CURSOR", "CYCLE",
            "DATA", "DATABASE", "DATE", "DAY", "DBCC", "DEALLOCATE", "DEC", "DECIMAL", "DECLARE", "DEFAULT", "DEFERRABLE", "DEFERRED", "DELETE", "DENY", "DEPTH", "DEREF", "DESC", "DESCRIBE", "DESCRIPTOR", "DESTROY", "DESTRUCTOR", "DETERMINISTIC", "DIAGNOSTICS", "DICTIONARY", "DISCONNECT", "DISK", "DISTINCT", "DISTRIBUTED", "DOMAIN", "DOUBLE", "DROP", "DUMP", "DYNAMIC",
            "EACH", "ELEMENT", "ELSE", "END", "END-EXEC", "EQUALS", "ERRLVL", "ESCAPE", "EVERY", "EXCEPT", "EXCEPTION", "EXEC", "EXECUTE", "EXISTS", "EXIT", "EXTERNAL", "EXTRACT",
            "FALSE", "FETCH", "FILE", "FILLFACTOR", "FILTER", "FIRST", "FLOAT", "FOR", "FOREIGN", "FORTRAN", "FOUND", "FREE", "FREETEXT", "FREETEXTTABLE", "FROM", "FULL", "FULLTEXTTABLE", "FUNCTION", "FUSION",
            "GENERAL", "GET", "GLOBAL", "GO", "GOTO", "GRANT", "GROUP", "GROUPING",
            "HAVING", "HOLD", "HOLDLOCK", "HOST", "HOUR",
            "IDENTITY", "IDENTITY_INSERT", "IDENTITYCOL", "IF", "IGNORE", "IMMEDIATE", "IN", "INCLUDE", "INDEX", "INDICATOR", "INITIALIZE", "INITIALLY", "INNER", "INOUT", "INPUT", "INSENSITIVE", "INSERT", "INT", "INTEGER", "INTERSECT", "INTERSECTION", "INTERVAL", "INTO", "IS", "ISOLATION", "ITERATE",
            "JOIN",
            "KEY", "KILL",
            "LABEL", "LANGUAGE", "LARGE", "LAST", "LATERAL", "LEADING", "LEFT", "LESS", "LEVEL", "LIKE", "LIKE_REGEX", "LIMIT", "LINENO", "LN", "LOAD", "LOCAL", "LOCALTIME", "LOCALTIMESTAMP", "LOCATOR", "LOWER",
            "MAP", "MATCH", "MAX", "MEMBER", "MERGE", "METHOD", "MIN", "MINUTE", "MOD", "MODIFIES", "MODIFY", "MODULE", "MONTH", "MULTISET",
            "NAMES", "NATIONAL", "NATURAL", "NCHAR", "NCLOB", "NEW", "NEXT", "NO", "NOCHECK", "NONCLUSTERED", "NONE", "NORMALIZE", "NOT", "NULL", "NULLIF", "NUMERIC",
            "OBJECT", "OCCURRENCES_REGEX", "OCTET_LENGTH", "OF", "OFF", "OFFSETS", "OLD", "ON", "ONLY", "OPEN", "OPENDATASOURCE", "OPENQUERY", "OPENROWSET", "OPENXML", "OPERATION", "OPTION", "OR", "ORDER", "ORDINALITY", "OUT", "OUTER", "OUTPUT", "OVER", "OVERLAPS", "OVERLAY",
            "PAD", "PARAMETER", "PARAMETERS", "PARTIAL", "PARTITION", "PASCAL", "PATH", "PERCENT", "PERCENT_RANK", "PERCENTILE_CONT", "PERCENTILE_DISC", "PIVOT", "PLAN", "POSITION", "POSITION_REGEX", "POSTFIX", "PRECISION", "PREFIX", "PREORDER", "PREPARE", "PRESERVE", "PRIMARY", "PRINT", "PRIOR", "PRIVILEGES", "PROC", "PROCEDURE", "PUBLIC",
            "RAISERROR", "RANGE", "READ", "READS", "READTEXT", "REAL", "RECONFIGURE", "RECURSIVE", "REF", "REFERENCES", "REFERENCING", "REGR_AVGX", "REGR_AVGY", "REGR_COUNT", "REGR_INTERCEPT", "REGR_R2", "REGR_SLOPE", "REGR_SXX", "REGR_SXY", "REGR_SYY", "RELATIVE", "RELEASE", "REPLICATION", "RESTORE", "RESTRICT", "RESULT", "RETURN", "RETURNS", "REVERT", "REVOKE", "RIGHT", "ROLE", "ROLLBACK", "ROLLUP", "ROUTINE", "ROW", "ROWCOUNT", "ROWGUIDCOL", "ROWS", "RULE",
            "SAVE", "SAVEPOINT", "SCHEMA", "SCOPE", "SCROLL", "SEARCH", "SECOND", "SECTION", "SECURITYAUDIT", "SELECT", "SEMANTICKEYPHRASETABLE", "SEMANTICSIMILARITYDETAILSTABLE", "SEMANTICSIMILARITYTABLE", "SENSITIVE", "SEQUENCE", "SESSION", "SESSION_USER", "SET", "SETS", "SETUSER", "SHUTDOWN", "SIMILAR", "SIZE", "SMALLINT", "SOME", "SPACE", "SPECIFIC", "SPECIFICTYPE", "SQL", "SQLCA", "SQLCODE", "SQLERROR", "SQLEXCEPTION", "SQLSTATE", "SQLWARNING", "START", "STATE", "STATEMENT", "STATIC", "STATISTICS", "STDDEV_POP", "STDDEV_SAMP", "STRUCTURE", "SUBMULTISET", "SUBSTRING", "SUBSTRING_REGEX", "SUM", "SYMMETRIC", "SYSTEM", "SYSTEM_USER",
            "TABLE", "TABLESAMPLE", "TEMPORARY", "TERMINATE", "TEXTSIZE", "THAN", "THEN", "TIME", "TIMESTAMP", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", "TO", "TOP", "TRAILING", "TRAN", "TRANSACTION", "TRANSLATE", "TRANSLATE_REGEX", "TRANSLATION", "TREAT", "TRIGGER", "TRIM", "TRUE", "TRUNCATE", "TRY_CONVERT", "TSEQUAL",
            "UESCAPE", "UNDER", "UNION", "UNIQUE", "UNKNOWN", "UNNEST", "UNPIVOT", "UPDATE", "UPDATETEXT", "UPPER", "USAGE", "USE", "USER", "USING",
            "VALUE", "VALUES", "VAR_POP", "VAR_SAMP", "VARCHAR", "VARIABLE", "VARYING", "VIEW",
            "WAITFOR", "WHEN", "WHENEVER", "WHERE", "WHILE", "WIDTH_BUCKET", "WINDOW", "WITH", "WITHIN", "WITHIN GROUP", "WITHOUT", "WORK", "WRITE", "WRITETEXT",
            "XMLAGG", "XMLATTRIBUTES", "XMLBINARY", "XMLCAST", "XMLCOMMENT", "XMLCONCAT", "XMLDOCUMENT", "XMLELEMENT", "XMLEXISTS", "XMLFOREST", "XMLITERATE", "XMLNAMESPACES", "XMLPARSE", "XMLPI", "XMLQUERY", "XMLSERIALIZE", "XMLTABLE", "XMLTEXT", "XMLVALIDATE",
            "YEAR",
            "ZONE",
        };

        /// <inheritdoc/>
        public override bool Validate(string identifier)
        {
            char first = identifier[0];
            bool validFirstChar = char.IsLetter(first)
                || first == '_'
                || first == '@'
                || first == '#';

            bool invalid = string.IsNullOrEmpty(identifier)
                || ReservedWords.Contains(identifier)
                || Encoding.Unicode.GetByteCount(identifier) > 128
                || !validFirstChar;

            if (invalid)
            {
                return false;
            }

            foreach (char c in identifier)
            {
                if (char.IsLetterOrDigit(c))
                {
                    continue;
                }

                switch (c)
                {
                    case '@':
                    case '$':
                    case '#':
                    case '_':
                        continue;
                }

                return false;
            }

            return true;
        }
    }

    public class OracleProvider : DbProvider
    {
        /// <summary>
        /// Oracle reserved words as of 19c.
        /// </summary>
        public static readonly ISet<string> ReservedWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "AT",
            "BEGIN", "BETWEEN", "BY",
            "CASE", "CHECK", "CLUSTERS", "CLUSTER", "COLAUTH", "COLUMNS", "COMPRESS", "CONNECT", "CRASH", "CREATE", "CURSOR",
            "DECLARE", "DEFAULT", "DESC", "DISTINCT", "DROP",
            "ELSE", "END", "EXCEPTION", "EXCLUSIVE",
            "FETCH", "FOR", "FROM", "FUNCTION",
            "GOTO", "GRANT", "GROUP",
            "HAVING",
            "IDENTIFIED", "IF", "IN", "INDEX", "INDEXES", "INSERT", "INTERSECT", "INTO", "IS",
            "LIKE", "LOCK",
            "MINUS", "MODE",
            "NOCOMPRESS", "NOT", "NOWAIT", "NULL",
            "OF", "ON", "OPTION", "OR", "ORDER", "OVERLAPS",
            "PROCEDURE", "PUBLIC",
            "RESOURCE", "REVOKE",
            "SELECT", "SHARE", "SIZE", "SQL", "START", "SUBTYPE",
            "TABAUTH", "TABLE", "THEN", "TO", "TYPE",
            "UNION", "UNIQUE", "UPDATE",
            "VALUES", "VIEW", "VIEWS",
            "WHEN", "WHERE", "WITH",
        };

        /// <inheritdoc/>
        public override bool Validate(string identifier)
        {
            bool invalid = string.IsNullOrEmpty(identifier)
                || ReservedWords.Contains(identifier)
                || Encoding.Unicode.GetByteCount(identifier) > 128
                || !char.IsLetter(identifier[0]);

            if (invalid)
            {
                return false;
            }

            foreach (char c in identifier)
            {
                if (char.IsLetterOrDigit(c))
                {
                    continue;
                }

                switch (c)
                {
                    case '_':
                    case '$':
                    case '#':
                        continue;
                }

                return false;
            }

            return true;
        }
    }
}
12
  • 1
    I would say use the database dictionary to validate the identifier, only accepts the ones the user has access to, this way will prevent sql injection, limit the access to valid identifiers only and bonus, to only what the connected user has access to, even if it is just the app user. Commented Oct 6 at 20:30
  • Okey, but where does tableName comes from? User input? Or some combobox whitelist? Usually very few application has a fully dynamic table set, so you should likely know the number of allowed tables, no? Commented Oct 6 at 20:35
  • 1
    Dynamic SQL is not automatically bad. Imagine you have a data warehouse application with 10000+ tables. Making all statements static is just impossible. Commented Oct 7 at 6:17
  • 1
    Have a look at DBMS_ASSERT it should provide all needed functions. Commented Oct 7 at 6:22
  • 1
    Within Oracle, you can use the query BEGIN OPEN :cursor FOR 'SELECT id, name FROM ' || DBMS_ASSERT.SQL_OBJECT_NAME(:table_name) || ' WHERE name = :name' USING :name; END; with 3 bind variables: the cursor to return the result set; the table name; and the name to filter your result set. fiddle Commented Oct 7 at 7:48

2 Answers 2

2

how do I properly validate (unquoted) identifiers?

If you are going to dynamically generate queries then use the DBMS_ASSERT package to assert that identifiers are valid.

You can use the same SELECT query, just replace the string concatenation in C# with equivalent string concatenation within the database and call the DBMS_ASSERT.SQL_OBJECT_NAME function to verify that the identifier is a valid SQL object name before concatenating:

using (OracleCommand cmd = connection.CreateCommand())
{
  cmd.CommandText = $"
    BEGIN
      OPEN :cursor FOR
        'SELECT id, name
         FROM ' || DBMS_ASSERT.SQL_OBJECT_NAME(:table_name) || '
         WHERE name = :name'
      USING :name;
    END;";
  cmd.BindByName = true;
  cmd.Parameters.Add("cursor", OracleDbType.RefCursor).Direction = ParameterDirection.Output;
  cmd.Parameters.Add("table_name", tableName);
  cmd.Parameters.Add("name", name);
}

Oracle fiddle

Note: C# code is untested but it should give you the general idea.


DB object names can come from anywhere

If your identifiers can come from anywhere then they could be any valid string.

  • It is valid to name a table or column using a reserved word; however, you will not be able to use reserved words as unquoted identifiers so you MUST use quoted identifiers to address them.
  • Similarly, the Oracle Database Object Naming Rules allow the use of any characters (except double quote and the null characters) within object names but you must then use quoted identifiers (unless you abide by the much narrower restrictions for unquoted identifiers).

Therefore, if you want to validate ANY valid identifier you must use quoted identifiers (which means your identifiers must have the correct case).

A quoted identifier:

  • Starts and ends with a double quote ".
  • Does not contain double quote " or null \0 characters.

That is all you need to check for quoted identifiers. An identifier that matches the pattern for a quoted identifier will be parsed as a single token within the SQL and should not pose an SQL injection vulnerability.

If you want to validate a identifier within the database and ensure that it is quoted in the query then use DBMS_ASSERT.ENQUOTE_NAME. (The code can be similar to above, replacing DBMS_ASSERT.SQL_OBJECT_NAME with DBMS_ASSERT.ENQUOTE_NAME.)


If you must restrict your input to unquoted identifiers then the Oracle Database Object Naming Rules state:

  1. Nonquoted identifiers must begin with an alphabetic character from your database character set. [...]
  2. Nonquoted identifiers can only contain alphanumeric characters from your database character set and the underscore (_), dollar sign ($), and pound sign (#). Database links can also contain periods (.) and "at" signs (@). Oracle strongly discourages you from using $ and # in nonquoted identifiers.

So, if you want to only allow the ASCII character set then the identifiers should match [a-zA-Z][a-zA-Z0-9_$#]* (extending the regular expression to include alphabetic characters in other character sets is left as an exercise to the reader, as appropriate to their situation).

You should not need to check for reserved words with schema and table identifiers (see below for column identifiers) - instead, run the query and ensure you have appropriate error handling to catch exceptions if the query is malformed (as it would be if a reserved word is erroneously used in place of an unquoted identifier).


If you are dynamically generating unquoted column identifiers that may include reserved words then prefix the identifier with the table name/alias so that the query fails to parse.

Compare the behaviour of trying to filter on a column named NULL:

  • SELECT * FROM table_name WHERE NULL IS NULL
    

    Runs, returning all rows

  • SELECT * FROM table_name t WHERE t.NULL IS NULL
    

    Fails with a syntax error

  • SELECT * FROM table_name t WHERE t."NULL" IS NULL
    

    or:

    SELECT * FROM table_name WHERE "NULL" IS NULL
    

    Filters the query on the column named NULL

fiddle

Without the table alias preceding the dynamic identifier, if the identifier was NULL, LEVEL, SYSDATE or USER (maybe some others) then the query run because it is syntactically correct but would give an incorrect result (and probably expose you to an SQL injection vulnerability). With the preceding alias the query will not parse and an exception will be raised. However, in this case where you have reserved words as identifiers, you really should be using quoted identifiers.

Don't try to use unquoted identifiers in dynamic queries if you know that the rest of your system allows values that are inappropriate to use as unquoted identifiers. In that case, use quoted identifiers everywhere in your dynamic queries and ensure that your underlying data has the correct schema/table/column names (including correct cases).

Sign up to request clarification or add additional context in comments.

3 Comments

Awesome answer, one question: For the last method, does TRUE, FALSE, NULL etc. really not need to be checked? I feel like it can't be good if someone substitutes NULL (a syntactically valid identifier except for being a reserved word) for col3 in $"SELECT col1, col2, FROM someTable WHERE {col3} IS NULL" or similar...
@fff If you have dynamic columns (as opposed to tables or schema) then, yes, you might need to do more sanity checking if you are using unquoted identifiers. You probably need to filter out LEVEL, NULL, SYSDATE and USER; most (all?) of the other reserved words will generate a syntax error if you try using them as a column name. (Note: Oracle SQL does not have TRUE and FALSE literals, they exist in the PL/SQL scope but not in the SQL scope). However, none of those checks are required if you use quoted identifiers (which you really should be using).
@fff Alternatively, generate your SQL so that the column name is prefixed with the table alias: SELECT t.col1, t.col2 FROM sometable t WHERE t.{col3} IS NULL fiddle Then if you do use a reserved word you will get a syntax error.
0

The primary point of parameterized queries is improved security. Your fears are overblown, you are doing it correctly. The thing to avoid is constructing your own strings that go directly to the DB. Obviously, if a table name is expected here, you've got a limited subset of possible values. Limit the selection to those values and you are golden.

3 Comments

You're probably right that my fears are overblown. With the validation code I provided, I cannot imagine how SQL could be injected without whitespace, but then someone more creative than me could probably figure something out.
@fff If you want an example of SQL injection without whitespaces then see security.stackexchange.com/a/252475/261465
Rats! I was hoping it would be that simple. Still, that query uses several other special characters that are filtered out with the naive identifier validation, and there is absolutely no way to inject SQL with only Unicode letters, digits, '@', '#', and '_', right? Right?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.