Защита бизнеса от наихудших сценариев является ключевым моментом при использовании функции Lambda для интеграции стороннего события с современным облачным приложением. Мы делимся передовым опытом и функциями, необходимыми для создания надежной и безопасной функции Lambda, готовой к работе.

Эта статья написана совместно членами команды Whitespectre Гильермо А. Казеротто, Бенджамином Калаче и Алексом Наумчуком.

Функции AWS Lambda — это мощный инструмент для современных облачных приложений, обеспечивающий бессерверную вычислительную мощность, безопасную обработку конфиденциальных данных и обеспечение непрерывной работы. Они позволяют разработчикам запускать код без управления серверами или инфраструктурой, они экономичны, масштабируемы, гибки и просты в развертывании.

В нашей предыдущей статье об использовании шлюзов AWS Lambda и API для обработки веб-перехватчиков в бессерверной среде мы рассказали, как использование функций Lambda позволило нам интегрировать стороннее событие в наше приложение.

В этой статье мы рассмотрим следующие передовые практики для надежных и безопасных функций AWS Lambda:

В качестве отправной точки, вот наша базовая лямбда:

import { returnProduct } from './db';
 
export const handler = async (event, context) => {
  const { productIds } = event.body;
 
  for (const productId of productIds) {
    await returnProduct(productId);
  }
}

Теперь мы пойдем немного дальше и дополним функциональность Lambda, которая интегрирует как стороннее, так и наше основное приложение.

Наша цель — получить список товаров, которые были возвращены в магазин, и пометить их как возвращенные в нашем приложении.

Такая базовая функция хороша только для целей тестирования, и если мы хотим, чтобы наша функция была готова к работе, нам нужно решить довольно много моментов, которых сейчас не хватает нашей функции, таких как ведение журнала в бессерверной среде, обработка ошибок. , использование внешних API, управление переменными среды, доступ к частной информации и т. д.

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

Регистрация данных

У нас есть функция, которая получает массив строк с именем productIds и вызывает метод из внешней библиотеки для выполнения обновления, но мы не знаем, какой продукт или набор продуктов будут обрабатываться. Кроме того, если что-то пойдет не так, мы не сможем узнать, где именно проявляется проблема. Сейчас у нас нет никакой информации, которая могла бы помочь нам в этом.

Поэтому нам нужно добавить правильный способ регистрации всех соответствующих событий в нашей лямбда-функции. Мы можем использовать console.log() для достижения этой функциональности. Lambda автоматически отправляет все журналы в CloudWatch без каких-либо дополнительных действий с нашей стороны.

Каждая функция Lambda поставляется с группой журналов CloudWatch и потоком журналов для каждого экземпляра функции, поэтому мы также можем точно определить источник каждого журнала. И мы можем использовать функцию для инкапсуляции поведения записи журнала, например:

async writeLog(message, level) {
   console.log(
    JSON.stringify({
      level: level ? level.toLowerCase() : "info",
      message: message.toString(),
    })
  );
}

Затем мы можем использовать его для регистрации любого соответствующего события или части информации, которые могут показаться вам важными. Например, когда приходит полезная нагрузка, мы регистрируем идентификаторы продуктов. И только когда Lambda выполняет свою задачу, продукт помечается как returned.

import { returnProduct } from './db';
 
export const handler = async (event, context) => {
    const { productIds } = event.body;
    writeLog(`Product ids: ${JSON.stringify(productIds)}`);
 
    for (const productId of productIds) {
      await returnProduct(productId);
writeLog(`Returned product: product_id: ${productId}`);
    }
}

Кодировка Base64

По умолчанию наша Lambda предполагает, что полученная информация представлена ​​либо в виде простого текста, либо в формате JSON. Однако что произойдет, если по какой-то причине тело находится в кодировке base64? Или, возможно, если стороннее приложение решит закодировать информацию из соображений безопасности, чтобы просто не отправлять обычный текст через Интернет. Важно отметить, что мы не можем с уверенностью определить кодировку, используемую источником для передачи информации в нашу лямбду.

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

Чтобы узнать, закодирована ли полезная нагрузка в base64, нам нужно проверить параметр isBase64Encoded в полезной нагрузке, который может быть таким.

