Типизация — путь к безопасности

Последние несколько лет я работал с аннотацией типизации в Python в рамках нашего основного продукта в компании Flare Systems. Я обнаружил, что это замечательный инструмент для поддержки рефакторинга и повышения читабельности кода. В последнее время я исследовал, как мы можем сделать API более безопасным с помощью типов. В частности, я рассмотрю, как мы можем использовать аннотацию типов Python, чтобы сделать os.system безопасным.

В качестве отправной точки, текущим типом system является:

def system(command: StrOrBytesPath) -> int: ...
Войти в полноэкранный режим Выйти из полноэкранного режима

Этот тип является правильным. Ее преимущество в том, что она отлавливает любой вызов, не использующий строку, который обязательно завершится неудачей. Но это не проверяет злоупотребление, например, передачу несанированного пользовательского ввода. Например, кто-то может захотеть использовать ImageMagick для изменения размера изображения:

def resize(size: str):
  system(f"convert INPUT -resize {size} OUTPUT")

def api(request):
  resize(request.args["size"])
Войти в полноэкранный режим Выйти из полноэкранного режима

К сожалению, эта простая реализация внесла критический изъян в наше приложение. Пользователь мог использовать вредоносный размер, например $(echo hacked). Затем размер вставил бы себя в команду и выполнил бы следующую команду: convert INPUT -resize $(echo hacked) OUTPUT. Эта точная схема уязвимости очень распространена и по сей день.

Исправление так же просто, как и ошибка. shlex.quote можно использовать для того, чтобы убедиться, что строка используется в команде как единственный строковый токен. Тем не менее, в system нет явной проверки, чтобы убедиться, что команда экранировала пользовательский ввод.

К счастью, мы можем придумать, как это улучшить. Во-первых, мы можем разделить все типы на две категории: Безопасные и Небезопасные. Как видно, использование пользовательского ввода в качестве аргумента system небезопасно. Но передача литеральной строки должна быть несколько безопаснее. По крайней мере, литералы приводят к предсказуемому поведению. Если вы выполните литерал rm --no-preserve-root -rf /, вы можете предсказать, что он сотрет ваш диск.

Пользователи аннотаций по типизации, возможно, уже знакомы с типом Literal. В качестве краткого напоминания, тип литерал позволяет разработчикам вводить переменную с буквальным значением. Это полезно для функций, которые могут принимать только ограниченное количество известных буквальных значений. Например, system могла бы использовать его таким образом:

def system(command: Literal["ls"] | Literal["id"]): ...

system("ls")  # ok
system("id")  # ok

system("rm -rf")  # error!
system(request.args["size"])  # error!
Войти в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что на практике я обычно предпочитаю Enum. Это обычно приводит к более безопасному коду, так как он также проверяет правильность значения во время выполнения.

Приятным моментом в этой концепции является то, что проверка типов не позволит передать str, когда ожидается Literal. Большим ограничением является то, что Literal работает только с конкретным литералом. Нет способа установить тип переменной, чтобы она принимала любой литерал типа:

def system(command: Literal) -> int: ...  # error: Literal[...] must have at least one parameter
Войти в полноэкранный режим Выйти из полноэкранного режима

В нашем случае это довольно ограничивает возможности, поскольку мы хотим, чтобы system выполняла любую безопасную команду. До Python 3.10 не существовало встроенных способов иметь функцию, принимающую только аргументы Literal. То есть до того, как в Python 3.11 появился тип LiteralString. LiteralString позволяет переменной принимать только литеральные строки.

def system(command: LiteralString) -> int: ...

system("convert INPUT OUTPUT")  # ok
system(f"convert INPUT -resize {size} OUTPUT")  # error!
Вход в полноэкранный режим Выход из полноэкранного режима

На момент публикации, Mypy определяет LiteralString как псевдоним str. Таким образом, последняя версия Mypy с Python 3.11 не поймает ошибку в приведенных выше и ниже фрагментах.

