Как делать11 октября 2026 г.9 мин чтения
API и интеграцииШаг 6 из 8

Безопасность интеграций: ключи, подписи, минимальные права

Коротко

Каждая интеграция — это новый вход в систему и новый ключ, который может утечь. Базовый набор защит: отдельный ключ с минимальными правами на каждую интеграцию и окружение, плановая ротация с перекрытием старого и нового ключа, проверка подписи, свежести и схемы каждого входящего события, запросы по чужим URL только после проверки адреса (SSRF) и журналы без токенов и персональных данных. Данные партнёра проверяются так же строго, как ввод пользователя.

ОсновыУровень: СреднийБезопасность интеграций

После материала вы сможете

  • составить модель угроз интеграции и для каждой угрозы назвать защиту: ключ, права, подпись, схема, SSRF-фильтр, журнал
  • спланировать ротацию ключа интеграции с перекрытием, при котором партнёр не получает отказов
  • принять входящее событие партнёра в правильном порядке: размер, подпись, свежесть, схема zod, дубликаты
  • защитить исходящий запрос по URL от пользователя от SSRF: схема, порт, IP после DNS, запрет редиректов, таймаут и лимит размера

Проверено на

Node.js 24 LTS (24.18)zod 4.6OWASP API Security Top 10 (2023)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Модель угроз интеграции
  2. 02Ключи: хранение, ротация, отдельные ключи
  3. 03Минимальные права, mTLS и IP allowlist
  4. 04Входящие данные: подпись, свежесть, схема, размер
  5. 05Исходящие запросы: SSRF и allowlist
  6. 06Логи без секретов и персональных данных
  7. 07Чек-лист перед запуском интеграции

Модель угроз интеграции

Интеграция — это любой обмен данными с внешней системой: оплата, CRM, 1С, рассылка, загрузка аватара по ссылке, вебхук от маркетплейса. У каждой есть три точки, где что-то может пойти не так: ключ, которым вы подписываетесь при обращении к партнёру; входящие данные, которые партнёр присылает вам; и исходящие запросы, которые ваш сервер делает по чьему-то адресу. К ним добавляется четвёртая — журналы, куда всё это записывается.

OWASP выделяет эту тему отдельным пунктом списка API Security Top 10 (2023) — API10, «Unsafe Consumption of APIs»: разработчики склонны доверять данным от сторонних API больше, чем вводу пользователя, и ослабляют проверки. Атакующему проще взломать слабого партнёра и прислать через него вредные данные, чем атаковать вас напрямую. Там же, под номером API7, — SSRF: сервер ходит по URL, который подставил пользователь.

Утечка ключа

Пример
Ключ CRM в репозитории, в логе или во фронтенде
Защита
Хранилище секретов, отдельные ключи, ротация, маскирование в логах

Слишком широкие права

Пример
Ключ «администратора» для выгрузки одного отчёта
Защита
Минимальный scope, отдельный сервисный аккаунт на интеграцию

Поддельное входящее событие

Пример
Кто-то шлёт «заказ оплачен» на ваш адрес вебхука
Защита
Подпись HMAC, метка времени, mTLS или IP allowlist

Вредные данные от партнёра

Пример
Лишние поля, SQL в строке, огромный ответ, редирект на чужой сервер
Защита
Строгая схема, лимиты размера и времени, запрет редиректов

SSRF

Пример
Пользователь указывает адрес вебхука http://169.254.169.254/…
Защита
Проверка схемы, порта и IP после DNS, сетевая изоляция

Утечка через журналы

Пример
В логе токен, телефон и e-mail клиента
Защита
Структурированный лог с вырезанием секретов и маскированием
Что может пойти не так и какая защита за это отвечает
  1. 01Партнёр
  2. 02Приёмник событий
  3. 03Ваша система
  4. 04Исходящий клиент
  5. 05Чужой URL
Входящие данные и исходящие запросы — две разные границы доверия, проверяются обе

Ключи: хранение, ротация, отдельные ключи