{
  "body": "eyJ0ZXN0IjoiYm9keSJ9",
  "httpMethod": "POST",
  "isBase64Encoded": true,
  "queryStringParameters": {
    "foo": "bar"
  }
  .....
}

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

function decodeBase64(encodedPayload){
  return Buffer.from(encodedPayload, 'base64').toString('utf-8');	
}

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

import { returnProduct } from './db';
 
export const handler = async (event, context) => {
  //check first if is base64 
  const productIds = (event.isBase64Encoded) ?
  decodeBase64(event.body.productIds): event.body.productIds;
 
  for (const productId of productIds) {
    .......
  }  
}

Итак, мы позаботились о логировании и правильной обработке кодировки base64, но что, если что-то пойдет не так и по какой-то причине состояние продукта не может быть обновлено, или нормальный поток приложения прервется?

В этом случае нам нужно добавить правильную обработку ошибок, и это мы рассмотрим в следующем разделе.

Обработка ошибок

При разработке лямбда-функции важно предвидеть потенциальные ошибки и исключения, которые могут возникнуть во время ее выполнения. Даже если вы написали почти идеальный код, все равно могут возникнуть непредвиденные проблемы.

Чтобы ваша лямбда-функция могла изящно обрабатывать ошибки, вы должны спроектировать ее так, чтобы она была отказоустойчивой, предоставив соответствующие механизмы обработки ошибок. Это означает, что ваша функция должна быть способна обнаруживать и обрабатывать ошибки, сообщать о них осмысленным образом и обеспечивать резервное поведение, чтобы ваши пользователи могли продолжать использовать ваше приложение даже в случае сбоя функции.

Итак, мы только что добавили функцию для регистрации любого соответствующего события и обработки обоих случаев кодирования, но что произойдет, если идентификатор продукта не найден или по какой-то причине Lambda не может обновить состояние продукта?

На данный момент мы не обрабатываем какие-либо ошибки, которые могут возникнуть при вызове returnProduct, поэтому, если и будет ошибка, мы ее вообще не заметим и не сможем отследить. Если что-то пойдет не так, наше приложение может рухнуть, если мы не обработаем ошибки должным образом.

Чтобы исправить это, давайте добавим блок try-catch на случай, если возникнут какие-либо проблемы или по какой-то причине бессерверная функция не сможет обновить состояния продуктов и добавить логи, чтобы мы знали, что произошло. Обратите внимание, что в нашем цикле for также есть блок try-catch, так как мы хотим знать, не удалось ли вернуть только отдельные продукты, в то же время завершая успешные.

import { returnProduct } from "./db";
 
export const handler = async (event, context) => {
  try {
    //check first if is base64
    const productIds = event.isBase64Encoded
      ? decodeBase64(event.body.productIds)
      : event.body.productIds;
 
    writeLog(`Product ids: ${JSON.stringify(productIds)}`);
 
    for (const productId of productIds) {
      try {
    await returnProduct(productId);
    writeLog(`Returned product: product_id: ${productId}`);
  } catch (err) {
    writeLog(
      `An error occurred trying to return the product: ${productId}`,
      "Error"
    );
  }
    }
  } catch (error) {
    writeLog(`An error occurred processing produts: ${error.message}`, "Error");
  }
};

Теперь у нас есть функция, которая получает информацию о продукте и пытается обновить состояние. Кроме того, он регистрирует любую соответствующую информацию, и если что-то пойдет не так, он не остановит поток и зарегистрирует ошибку.

Однако нет никакой гарантии, что товар был возвращен. Мы только попытались вернуть его. Что делать, если товар уже был возвращен? Или если возврат не удался, а статус так и не изменился?

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

import { returnProduct, getProductStatus } from './db';
 