Она по-прежнему ограничивает нас буквальными значениями. Возвращаясь к нашему примеру, мы хотим иметь возможность передавать размер изображения. На самом деле можно сделать size безопасным, санировав значение для использования в оболочке. Это обычные функции quote или escape, которые принимают пользовательский ввод и возвращают строки, безопасные для использования. Для оболочек в Python есть функция shlex.quote. Входные и выходные данные этих функций имеют разное свойство безопасности. Было бы интересно отразить это различие в типах:

ShellQuotedString = NewType("ShellQuotedString", str)

def quote(value: str) -> ShellQuotedString: ...
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы вводим новый тип, который включает свойство безопасности. Python включает инструмент NewType для легкого создания нового типа из существующего. Этот новый тип можно использовать везде, где используется базовое время, но не наоборот:

safe: ShellQuotedString = ShellQuotedString("This string is safe")
unsafe: str = safe  # ok
safe = unsafe  # error: Incompatible types in assignment
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь у нас есть оба типа безопасных данных: Буквальные и цитируемые данные. Для простоты использования мы можем присвоить обоим типам псевдоним enum:

ShellString = LiteralString | ShellQuotedString
Ввести полноэкранный режим Выйти из полноэкранного режима

Это перечисление — все безопасные типы команд, которые может выполнить система. Это гарантирует, что разработчик должен думать о цитировании пользовательского ввода, прежде чем передать его system.

def system(command: ShellString) -> int: ...

system("convert INPUT OUTPUT")  # ok
system("convert INPUT -resize {quote(size)} OUTPUT")  # error!
Войти в полноэкранный режим Выход из полноэкранного режима

Он по-прежнему не принимает наш аргумент размера в кавычках. Интересным свойством NewType является то, что любая операция, выполненная над ним, преобразует его обратно в базовый тип. Например, конкатенация str в ShellQuotedString вернет str. Это возлагает на разработчика API бремя определения набора безопасных операций. Если мы хотим предоставить операции для работы с нашими безопасными строками, мы должны их реализовать.

В нашем случае мы знаем, что конкатенация shell-safe строк создаст новую shell-safe строку. Поэтому мы можем использовать эту операцию как безопасный способ смешивания пользовательского ввода и литеральных значений.

def shell_format(
  format_string: str
  *args: ShellString,
  *kwargs: ShellString,
) -> ShellQuotedString:
  return ShellQuotedString(format_string.format(*args, **kwargs))

output_path = "/tmp/out"
shell_format(
  "convert INPUT --resize {} {}", 
  quote(size), 
  output_path
)  # ok

shell_format(
  "convert INPUT --resize {} {}", 
  size, 
  output_path
)  # error!
Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что такая реализация может оставить место для уязвимости безопасности. Использование функции типа shell_format("convert -resize '{}'", size) оставит size фактически без кавычек. Можно было бы добавить несколько дополнительных проверок, чтобы убедиться, что все литералы {} не окружены кавычками. Это отличный пример того, почему обычные операции со строками могут привести к небезопасному поведению, если их применить к нашим новым типам.

Теперь, когда у нас есть все необходимые операции и свойства безопасности, мы можем склеить все вместе:

Наш system API теперь доказал свою безопасность. Он должен отлавливать любые злоупотребления с несанированными значениями. Обратите внимание, что мы используем здесь ShellQuotedString вместо типа SafeString, который может быть использован для многих других случаев (цитирование SQL, html.escape и т.д.). Безопасность нашего типа относительна его использования. Возвращаемое значение html.escape безопасно для отображения без внедрения XSS. Однако это же значение может привести к SQL-инъекции, если использовать его в запросе как есть.

Добавление безопасности в типы может выходить за рамки экранирования или цитирования шаблонов. Типы могут подвергать статическому анализу большинство неявных предварительных условий. Например, статический файловый API, открывающий файл на основе пользовательского ввода, может определить тип SafePath. Затем функция может преобразовать str в SafePath после того, как проверит, что файл находится в определенном каталоге.

Мы увидели, что можем легко использовать типы для встраивания семантики в наш код. Аннотация типов в Python может сделать гораздо больше, чем просто предотвратить TypeError. Она может сделать предусловие явным и предотвратить критические уязвимости в безопасности.

Оцените статью
devanswers.ru
Добавить комментарий