🧩 Принципы и паттерны безопасной разработки: LSP
Часть 2.
Продолжаем разбор SOLID... на очереди — принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP). Формально он гласит: объекты подклассов должны быть способны заменить объекты базовых классов без нарушения корректности программы. Проще говоря, наследник не должен «ломать» контракт, установленный родителем — ни в предусловиях (не требовать большего), ни в постусловиях (не гарантировать меньшего), ни в инвариантах (не нарушать постоянных условий).
Что неочевидно для многих, благодаря дядюшке Мартину, так это то, что LSP является поведенческим принципом, а не структурным. Иными словами, чтобы нарушить его, наследование (да и ООП в целом) — вообще не нужно. Если у нас есть одна сущность, переопределяющая поведение другой, и при этом к ней можно обратиться, как к исходной — этого достаточно. Даже, если сущности — результат функциональной композиции, без каких-либо объектов в терминах ООП в целом. Пример того, как LSP выглядит в языках, не имеющих наследования, можно посмотреть тут.
С точки зрения безопасности, нарушение LSP — это прямая дорога к обходу защитных механизмов. Когда подкласс изменяет семантику безопасности базового класса (например, отключает проверку прав доступа или меняет логику валидации), код, написанный с расчётом на родительский контракт, начинает работать непредсказуемо. Это порождает логические уязвимости, которые сложно отловить статическим анализом, поскольку формально типы совместимы, а вот их поведение — нет.
Как правило, нарушения LSP могут повлечь за собой примерно любые уязвимости, так или иначе связанные с логикой работы приложения. «В природе», однако, чаще всего они относятся ко следующим категориям:
• CWE-264: Permissions, Privileges, and Access Controls
• CWE-284: Improper Access Control
• CWE-290: Authentication Bypass by Spoofing
• CWE-703: Improper Check or Handling of Exceptional Conditions
🐛 Жизненное
CVE-2025-22223 — Spring Security (Authentication Bypass by Spoofing).
Когда аннотация безопасности (@PreAuthorize) размещена на методе обобщённого суперкласса или интерфейса, и конкретный подкласс переопределяет этот метод, механизм UniqueSecurityAnnotationScanner не находит аннотацию на переопределённом методе. Причина -- использование targetClass.getDeclaredMethod(method.getName(), method.getParameterTypes()) для поиска, что не работает после стирания типов (type erasure): сигнатура родительского метода Object mutate(Object) не совпадает с сигнатурой конкретной реализации AccountSecret mutate(AccountSecret).
public abstract class BaseService {
@PreAuthorize("hasRole('ADMIN')")
public abstract T getResource(Long id);
}
// Подкласс -- Spring Security не видит аннотацию
public class UserService extends BaseService {
@Override
public User getResource(Long id) {
return userRepo.findById(id);
}
}
Нарушение LSP здесь в том, что родительский тип объявляет контракт «этот метод требует роли ADMIN». Подкласс переопределяет метод, и контракт безопасности молча пропадает. При подстановке подкласса вместо родителя предусловие (авторизация) ослабляется.
Патч (коммит dc2e1af). Наивный поиск getDeclaredMethod заменен на итерацию по всем методам класса с разрешением обобщённых типов.
❗️Что делать?
• Помечайте security-critical классы как final|sealed или явно контролируйте наследование.
• Аннотации безопасности дублируйте на каждом переопределяющем методе.
• Фильтруйте и определяйте сущности по их типу, а не по имени.
• Соблюдайте явным образом все поведенческие контракты переопределяемых методов.
• Обеспечивайте инварианты объекта даже при аварийном завершении конструктора или инициализации.
⚠ TL;DR: В целом, общий принцип один: если ваш код принимает сущность по ссылке на базовую реализацию, он неявно доверяет поведенческому контракту этой сущности. Убедитесь, что это обосновано — особенно на границах доверия между компонентами системы.
Часть 2.
Продолжаем разбор SOLID... на очереди — принцип подстановки Барбары Лисков (Liskov Substitution Principle, LSP). Формально он гласит: объекты подклассов должны быть способны заменить объекты базовых классов без нарушения корректности программы. Проще говоря, наследник не должен «ломать» контракт, установленный родителем — ни в предусловиях (не требовать большего), ни в постусловиях (не гарантировать меньшего), ни в инвариантах (не нарушать постоянных условий).
Что неочевидно для многих, благодаря дядюшке Мартину, так это то, что LSP является поведенческим принципом, а не структурным. Иными словами, чтобы нарушить его, наследование (да и ООП в целом) — вообще не нужно. Если у нас есть одна сущность, переопределяющая поведение другой, и при этом к ней можно обратиться, как к исходной — этого достаточно. Даже, если сущности — результат функциональной композиции, без каких-либо объектов в терминах ООП в целом. Пример того, как LSP выглядит в языках, не имеющих наследования, можно посмотреть тут.
С точки зрения безопасности, нарушение LSP — это прямая дорога к обходу защитных механизмов. Когда подкласс изменяет семантику безопасности базового класса (например, отключает проверку прав доступа или меняет логику валидации), код, написанный с расчётом на родительский контракт, начинает работать непредсказуемо. Это порождает логические уязвимости, которые сложно отловить статическим анализом, поскольку формально типы совместимы, а вот их поведение — нет.
Как правило, нарушения LSP могут повлечь за собой примерно любые уязвимости, так или иначе связанные с логикой работы приложения. «В природе», однако, чаще всего они относятся ко следующим категориям:
• CWE-264: Permissions, Privileges, and Access Controls
• CWE-284: Improper Access Control
• CWE-290: Authentication Bypass by Spoofing
• CWE-703: Improper Check or Handling of Exceptional Conditions
🐛 Жизненное
CVE-2025-22223 — Spring Security (Authentication Bypass by Spoofing).
Когда аннотация безопасности (@PreAuthorize) размещена на методе обобщённого суперкласса или интерфейса, и конкретный подкласс переопределяет этот метод, механизм UniqueSecurityAnnotationScanner не находит аннотацию на переопределённом методе. Причина -- использование targetClass.getDeclaredMethod(method.getName(), method.getParameterTypes()) для поиска, что не работает после стирания типов (type erasure): сигнатура родительского метода Object mutate(Object) не совпадает с сигнатурой конкретной реализации AccountSecret mutate(AccountSecret).
public abstract class BaseService {
@PreAuthorize("hasRole('ADMIN')")
public abstract T getResource(Long id);
}
// Подкласс -- Spring Security не видит аннотацию
public class UserService extends BaseService {
@Override
public User getResource(Long id) {
return userRepo.findById(id);
}
}
Нарушение LSP здесь в том, что родительский тип объявляет контракт «этот метод требует роли ADMIN». Подкласс переопределяет метод, и контракт безопасности молча пропадает. При подстановке подкласса вместо родителя предусловие (авторизация) ослабляется.
Патч (коммит dc2e1af). Наивный поиск getDeclaredMethod заменен на итерацию по всем методам класса с разрешением обобщённых типов.
❗️Что делать?
• Помечайте security-critical классы как final|sealed или явно контролируйте наследование.
• Аннотации безопасности дублируйте на каждом переопределяющем методе.
• Фильтруйте и определяйте сущности по их типу, а не по имени.
• Соблюдайте явным образом все поведенческие контракты переопределяемых методов.
• Обеспечивайте инварианты объекта даже при аварийном завершении конструктора или инициализации.
⚠ TL;DR: В целом, общий принцип один: если ваш код принимает сущность по ссылке на базовую реализацию, он неявно доверяет поведенческому контракту этой сущности. Убедитесь, что это обосновано — особенно на границах доверия между компонентами системы.