openlang

Несовпадение HMAC только на одном вебхуке (с акцентами в имени)

Написал небольшой чекер на Node (чистый JS, без сборки): он переигрывает сохранённые доставки вебхуков и проверяет HMAC-SHA256, прежде чем что-либо дальше по цепочке им доверится. Проходят две из трёх. evt_2044 от billing-partner падает, а evt_2051 от того же партнёра с тем же секретом проходит.

$ node main.js
staging         evt_1001  ok
billing-partner evt_2044  FAIL (digest)
billing-partner evt_2051  ok

Оба секрета ротировали на прошлой неделе, поэтому сначала я грешил на устаревший секрет. Но тогда и evt_2051 не прошёл бы. Единственное отличие, которое я вижу, — имя клиента в 2044: Zoë Ramírez, с акцентами.

Поэтому я заподозрил utf8 vs latin1 где-то в том, как payload попадает в createHmac. Пробовал менять кодировку, которую передаю в update(). Вообще никакой разницы.

Суть lib/verify.js:

const body = JSON.parse(delivery.raw);

// sign the payload, same as the partner docs describe
const payload = JSON.stringify(body);
const expected = crypto
  .createHmac("sha256", secret)
  .update(payload, "utf8")
  .digest("hex");

В доках партнёра сказано, что подпись считается по «JSON payload» (я почти уверен), так что не понимаю, что ещё мне нужно хешировать. Что я упускаю? И почему только это одно событие?

(Проверка реплея через toleranceSeconds — это TODO, не обращайте внимания.)

Переведено с английскогоПоказать оригинал

Разговор продолжается 7

Следуя доводам
  1. Ссылка на ответ

    Готов поспорить, что проблема в круге JSON.parse → JSON.stringify, а не в кодировке, которую вы передаёте в update(). Поэтому её смена ничего и не дала.

    HMAC считается от тех самых байтов, которые подписал партнёр. Когда вы парсите и заново сериализуете, вы подписываете свою сериализацию данных, а не их. Для обычных ASCII-payload'ов с компактным форматированием результат часто получается байт в байт одинаковым, так что evt_2051 и evt_1001 проходят по везению. В evt_2044 есть не-ASCII символы, и именно там сериализаторы расходятся. Если партнёр отдаёт Zo\u00eb Ram\u00edrez (с экранированием, как по умолчанию делают некоторые энкодеры в Java/PHP/Python), то JSON.stringify выдаст вам буквально Zoë Ramírez, и дайджест изменится. Бывает и наоборот. Порядок ключей, пробелы и формат чисел тоже могут отличаться, но диакритика лучше всего объясняет ваш симптом.

    Решение: хешируйте delivery.raw напрямую, не трогая.

    const expected = crypto
      .createHmac("sha256", secret)
      .update(delivery.raw)   // string or Buffer, exactly as received
      .digest("hex");

    JSON.parse вызывайте только после того, как подпись проверена. Если вы сохраняли доставки на диск, убедитесь также, что то, что их писало, не перекодировало и не нормализовало текст. Безопаснее всего хранить сырой Buffer.

    Раз уж вы всё равно там копаетесь: сравнивайте дайджесты через crypto.timingSafeEqual, а не через ===. Доки: https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b

    Надеюсь, поможет. Напишите, починила ли 2044 версия с raw.

    Переведено с английскогоПоказать оригинал
  2. Ссылка на ответ

    const payload = JSON.stringify(body);

    Проблема в этой строке. Партнёр подписал свои байты, а не то, что получается у вашего JSON.stringify. По моему опыту, "the JSON payload" в таких доках всегда значит сырое тело. Хешируйте delivery.raw как есть, а парсите после проверки.

    https://nodejs.org/api/crypto.html

    Переведено с английскогоПоказать оригинал
  3. Ссылка на ответ

    Согласен насчёт хеширования сырого тела, но я бы немного поспорил с "the JSON payload всегда значит сырое". В доках так говорят вскользь постоянно, наверное, поэтому tess.p на этом и споткнулся, нет?

    Одно замечание для реплей-чекера: если сохранённые доставки читали как utf8-строку и писали обратно, BOM или смена CRLF сломают дайджест точно так же. Я бы держал raw как Buffer от начала до конца. А если это когда-нибудь окажется за Express, то express.json() к моменту, когда вы увидите тело, его уже распарсит. Есть два способа обойти это:

    // option A: keep the bytes next to the parsed body
    app.use(express.json({
      verify: (req, _res, buf) => { req.rawBody = buf; },
    }));
    
    // option B: raw only on the webhook route, parse after verifying
    app.post("/hooks/billing", express.raw({ type: "application/json" }), (req, res) => {
      // req.body is a Buffer here
    });

    Я склоняюсь к B, потому что так сложнее случайно захешировать не то, но A менее инвазивен для существующего приложения. Не уверен, какой из них я бы защищал на ревью.

    Переведено с английскогоПоказать оригинал
  4. Ссылка на ответ

    Везение относилось к успешным проверкам, а не к падению. Экранирование объясняет 2044, но 2051 совпал только потому, что их пробелы и порядок ключей случайно совпали с JSON.stringify, и ничто этого не гарантировало.

    Переведено с английскогоПоказать оригинал

Другие стороны того же вопроса

  1. -race сообщает DATA RACE, хотя ack — это приём из небуферизованного канала. Виноват time.After?

    Может ли быть, что race detector здесь ошибается, или я неправильно понимаю, что мне даёт ack? Это pushgate, небольшой сервис…

    Ответы 4Последняя активность go
  2. memset перед free проходит с флагами по умолчанию, падает с OPT=-O2

    Делаю небольшое хранилище учётных данных на C, называется vault, собираю в WSL. vault_release() должна затирать секрет и пото…

    Ответы 6Последняя активность c
  3. Одна и та же проверка переполнения, разные ветки ошибки на -O0 и -O2

    Враждебный pack с count=65536 и rec_size=32768. На -O0 получаю "pack too large". На -O2 вместо этого получаю "out of memory".…

    Ответы 6Последняя активность c