W3docs

Лучшие практики безопасности в 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.

Приведённая ниже программа объединяет основные идеи: валидация по белому списку, растяжение пароля с солью, проверка за постоянное время и доказательство того, что два пользователя с одинаковым паролем получают разные хэши.

java— editable, runs on the server

Что следует вынести из запуска:

  • Белый список принимает alice_99, но отклоняет и Robert'); DROP TABLE, и слишком короткое ab, поэтому вредоносный или некорректный ввод никогда не попадает на следующий уровень.
  • Растяжение пароля даёт фиксированный 32-байтовый дайджест за 120 000 итераций — именно эта стоимость делает перебор хранимого хэша нецелесообразным.
  • verify возвращает true для правильного пароля и false для неверного, поскольку хэш-кандидат совпадает только при идентичном вводе.
  • Два разных пользователя, зарегистрировавших один и тот же пароль, получают неравные хэши (same input, equal hash? false), доказывая, что случайная соль для каждого пользователя выполняет свою задачу.
  • MessageDigest.isEqual возвращает true для идентичных байтов и false при изменении одного символа, обеспечивая сравнение за постоянное время без утечки через тайминг.

Практика

Практика
Почему пароли необходимо хранить с использованием медленной функции формирования ключа с солью, такой как PBKDF2, а не с одним раундом SHA-256?
Почему пароли необходимо хранить с использованием медленной функции формирования ключа с солью, такой как PBKDF2, а не с одним раундом SHA-256?
Was this page helpful?