export const handler = async (event, context) => {
  try {
    //check first if is base64
    const productIds = event.isBase64Encoded
      ? decodeBase64(event.body.productIds)
      : event.body.productIds;
 
    writeLog(`Products ids: ${JSON.stringify(productIds)}`);
 
    const processedProducts = [];
    const failedProducts = [];
    const skippedProducts = [];
    for (const productId of productIds) {
      try {
        writeLog(`Started processing: ${productId}`);
        const productStatus = await getProductStatus(productId);
        if (productStatus === ProductStatus.RETURNED) {
          skippedProducts.push(productId);
          writeLog(`Skipped processing: ${productId}`);
        } else {
          await returnProduct(productId);
          const updatedProductStatus = await getProductStatus(productId);
 
          if (updatedProductStatus === ProductStatus.RETURNED) {
            processedProducts.push(productId);
            writeLog(`Processed successfully: ${productId}`);
          } else {
            failedProducts.push(productId);
            writeLog(`Failed processing: ${productId}`);
          }
        }
      } catch (err) {
        writeLog(
          `An error occurred trying to return productId: ${productId}.`,
          "Error"
        );
      }
    }
  } catch (error) {
    writeLog(`An error occurred processing produts: ${error.message}`, "Error");
  }
  writeLog(
    `Processing succesful. Successful returns: ${processedProducts.toString()}, 
     Failed returns: ${failedProducts.toString()}, 
     Skipped returns: ${skippedProducts.toString()}.`
  );
}

Здесь мы будем обновлять статус каждого продукта в отдельности и отслеживать те, которые вышли из строя, а также те, которые мы пропустили в обработке просто потому, что они уже были возвращены.

Таким образом, несколько вызовов с одинаковыми идентификаторами продуктов должны быть намного быстрее, так как нам нужно будет только проверить, что продукты уже возвращены. Мы также регистрируем очень необходимую информацию о том, какие продукты необходимо переработать.

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

import { returnProduct, getProductStatus } from "./db";
 
export const handler = async (event, context) => {
  try {
    //check first if is base64
    const productIds = event.isBase64Encoded
      ? decodeBase64(event.body.productIds)
      : event.body.productIds;
 
    writeLog(`Products ids: ${JSON.stringify(productIds)}`);
 
    const processedProducts = [];
    const failedProducts = [];
    const skippedProducts = [];
    for (const productId of productIds) {
      processProduct(
        productId,
        processedProducts,
        failedProducts,
        skippedProducts
      );
    }
  } catch (error) {
    writeLog(`An error occurred processing produts: ${error.message}`, "Error");
  }
  writeLog(
    `Processing succesful. Successful returns: ${processedProducts.toString()}, 
     Failed returns: ${failedProducts.toString()}, 
     Skipped returns: ${skippedProducts.toString()}.`
  );
};
 
const processProduct = (
  productId,
  processedProducts,
  failedProducts,
  skippedProducts
) => {
  try {
    writeLog(`Started processing: ${productId}`);
    const productStatus = await getProductStatus(productId);
    if (productStatus === ProductStatus.RETURNED) {
      skippedProducts.push(productId);
      writeLog(`Skipped processing: ${productId}`);
    } else {
      await returnProduct(productId);
      const updatedProductStatus = await getProductStatus(productId);
 
      if (updatedProductStatus === ProductStatus.RETURNED) {
        processedProducts.push(productId);
        writeLog(`Processed successfully: ${productId}`);
      } else {
        failedProducts.push(productId);
        writeLog(`Failed processing: ${productId}`);
      }
    }
  } catch (err) {
    writeLog(
      `An error occurred trying to return productId: ${productId}.`,
      "Error"
    );
  }
};

Сделав все это, мы тестируем нашу конечную точку.

И мы понимаем, что нам нужно переходить к CloudWatch при каждом вызове, чтобы увидеть, были ли какие-либо проблемы, поскольку для нашей Lambda нет ответного сообщения.

Нам все еще не хватает какой-то обратной связи.

Итак, давайте добавим короткую функцию, которая поможет нам в этом: createResponse даст нам структуру сообщения, а createReturnMessage создаст удобочитаемое сообщение с данными, которые мы хотели бы видеть при каждом вызове Lambda:

import { returnProduct, getProductStatus } from "./db";
 
export const handler = async (event, context) => {
  try {
    //check first if is base64
    const productIds = event.isBase64Encoded
      ? decodeBase64(event.body.productIds)
      : event.body.productIds;
 
    writeLog(`Products ids: ${JSON.stringify(productIds)}`);
 
    const processedProducts = [];
    const failedProducts = [];
    const skippedProducts = [];
    for (const productId of productIds) {
      processProduct(
        productId,
        processedProducts,
        failedProducts,
        skippedProducts
      );
    }
  } catch (error) {
    writeLog(`An error occurred processing produts: ${error.message}`, "Error");
  }
  writeLog(
    `Processing succesful. Successful returns: ${processedProducts.toString()}, 
     Failed returns: ${failedProducts.toString()}, 
     Skipped returns: ${skippedProducts.toString()}.`
  );
};
 