Где хранить секреты, как не допустить их попадания в git и бандл и что делать при утечке, подробно разобрано в уроке о секретах и переменных окружения. Для интеграций к этому добавляются три правила.

  • Отдельный ключ на каждую интеграцию. Шпаргалка OWASP по управлению секретами отмечает: когда сервисы делят один секрет, источник утечки трудно установить. Ключ CRM для сайта и ключ CRM для фонового воркера — это два ключа: утёк один — отзываете один.
  • Отдельные ключи на окружения. Тестовый стенд работает с sandbox-ключом партнёра, production — со своим. Ключ production не попадает на компьютеры разработчиков и в тестовые CI-задачи.
  • Владелец и срок. Для каждого ключа записано, кто его выпустил, где он используется, когда истекает и когда ротировался. OWASP рекомендует журналировать создание, использование, ротацию и удаление секретов.

Ротация — плановая замена ключа, чтобы украденная копия работала недолго. OWASP советует автоматизировать её или хотя бы описать процесс, потому что ручная замена часто ломает интеграцию. Чтобы обойтись без простоя, ключи меняют с перекрытием — какое-то время принимаются оба:

  1. Выпустить новый ключ

    У партнёра или в вашем хранилище появляется второй действующий ключ. Старый пока работает.

  2. Принимать оба

    Приёмник проверяет подпись сначала новым, затем старым ключом — так работает acceptPartnerEvent ниже с массивом [current, previous].

  3. Переключить отправителя

    Ваш исходящий клиент и партнёр начинают подписывать новым ключом. Мониторинг показывает, каким ключом пришли последние запросы.

  4. Отозвать старый

    Старый ключ удаляется из конфигурации и отзывается у партнёра. Тест ниже показывает: после этого подпись старым ключом даёт 401.

Минимальные права, mTLS и IP allowlist

Принцип минимальных привилегий для интеграции означает: у каждого подключения — свой сервисный аккаунт или OAuth-клиент, и ему выдано ровно то, что нужно. Воркеру выгрузки отчётов — только чтение отчётов, форме заявок на сайте — только создание лида, без чтения базы клиентов. Если партнёр поддерживает OAuth 2.0, используйте client credentials с узким scope; как это выглядит в коде, показано в уроке об OAuth 2.0 и OpenID Connect.

Права проверяются и в обратную сторону: что партнёр может сделать с вашим API. Отдельный ключ партнёра не должен открывать административные эндпоинты, а действия по чужим объектам проверяются так же, как для пользователя: в API Security Top 10 это API1 и API5 — нарушение авторизации на уровне объекта и функции.

Подпись HMAC

Что даёт
Доказывает, что тело отправил владелец общего секрета, и не изменилось
Когда уместен
Почти всегда для вебхуков и событий
Ограничения
Общий секрет хранится у обеих сторон; нужна защита от повтора

mTLS (взаимный TLS)

Что даёт
Клиент предъявляет свой сертификат, сервер проверяет его при соединении
Когда уместен
Банки, B2B-обмен, сервис-сервис внутри компании; когда партнёр это поддерживает
Ограничения
Выпуск и ротация сертификатов, настройка на прокси

IP allowlist

Что даёт
Отсекает запросы не с адресов партнёра ещё до приложения
Когда уместен
Партнёр публикует стабильный список адресов
Ограничения
Не заменяет подпись: адреса облаков общие и меняются; список нужно обновлять

OAuth client credentials

Что даёт
Короткоживущий токен с узким scope вместо вечного ключа
Когда уместен
Партнёр выступает сервером авторизации
Ограничения
Секрет клиента всё равно нужно хранить и ротировать

Входящие данные: подпись, свежесть, схема, размер

Входящее событие проверяется по цепочке, и порядок важен: дешёвые проверки — раньше дорогих, подпись — до разбора JSON, бизнес-логика — только после схемы. Подробно подпись HMAC, повторную доставку и идемпотентность разбирает урок о вебхуках; здесь акцент на том, что идёт после подписи: подпись доказывает, что событие прислал партнёр, но не то, что его содержимое безопасно.

  1. Размер

    Тело больше лимита отклоняется с 413 до любых вычислений: огромный JSON — простой способ занять память и процессор (API4 — неограниченное потребление ресурсов).

  2. Подпись

    HMAC-SHA256 по метке времени и сырому телу, сравнение за постоянное время через timingSafeEqual. Принимаются текущий и предыдущий ключи — для ротации.

  3. Свежесть

    Метка времени не старше пяти минут: перехваченный запрос нельзя переслать завтра.

  4. Схема

    Строгая схема: точные типы, форматы, диапазоны, перечисления допустимых значений, запрет лишних полей. Ошибка схемы — 422 с перечнем полей.

  5. Дубликаты

    Событие с уже обработанным id не выполняется второй раз. В продакшне — уникальный ключ в таблице, а не Set в памяти.

