Лучшие практики безопасности в Java
Типичные уязвимости Java и способы защиты: валидация ввода, десериализация, зависимости, управление секретами.
Большинство уязвимостей в Java-коде не являются экзотическими. Это пропущенная проверка ввода, SQL-запрос, построенный конкатенацией строк, пароль, хранящийся в виде простого хэша, или секрет, закоммиченный в Git. В этой главе рассматриваются средства защиты, которые позволяют предотвратить большинство реальных атак: проверяйте всё, что пересекает границу доверия, никогда не строите запросы через конкатенацию, хэшируйте пароли с помощью медленной функции формирования ключа, храните секреты вне кода и запускайте задачи с минимально необходимыми привилегиями.
Валидация ввода по белому списку
Первое правило — считать весь внешний ввод враждебным, пока не доказано обратное: параметры запросов, имена файлов, заголовки, полезные нагрузки сообщений — всё, что пересекает границу доверия. Предпочитайте белый список (принимать только известные допустимые форматы) чёрному списку (пытаться заблокировать плохие) — чёрный список всегда что-нибудь пропустит.
// Allowlist: only lowercase letters, digits and underscore, 3–16 chars.
static boolean isValidUsername(String s) {
return s != null && s.matches("[a-z0-9_]{3,16}");
}
// Constrain numbers to a sane range instead of trusting the caller.
int page = Math.clamp(requested, 1, 1000);Выполняйте валидацию на границе системы и повторно на каждой более глубокой границе, которую вы не контролируете. Отклоняйте как можно раньше, завершайте работу в безопасном состоянии и возвращайте обобщённую ошибку, чтобы не раскрывать правило валидации злоумышленнику, зондирующему ваш endpoint. При сопоставлении с шаблоном привязывайте его и делайте простым — смотрите введение в регулярные выражения, где объясняется, как matches проверяет всю строку, а не только её фрагмент.
Используйте подготовленные запросы, а не конкатенацию строк
SQL-инъекция по-прежнему остаётся одной из наиболее распространённых и разрушительных веб-уязвимостей, и в Java её очень легко предотвратить. Строите запросы с параметрами привязки через PreparedStatement; драйвер отправляет шаблон запроса и значения отдельно, поэтому пользовательские данные никогда не будут разобраны как SQL.
// NEVER do this — user input becomes part of the query text.
String bad = "SELECT * FROM users WHERE name = '" + name + "'";
// Do this — the value is bound, not concatenated.
String sql = "SELECT id FROM users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) process(rs.getLong("id"));
}
}То же самое применимо и за пределами SQL: используйте параметризованные API для LDAP, команд ОС (ProcessBuilder со списком аргументов, а не строкой оболочки) и любых шаблонов, смешивающих код с данными. Подробнее о JDBC см. PreparedStatement и введение в JDBC.
Хэшируйте пароли с помощью медленной KDF
Пароли нельзя хранить в открытом виде или за быстрым хэшем, таким как один раунд SHA-256 — современные GPU перебирают миллиарды таких хэшей в секунду. Используйте намеренно медленную функцию формирования ключа с солью. JDK поставляется с PBKDF2; Argon2 и bcrypt — отличные сторонние варианты.
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
byte[] salt = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(salt); // unique per user
var spec = new PBEKeySpec(password, salt, 600_000, 256); // iterations, key bits
var skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = skf.generateSecret(spec).getEncoded();
spec.clearPassword(); // wipe the secret| Подход | Вердикт |
|---|---|
| Открытый текст / обратимый | Никогда |
| MD5, SHA-1, одиночный SHA-256 | Слишком быстро — не подходит для паролей |
| PBKDF2 / bcrypt / Argon2 с солью для каждого пользователя | Правильно |
| Одна соль для всех пользователей | Сводит на нет смысл соли |
Всегда сравнивайте хэши с помощью проверки за постоянное время (MessageDigest.isEqual), чтобы время ответа не раскрывало, насколько правильна угадываемая часть.
Храните секреты вне кода
API-ключи, пароли баз данных и ключи подписи не должны находиться в исходных файлах — однажды закоммиченные, они останутся в истории Git навсегда. Читайте их из переменных окружения или менеджера секретов во время выполнения, и не допускайте попадания учётных данных в логи и сообщения об исключениях.
String dbPassword = System.getenv("DB_PASSWORD");
if (dbPassword == null || dbPassword.isBlank()) {
throw new IllegalStateException("DB_PASSWORD is not configured");
}
// Hold short-lived secrets in char[]/byte[] and wipe them, not String,
// because String is immutable and lingers in the heap until GC.Используйте SecureRandom (а не java.util.Random) для всего, что связано с безопасностью, — токенов, солей, одноразовых значений, идентификаторов сессий. Random предсказуем и поддаётся инициализации, что делает его вывод угадываемым.
Применяйте принцип наименьших привилегий и безопасные настройки по умолчанию
Предоставляйте каждому компоненту только тот доступ, который ему необходим, и ничего сверх: пользователь базы данных только для чтения на путях чтения, учётная запись службы, ограниченная одним бакетом, разрешения на файлы, исключающие группу и всех остальных. Проверяйте TLS-сертификаты (никогда не отключайте верификацию имени хоста «чтобы заработало»), устанавливайте таймауты на каждый сетевой вызов и ограничивайте размер всего, что вы разбираете, чтобы избежать атак на отказ в обслуживании через чрезмерно большой ввод или десериализацию.
// Never deserialize untrusted bytes with Java's native serialization.
// Prefer a data format you can validate (JSON/Protobuf) and bound its size.
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // fail fast, don't hang
.build();Поддерживайте зависимости в актуальном состоянии — большинство взломов эксплуатируют известный CVE в старой библиотеке, поэтому запускайте сканер (OWASP Dependency-Check, mvn versions:display-dependency-updates) в CI.
Приведённая ниже программа объединяет основные идеи: валидация по белому списку, растяжение пароля с солью, проверка за постоянное время и доказательство того, что два пользователя с одинаковым паролем получают разные хэши.
Что следует вынести из запуска:
- Белый список принимает
alice_99, но отклоняет иRobert'); DROP TABLE, и слишком короткоеab, поэтому вредоносный или некорректный ввод никогда не попадает на следующий уровень. - Растяжение пароля даёт фиксированный 32-байтовый дайджест за 120 000 итераций — именно эта стоимость делает перебор хранимого хэша нецелесообразным.
verifyвозвращаетtrueдля правильного пароля иfalseдля неверного, поскольку хэш-кандидат совпадает только при идентичном вводе.- Два разных пользователя, зарегистрировавших один и тот же пароль, получают неравные хэши (
same input, equal hash? false), доказывая, что случайная соль для каждого пользователя выполняет свою задачу. MessageDigest.isEqualвозвращаетtrueдля идентичных байтов иfalseпри изменении одного символа, обеспечивая сравнение за постоянное время без утечки через тайминг.