const processProduct = (
  productId,
  processedProducts,
  failedProducts,
  skippedProducts
) => {
  try {
    writeLog(`Started processing: ${productId}`);
    const productStatus = await getProductStatus(productId);
    if (productStatus === ProductStatus.RETURNED) {
      skippedProducts.push(productId);
      writeLog(`Skipped processing: ${productId}`);
    } else {
      await returnProduct(productId);
      const updatedProductStatus = await getProductStatus(productId);
 
      if (updatedProductStatus === ProductStatus.RETURNED) {
        processedProducts.push(productId);
        writeLog(`Processed successfully: ${productId}`);
      } else {
        failedProducts.push(productId);
        writeLog(`Failed processing: ${productId}`);
      }
    }
  } catch (err) {
    writeLog(
      `An error occurred trying to return productId: ${productId}.`,
      "Error"
    );
  }
};

Идея состоит в том, чтобы вернуть полезное сообщение, чтобы пользователь знал значения processedProducts, failedProducts, skippedProducts и произошла ли ошибка.

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

Это позволит избежать необходимости вызывать внешний API при каждом вызове.

Проверка полезной нагрузки

Как мы уже упоминали, у нас могут быть нежелательные звонки на нашу Lambda. Но что также произойдет, если злоумышленник или «человек посередине» отправит запросы с неверными или вредоносными данными? Это может привести к уязвимостям в системе безопасности, утечке данных и другим типам атак. Мы можем столкнуться с потерей данных или фальсификацией данных. Lambda может перестать работать, или мы понесем слишком большие расходы с нашей стороны.

Мы должны позаботиться об этом.

Чтобы решить эту проблему, мы можем ввести проверку полезной нагрузки, чтобы гарантировать, что запросы исходят из правильного источника, а не от какой-то неизвестной третьей стороны. Проверяя полезную нагрузку с помощью хэша, мы можем повысить общую безопасность.

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

{
	body: { productIds: string[], event: "return_products" }
	headers: {
		X-Return-Signature: string // sha-256 encoded payload using a secret key
	}
}

Здесь важен секретный ключ. Этот ключ будет предоставлен третьей стороной и сохранен на нашей стороне, а также в качестве секрета с использованием AWS Secrets Manager.

Сделать это:

  • Перейдите в Диспетчер секретов AWS -> Секреты.
  • Нажмите Сохранить новый секрет:

а. Тип секрета: Другой тип секрета

б. Ключ: значение стороннего секретного ключа: (например, «2ba…15c2b7»)

в. Ключ шифрования: оставьте по умолчанию «aws/secretsmanager».

д. Нажмите "Далее.

е. Секретное имя: сторонний-секретный ключ

ф. Нажмите «Далее» на шаге 2.

г. Нажмите «Далее» на шаге 3.

час Просмотрите и нажмите «Сохранить».

ВАЖНО: Нам нужно будет добавить разрешения в Lambda для доступа к диспетчеру секретов, это можно сделать, добавив политику разрешений к связанной роли Lambda.

  • Перейдите в конфигурацию -> нажмите на роль выполнения, созданную для этой лямбды:

  • Он откроется в новой вкладке для роли IAM -> добавьте встроенную политику для предоставления доступа к диспетчеру секретов и методу getSecrets:

  • Политика будет похожа на этот JSON:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "GetSecrets",
            "Effect": "Allow",
            "Action": "secretsmanager:GetSecretValue",
            "Resource": "arn:aws:secretsmanager:*:account-id:secret:*"
        }
    ]
}

После этого нам нужно будет добавить расширение AWS Parameters and Secrets Lambda Extension, чтобы иметь возможность извлекать и кэшировать секреты без SDK.

Для этого нам нужно перейти в раздел Layers и добавить расширение AWS Parameters and Secrets Lambda.

Затем, чтобы получить секрет и использовать его в нашей лямбде, мы могли бы создать такую ​​функцию:

async function getSecret(secretName) {
  try {
	const headers = {
  	"X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN,
	};
	const res = await fetch(
 "http://localhost:2773/secretsmanager/get?secretId=thirdparty-secretkey",
  	{ headers }
	);
	if (res.ok) {
  	const data = await res.json();
  	return data.SecretString;
	}
  } catch (err) {
	writeLog(
  	`An error occurred getting the ${secretName} secret.`,
  	"Error"
	);
  }
}

Ссылаясь на документацию AWS, здесь мы отправляем process.env.AWS_SESSION_TOKEN в качестве заголовка, а затем извлекаем секрет с помощью выборки (глобально доступно в среде выполнения node 18.x).

Затем, используя этот секрет, мы проверим все полезные данные, поступающие в нашу Lambda, следующим образом:

import { createHmac }  from "crypto";
async function validateThirdParty(event) {
  const { body, headers } = event;
  const secretName = "thirdparty-secretkey";
  const secret = await getSecret(secretName);
  if (!secret || !secret[secretName]) {
	writeLog("No secret key was found", "Error");
	return false;
  }
 
  let encodedPayload = createHmac("sha256", secret[secretName])
	.update(JSON.stringify(body))
	.digest("hex");
 
  const signature = headers["X-Return-Signature"];
 
  if (!signature || signature !== encodedPayload) {
	writeLog("Wrong signature or secretKey", "Error");
	return false;
  }
 
  return signature === encodedPayload;
}

Таким образом, мы гарантируем, что третья сторона является той, которую мы ожидаем. Все, что осталось сделать, это вызвать эту проверку в начале нашего обработчика:

export const handler = async (event, context) => {
 const productIds = (event.isBase64Encoded)?
  decodeBase64(event.body.productIds): event.body.productIds;
 
 
  writeLog(`Product ids: ${JSON.stringify(productIds)}`);
 
  const isValid = await validateThirdParty(event);
  if (!isValid) {
    writeLog("Validation failed.", "Error");
    const message = {
      message: "Request did not come from the correct third party.",
    };
    return createResponse(400, message);
  }
  // ...
};

Запросить проверку

Мы можем сделать это, включив проверку запросов в консоли шлюза API. Это гарантирует, что запросы в этом POST включают заголовок X-Return-Signature, а все запросы, которые не содержат требуемой полезной нагрузки, тела или заголовка, будут отклонены, что предотвратит ненужный вызов Lambda.

Чтобы проверить заголовок:

  • Перейдите к Выполнение метода метода POST в консоли шлюза API -> Ресурсы.
  • Если валидатор запроса не выбран, выберите любой валидатор, который включает заголовки.
  • В разделе «Заголовки HTTP-запроса» добавьте новый заголовок X-Return-Signature и установите его по мере необходимости.

Помните, что этот заголовок будет полезной нагрузкой, закодированной sha-256, с использованием нашего секретного ключа в качестве секрета.

В качестве совета мы можем закодировать полезную нагрузку вручную в целях тестирования так же, как мы делаем проверку:

const thirdPartySignature = crypto
      .createHmac("sha256", ourSecretKey)
      .update(JSON.stringify(parsedBody))
      .digest("hex");

Разверните API и протестируйте метод (с любым HTTP-клиентом/cURL) с заголовком и без него. Вы должны увидеть следующее сообщение, если заголовок отсутствует:

{
    "message": "Missing required request parameters: [X-Return-Signature]"
}

Чтобы проверить тело:

  • Перейдите к Выполнение метода метода POST в консоли шлюза API -> Ресурсы.
  • Если средство проверки запроса не выбрано, выберите параметры проверки тела и заголовка.
  • Сначала вам нужно уже создать схему модели.
  • Создайте схему модели
  • Это должно быть что-то вроде этого:

Затем добавьте строку с «application/json» и названием модели.

Наконец, вам нужно снова развернуть API.

Заключение

Как мы видели, обеспечение безопасности и надежности бессерверных функций Lambda имеет решающее значение для успеха современных облачных приложений. Даже если лямбда-функции относительно легко разрабатывать, важно применять тот же уровень строгости, что и к любому веб-приложению, иосознавать, что значительный объем работы требуется для того, чтобы настроить их и запустить в производственной среде. В рабочей среде даже небольшие ошибки или уязвимости могут иметь серьезные последствия, такие как потеря данных, простои служб или нарушения безопасности.

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

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