bash
mkdir integration-security && cd integration-security
npm init -y && npm pkg set type=module
npm install zod@4.6.6
Проект для примеров; zod — единственная зависимость
partner-event.mjs
// Incoming event from a partner: size -> signature -> freshness -> schema -> duplicates.
// The signature is HMAC-SHA256 over "<timestamp>.<raw body>" with a shared secret.
import { createHmac, timingSafeEqual } from "node:crypto";
import * as z from "zod";

const MAX_BODY_BYTES = 64 * 1024;
const TOLERANCE_SECONDS = 300;

const OrderPaid = z.strictObject({
  id: z.string().regex(/^evt_[A-Za-z0-9]{8,32}$/),
  type: z.literal("order.paid"),
  data: z.strictObject({
    orderId: z.string().regex(/^[0-9]{1,12}$/),
    amount: z.number().int().positive().max(100_000_000),
    currency: z.enum(["KZT", "USD"]),
  }),
});

export class RejectedEvent extends Error {
  name = "RejectedEvent";
  constructor(status, reason) {
    super(reason);
    this.status = status;
  }
}

export function sign(secret, timestamp, rawBody) {
  return createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
}

function signatureMatches(secret, timestamp, rawBody, received) {
  const expected = Buffer.from(sign(secret, timestamp, rawBody), "hex");
  const got = Buffer.from(received, "hex");
  return got.length === expected.length && timingSafeEqual(got, expected);
}

const seen = new Set(); // in production: a table with a unique key on the event id

