blob: ee5e072b301c1f81c5a25be0a5edff0fe87cf325 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
SQL Security Utilities Module
Provides SQL identifier validation, escaping, and safe query building utilities
to prevent SQL injection attacks.
"""
import re
from collections.abc import Mapping, Sequence
from .logger import get_logger
from .security import (
AuthContext,
get_current_auth_context,
set_current_auth_context,
)
from .security import (
mcp_auth_context_var as auth_context_var, # noqa: F401
)
logger = get_logger(__name__)
class SQLSecurityError(Exception):
"""Exception raised for SQL security validation failures"""
pass
class SQLSecurityUtils:
"""
SQL Security Utilities for preventing SQL injection attacks.
Provides:
- Identifier validation (database names, table names, column names)
- Safe identifier quoting with backticks
- Safe table reference building
- Auth context retrieval from context variables
"""
# Valid SQL identifier pattern: letters, numbers, underscores
# Must start with letter or underscore, not a number
# Supports Unicode letters for international database/table names
IDENTIFIER_PATTERN = re.compile(
r"^[a-zA-Z_\u4e00-\u9fff][a-zA-Z0-9_\u4e00-\u9fff]*$"
)
# Maximum identifier length (MySQL/Doris standard)
MAX_IDENTIFIER_LENGTH = 64
# SQL reserved keywords that should be quoted
SQL_KEYWORDS = {
"SELECT",
"FROM",
"WHERE",
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"CREATE",
"ALTER",
"TABLE",
"DATABASE",
"INDEX",
"VIEW",
"AND",
"OR",
"NOT",
"NULL",
"TRUE",
"FALSE",
"IN",
"LIKE",
"BETWEEN",
"JOIN",
"LEFT",
"RIGHT",
"INNER",
"OUTER",
"ON",
"AS",
"ORDER",
"BY",
"GROUP",
"HAVING",
"LIMIT",
"OFFSET",
"UNION",
"ALL",
"DISTINCT",
"INTO",
"VALUES",
"SET",
"DEFAULT",
"PRIMARY",
"KEY",
"FOREIGN",
"REFERENCES",
"CHECK",
"UNIQUE",
"CONSTRAINT",
}
RULE_OPERATORS = {
"=",
"!=",
"<>",
"<",
">",
"<=",
">=",
"LIKE",
"NOT LIKE",
}
NULL_RULE_OPERATORS = {"IS NULL", "IS NOT NULL"}
@classmethod
def validate_identifier(cls, name: str, identifier_type: str = "identifier") -> str:
"""
Validate a SQL identifier (database name, table name, column name, etc.)
Args:
name: The identifier to validate
identifier_type: Type description for error messages (e.g., "database name", "table name")
Returns:
The validated identifier (unchanged if valid)
Raises:
SQLSecurityError: If the identifier is invalid
"""
if not name:
raise SQLSecurityError(f"Empty {identifier_type} is not allowed")
if not isinstance(name, str):
raise SQLSecurityError(
f"Invalid {identifier_type}: must be a string, got {type(name).__name__}"
)
# Strip whitespace
name = name.strip()
if not name:
raise SQLSecurityError(f"Empty {identifier_type} is not allowed")
# Check length
if len(name) > cls.MAX_IDENTIFIER_LENGTH:
raise SQLSecurityError(
f"Invalid {identifier_type}: '{name[:20]}...' exceeds maximum length of {cls.MAX_IDENTIFIER_LENGTH} characters"
)
# Check for dangerous characters that could be SQL injection
dangerous_chars = ["'", '"', ";", "--", "/*", "*/", "\\", "\x00"]
for char in dangerous_chars:
if char in name:
raise SQLSecurityError(
f"Invalid {identifier_type}: '{name}' contains forbidden character '{char}'"
)
# Validate pattern
if not cls.IDENTIFIER_PATTERN.match(name):
raise SQLSecurityError(
f"Invalid {identifier_type}: '{name}' contains invalid characters. "
f"Only letters, numbers, and underscores are allowed, and must start with a letter or underscore."
)
logger.debug(f"Validated {identifier_type}: {name}")
return name
@classmethod
def quote_identifier(cls, name: str, identifier_type: str = "identifier") -> str:
"""
Safely quote a SQL identifier using backticks.
Args:
name: The identifier to quote
identifier_type: Type description for error messages
Returns:
The quoted identifier (e.g., `table_name`)
Raises:
SQLSecurityError: If the identifier is invalid
"""
# First validate the identifier
validated_name = cls.validate_identifier(name, identifier_type)
# Escape any backticks within the name (double them)
escaped_name = validated_name.replace("`", "``")
return f"`{escaped_name}`"
@classmethod
def build_table_reference(
cls,
table_name: str,
db_name: str | None = None,
catalog_name: str | None = None,
quote: bool = True,
) -> str:
"""
Build a safe, fully-qualified table reference.
Args:
table_name: The table name (required)
db_name: The database name (optional)
catalog_name: The catalog name (optional)
quote: Whether to quote identifiers with backticks (default: True)
Returns:
A safe table reference string (e.g., `catalog`.`db`.`table`)
Raises:
SQLSecurityError: If any identifier is invalid
"""
parts = []
if catalog_name:
if quote:
parts.append(cls.quote_identifier(catalog_name, "catalog name"))
else:
parts.append(cls.validate_identifier(catalog_name, "catalog name"))
if db_name:
if quote:
parts.append(cls.quote_identifier(db_name, "database name"))
else:
parts.append(cls.validate_identifier(db_name, "database name"))
if quote:
parts.append(cls.quote_identifier(table_name, "table name"))
else:
parts.append(cls.validate_identifier(table_name, "table name"))
return ".".join(parts)
@classmethod
def build_column_reference(
cls, column_name: str, table_name: str | None = None, quote: bool = True
) -> str:
"""
Build a safe column reference.
Args:
column_name: The column name (required)
table_name: The table name (optional, for qualified references)
quote: Whether to quote identifiers with backticks (default: True)
Returns:
A safe column reference string (e.g., `table`.`column`)
Raises:
SQLSecurityError: If any identifier is invalid
"""
parts = []
if table_name:
if quote:
parts.append(cls.quote_identifier(table_name, "table name"))
else:
parts.append(cls.validate_identifier(table_name, "table name"))
if quote:
parts.append(cls.quote_identifier(column_name, "column name"))
else:
parts.append(cls.validate_identifier(column_name, "column name"))
return ".".join(parts)
@classmethod
def validate_and_build_where_condition(
cls, column_name: str, operator: str = "=", use_param: bool = True
) -> tuple[str, bool]:
"""
Build a safe WHERE condition for a column.
Args:
column_name: The column name
operator: The comparison operator (=, !=, <, >, <=, >=, LIKE, IN)
use_param: Whether to use parameterized placeholder (%s)
Returns:
Tuple of (condition_string, needs_param)
e.g., ("`column` = %s", True) or ("`column` = DATABASE()", False)
Raises:
SQLSecurityError: If column name is invalid or operator is not allowed
"""
# Validate column name
quoted_column = cls.quote_identifier(column_name, "column name")
# Validate operator
allowed_operators = {"=", "!=", "<>", "<", ">", "<=", ">=", "LIKE", "IN", "IS"}
if operator.upper() not in allowed_operators:
raise SQLSecurityError(
f"Invalid operator: '{operator}'. Allowed: {allowed_operators}"
)
if use_param:
return f"{quoted_column} {operator} %s", True
else:
return f"{quoted_column} {operator}", False
@staticmethod
def validate_integer(
value: object,
value_name: str,
*,
minimum: int,
maximum: int,
) -> int:
"""Return an integer that is safe to embed in SQL grammar positions."""
if isinstance(value, bool) or not isinstance(value, int):
raise SQLSecurityError(f"Invalid {value_name}: must be an integer")
if not minimum <= value <= maximum:
raise SQLSecurityError(
f"Invalid {value_name}: must be between {minimum} and {maximum}"
)
return value
@classmethod
def build_rule_predicate(
cls,
rule: Mapping[str, object],
) -> tuple[str, tuple[object, ...]]:
"""Build one parameterized business-rule predicate.
Raw SQL fragments are intentionally unsupported. Callers provide a column,
an allowlisted operator, and a value (or values for ``IN``).
"""
if "sql_condition" in rule:
raise SQLSecurityError(
"Raw sql_condition rules are not supported; use column/operator/value"
)
column_name = rule.get("column")
if not isinstance(column_name, str):
raise SQLSecurityError("Business rule column must be a string")
column = cls.quote_identifier(column_name, "column name")
operator = str(rule.get("operator", "=")).strip().upper()
if operator in cls.NULL_RULE_OPERATORS:
return f"{column} {operator}", ()
if operator == "IN":
values = rule.get("values")
if (
not isinstance(values, Sequence)
or isinstance(values, str | bytes)
or not values
or len(values) > 100
):
raise SQLSecurityError(
"IN rule values must be a non-empty sequence of at most 100 items"
)
placeholders = ", ".join("%s" for _ in values)
return f"{column} IN ({placeholders})", tuple(values)
if operator not in cls.RULE_OPERATORS:
allowed = sorted(cls.RULE_OPERATORS | cls.NULL_RULE_OPERATORS | {"IN"})
raise SQLSecurityError(
f"Invalid business rule operator: {operator!r}; allowed: {allowed}"
)
if "value" not in rule:
raise SQLSecurityError("Business rule value is required")
return f"{column} {operator} %s", (rule["value"],)
@staticmethod
def get_auth_context() -> AuthContext | None:
"""
Get auth_context from the context variable.
This retrieves the auth_context that was set by the HTTP middleware
during request processing.
Returns:
The auth_context object, or None if not available
"""
try:
auth_context = get_current_auth_context()
if auth_context:
logger.debug("Retrieved auth_context from context variable")
return auth_context
except Exception as e:
logger.debug(f"Could not retrieve auth_context: {e}")
return None
@staticmethod
def set_auth_context(auth_context: AuthContext) -> None:
"""
Set auth_context in the context variable.
This is typically called by the HTTP middleware during request processing.
Args:
auth_context: The auth_context object to set
"""
set_current_auth_context(auth_context)
logger.debug("Set auth_context in context variable")
# Convenience functions for direct use
validate_identifier = SQLSecurityUtils.validate_identifier
quote_identifier = SQLSecurityUtils.quote_identifier
build_table_reference = SQLSecurityUtils.build_table_reference
build_column_reference = SQLSecurityUtils.build_column_reference
build_rule_predicate = SQLSecurityUtils.build_rule_predicate
validate_integer = SQLSecurityUtils.validate_integer
get_auth_context = SQLSecurityUtils.get_auth_context
set_auth_context = SQLSecurityUtils.set_auth_context