// secrets: [current, previous] — both are accepted while a key is being rotated
export function acceptPartnerEvent(rawBody, headers, secrets, now = Date.now()) {
  if (rawBody.length > MAX_BODY_BYTES) throw new RejectedEvent(413, "body too large");

  const timestamp = headers["x-partner-timestamp"] ?? "";
  const signature = headers["x-partner-signature"] ?? "";
  if (!/^\d{10}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) {
    throw new RejectedEvent(401, "missing or malformed signature headers");
  }
  if (!secrets.filter(Boolean).some((s) => signatureMatches(s, timestamp, rawBody, signature))) {
    throw new RejectedEvent(401, "bad signature");
  }
  if (Math.abs(now / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    throw new RejectedEvent(401, "stale timestamp");
  }

  let json;
  try {
    json = JSON.parse(rawBody.toString("utf8"));
  } catch {
    throw new RejectedEvent(400, "invalid JSON");
  }
  const parsed = OrderPaid.safeParse(json);
  if (!parsed.success) {
    throw new RejectedEvent(422, parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "));
  }

  if (seen.has(parsed.data.id)) return { duplicate: true, event: parsed.data };
  seen.add(parsed.data.id);
  return { duplicate: false, event: parsed.data };
}
Приёмник события «заказ оплачен»: схема описывает не только типы, но и форматы, диапазоны и допустимые валюты

Почему strictObject, а не обычный объект: неизвестное поле isAdmin в событии — либо ошибка партнёра, либо попытка массового присваивания (mass assignment), если данные дальше передаются в ORM целиком. Строгая схема отклоняет такое событие, а не молча копирует поле в базу. Регулярное выражение для orderId разрешает только цифры — строка с SQL до запроса не доходит, хотя параметризованные запросы всё равно обязательны.

terminal
$ node --test partner-event.test.mjs
  first: false { orderId: '1042', amount: 15000, currency: 'KZT' } | repeat: true
  after rotation: 401 bad signature
  401 bad signature
  401 missing or malformed signature headers
  401 stale timestamp
  422 data: Unrecognized key: "isAdmin"
  422 data.orderId: Invalid string: must match pattern /^[0-9]{1,12}$/; data.amount: Too small: expected number to be >0; data.currency: Invalid option: expected one of "KZT"|"USD"
  413 body too large
✔ valid event is accepted once, the repeat is marked as duplicate (2.769ms)
✔ an event signed with the previous key passes during rotation (0.452ms)
✔ tampering, stale timestamps, junk and oversized bodies are rejected (0.8696ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0
Реальный вывод node --test на Node.js 24.18: дубль распознан, старый ключ принимается только до конца ротации, подмена суммы, просроченная метка, лишнее поле, неверные форматы и огромное тело отклонены

Исходящие запросы: SSRF и allowlist

SSRF (server-side request forgery) возникает, когда сервер делает запрос по адресу, который задал пользователь: импорт аватара по ссылке, адрес вебхука клиента, предпросмотр страницы. Атакующий подставляет внутренний адрес — базу, админку, сервис метаданных облака 169.254.169.254 с временными ключами — и сервер сам приносит ему ответ из закрытой сети.

Шпаргалка OWASP различает два случая. Если адресаты известны заранее — платёжный шлюз, CRM, API партнёра, — используйте allowlist: пользователь выбирает из списка, а URL собирает сервер; пример такой проверки есть в уроке про OWASP Top 10. Если адрес действительно произвольный, как у вебхуков клиентов, остаётся блок-лист внутренних сетей, и сделать его правильно сложнее, чем кажется:

  • URL разбирается одним стандартным парсером: https://0x7f000001/ и https://2130706433/ после разбора — это 127.0.0.1. Проверять нужно результат new URL, а не исходную строку.
  • Доменное имя может указывать на внутренний IP. Проверяются все адреса, в которые оно разрешается, — A и AAAA.
  • Проверка и соединение должны использовать один и тот же результат DNS. Если сначала проверить имя, а потом дать HTTP-клиенту разрешить его заново, атакующий успеет поменять запись (DNS rebinding). Поэтому проверка встроена в lookup самого соединения.
  • Редиректы не выполняются: разрешённый внешний сервер может ответить 302 на внутренний адрес. Это же требование есть в API10: не следовать за редиректами партнёра вслепую.
  • Таймаут и лимит размера ответа обязательны: медленный или бесконечный ответ занимает соединения вашего сервера.
safe-fetch.mjs
// Outgoing request to a URL supplied by a user (avatar import, customer webhook URL).
// SSRF defences from the OWASP cheat sheet: scheme allowlist, IP check after DNS,
// connection only to the checked address, no redirects, timeout and size limit.
import { lookup as dnsLookup } from "node:dns";
import { request } from "node:https";
import { BlockList, isIP } from "node:net";

const denied = new BlockList();
for (const [net, prefix] of [
  ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
  ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
  ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
]) denied.addSubnet(net, prefix, "ipv4");
for (const [net, prefix] of [
  ["::", 128], ["::1", 128], ["64:ff9b::", 96], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
]) denied.addSubnet(net, prefix, "ipv6");

export function isPublicAddress(ip) {
  const family = isIP(ip);
  if (family === 0) return false;
  // BlockList also matches IPv4-mapped IPv6 (::ffff:127.0.0.1) against IPv4 rules
  return !denied.check(ip, family === 4 ? "ipv4" : "ipv6");
}

export class BlockedUrlError extends Error {
  name = "BlockedUrlError";
}

// Every address the name resolves to must be allowed; the socket then connects
// to exactly these addresses, so there is no second, unchecked DNS lookup.
function guardedLookup(isAllowed) {
  return (hostname, options, callback) => {
    dnsLookup(hostname, { all: true }, (err, addresses) => {
      if (err) return callback(err);
      const bad = addresses.find((a) => !isAllowed(a.address));
      if (bad) return callback(new BlockedUrlError(`${hostname} resolves to a blocked address ${bad.address}`));
      if (options.all) return callback(null, addresses);
      callback(null, addresses[0].address, addresses[0].family);
    });
  };
}

export function safeGet(input, { timeoutMs = 5000, maxBytes = 1_000_000, isAllowed = isPublicAddress, ports = ["", "443"] } = {}) {
  let url;
  try {
    url = new URL(input);
  } catch {
    return Promise.reject(new BlockedUrlError("invalid URL"));
  }
  if (url.protocol !== "https:") return Promise.reject(new BlockedUrlError(`scheme ${url.protocol} is not allowed`));
  if (url.username || url.password) return Promise.reject(new BlockedUrlError("credentials in URL are not allowed"));
  if (!ports.includes(url.port)) return Promise.reject(new BlockedUrlError(`port ${url.port} is not allowed`));
  const host = url.hostname.replace(/^\[|\]$/g, "");
  // An IP literal skips DNS, so it is checked here
  if (isIP(host) && !isAllowed(host)) return Promise.reject(new BlockedUrlError(`address ${host} is blocked`));

  return new Promise((resolve, reject) => {
    const req = request(url, {
      method: "GET",
      lookup: guardedLookup(isAllowed),
      signal: AbortSignal.timeout(timeoutMs),
      headers: { "user-agent": "interlab-fetcher/1.0" },
    }, (res) => {
      // node:https does not follow redirects; we do not follow them either
      if (res.statusCode >= 300 && res.statusCode < 400) {
        res.resume();
        return reject(new BlockedUrlError(`redirect to ${res.headers.location} was not followed`));
      }
      const chunks = [];
      let size = 0;
      res.on("data", (chunk) => {
        size += chunk.length;
        if (size > maxBytes) {
          req.destroy(new BlockedUrlError(`response is larger than ${maxBytes} bytes`));
          return;
        }
        chunks.push(chunk);
      });
      res.on("end", () => resolve({ status: res.statusCode, type: res.headers["content-type"], body: Buffer.concat(chunks) }));
      res.on("error", reject);
    });
    req.on("error", reject);
    req.end();
  });
}
Безопасный GET по адресу от пользователя на встроенных модулях Node.js. Диапазоны — минимальный набор из шпаргалки OWASP плюс CGNAT, служебные и NAT64-сети
terminal
$ node --test safe-fetch.test.mjs
  http://example.com/ -> scheme http: is not allowed
  file:///etc/passwd -> scheme file: is not allowed
  https://user:pass@example.com/ -> credentials in URL are not allowed
  https://example.com:8443/ -> port 8443 is not allowed
  https://169.254.169.254/latest/meta-data/ -> address 169.254.169.254 is blocked
  https://0x7f000001/ -> address 127.0.0.1 is blocked
  https://2130706433/admin -> address 127.0.0.1 is blocked
  https://[::ffff:127.0.0.1]/ -> address ::ffff:7f00:1 is blocked
✔ isPublicAddress: private, loopback, link-local and mapped addresses are blocked (5.2406ms)
  https://localhost/ -> localhost resolves to a blocked address ::1
✔ dangerous URLs are rejected before any connection (10.0721ms)
  https://example.com/ -> 200 text/html; charset=utf-8, 577 bytes
✔ a public HTTPS address works (42.96ms)
  /redirect -> BlockedUrlError: redirect to http://169.254.169.254/latest/meta-data/ was not followed
  /big -> BlockedUrlError: response is larger than 1000000 bytes
  /slow -> AbortError: The operation was aborted
✔ redirects are not followed, size and time are limited (529.0931ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
Реальный вывод: опасные адреса отклонены до соединения, localhost — после DNS, публичный адрес работает. Для проверки редиректа, размера и таймаута тест явно разрешает локальный HTTPS-сервер с самоподписанным сертификатом

Логи без секретов и персональных данных

Интеграции отлаживают по логам, и именно туда чаще всего утекают ключи: заголовок Authorization, ?api_key= в URL, целиком сохранённый запрос партнёра. Шпаргалка OWASP по журналированию перечисляет, что не пишут в журнал напрямую: токены доступа, идентификаторы сессий, ключи шифрования и другие первичные секреты, чувствительные персональные данные; несенситивные персональные данные — имя, телефон, e-mail — по возможности маскируют или псевдонимизируют. Там же — требование очищать данные от переводов строк, чтобы чужое значение не подделало новую запись.

safe-log.mjs
// Integration log: JSON lines, secrets removed, personal data masked.
const SECRET_KEYS = /authorization|cookie|token|secret|password|api[-_]?key|signature/i;

// Query strings often carry keys (?api_key=…): keep only the origin and path
function stripQuery(value) {
  const url = new URL(value);
  return url.search ? `${url.origin}${url.pathname}?[REDACTED]` : value;
}

function maskEmail(value) {
  const [user, domain] = value.split("@");
  return `${user.slice(0, 1)}***@${domain}`;
}

function maskPhone(value) {
  const digits = value.replace(/\D/g, "");
  return `***${digits.slice(-2)}`;
}

export function redact(value, key = "") {
  if (SECRET_KEYS.test(key)) return "[REDACTED]";
  if (Array.isArray(value)) return value.map((v) => redact(v));
  if (value && typeof value === "object") {
    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)]));
  }
  if (typeof value === "string" && /^https?:\/\//.test(value)) return stripQuery(value);
  if (typeof value === "string" && /^[^@\s]+@[^@\s]+$/.test(value)) return maskEmail(value);
  if (typeof value === "string" && /^phone$/i.test(key)) return maskPhone(value);
  if (typeof value === "string" && /^(name|full_?name)$/i.test(key)) return `${value.slice(0, 1)}***`;
  return value;
}

export function logIntegration(event, fields) {
  // JSON.stringify escapes \r and \n, so a partner's value cannot forge a new log line
  console.log(JSON.stringify({ time: new Date().toISOString(), event, ...redact(fields) }));
}
Структурированный журнал интеграции: секретные ключи заменяются, query-строка убирается, e-mail, телефон и имя маскируются
safe-log.demo.mjs
import { logIntegration } from "./safe-log.mjs";

logIntegration("crm.lead.create", {
  integration: "crm-main",
  status: 401,
  durationMs: 184,
  request: {
    url: "https://crm.example.test/api/v4/leads?api_key=YOUR_API_KEY",
    headers: { Authorization: "Bearer YOUR_API_KEY", "Content-Type": "application/json" },
  },
  lead: { name: "Test Client", phone: "+7 701 000 00 42", email: "client@example.test" },
  partnerMessage: "invalid token\nINFO payment ok",
});
Пример записи о неудачном запросе в CRM; ключи — плейсхолдеры
terminal
$ node safe-log.demo.mjs
{"time":"2026-10-12T03:00:27.507Z","event":"crm.lead.create","integration":"crm-main","status":401,"durationMs":184,"request":{"url":"https://crm.example.test/api/v4/leads?[REDACTED]","headers":{"Authorization":"[REDACTED]","Content-Type":"application/json"}},"lead":{"name":"T***","phone":"***42","email":"c***@example.test"},"partnerMessage":"invalid token\nINFO payment ok"}
Реальный вывод: заголовок и query-строка скрыты, контакты замаскированы, перевод строки из ответа партнёра остался внутри JSON-строки и не создал поддельную запись

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

Чек-лист перед запуском интеграции

  • Ключ интеграции выпущен отдельно для этой интеграции и этого окружения, хранится в секретах хостинга или менеджере секретов, а не в коде
  • У ключа или сервисного аккаунта минимальные права; проверено, что он не открывает лишние методы
  • Известны владелец ключа, срок действия и порядок ротации; приёмник умеет принимать два ключа на время замены
  • Все входящие события проверяются по цепочке: размер → подпись → свежесть → строгая схема → дубликаты
  • Для B2B-обмена с поддержкой mTLS или стабильными адресами партнёра включены mTLS или IP allowlist — как дополнительный слой
  • Данные от партнёра проходят ту же валидацию, что и ввод пользователя, и попадают в базу только через параметризованные запросы
  • Исходящие запросы: TLS, таймаут, лимит размера, без слепого следования за редиректами
  • Запросы по URL от пользователя проходят SSRF-фильтр, а сервис изолирован от внутренней сети на сетевом уровне
  • Логи не содержат токенов, ключей, query-строк с ключами и открытых персональных данных; есть алерт на всплеск 401 и ошибок подписи
  • Есть план на случай утечки: кто отзывает ключ, как быстро выпускается новый, как уведомляется партнёр
Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    partner-event.mjs (zod 4.6.6)

    node --test на Node 24.18, 3 теста: приём и дубль, ротация (старый ключ до/после удаления), подмена суммы/нет заголовков/просрочка → 401, лишнее поле и неверные форматы → 422, 70 КБ → 413; вывод в статье — реальный

  • запущено, работает

    safe-fetch.mjs

    node --test, 4 теста: таблица isPublicAddress; http/file/userinfo/порт/169.254.169.254/0x7f000001/2130706433/[::ffff:127.0.0.1] отклонены до соединения, localhost — в lookup после DNS; реальный GET https://example.com → 200; локальный HTTPS (самоподписанный сертификат, явно разрешён только в тесте): 302 не выполнен, 2 МБ > лимита, таймаут 500 мс → AbortError

  • запущено, работает

    safe-log.mjs + safe-log.demo.mjs

    node 24.18, реальный вывод: Authorization и query скрыты, имя/телефон/e-mail замаскированы, \n остался внутри JSON-строки

  • запущено, работает

    команда установки (bash)

    npm init -y, npm pkg set type=module, npm install zod@4.6.6 во временном проекте

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.