From 51622b4bdc1d413ecf7b2772b52682842fdcb70f Mon Sep 17 00:00:00 2001 From: Pedro Goncalves Date: Wed, 23 Oct 2024 11:41:52 +0200 Subject: [PATCH] Initial implementation of IPv4 allow list in the webhook endooint --- .github/workflows/terraform.yml | 2 + lambdas/functions/webhook-auth/jest.config.ts | 17 +++ lambdas/functions/webhook-auth/package.json | 60 ++++++++++ .../functions/webhook-auth/src/lambda.test.ts | 111 ++++++++++++++++++ lambdas/functions/webhook-auth/src/lambda.ts | 53 +++++++++ lambdas/functions/webhook-auth/tsconfig.json | 6 + modules/webhook/main.tf | 12 ++ modules/webhook/variables.tf | 32 +++++ modules/webhook/webhook-auth.tf | 91 ++++++++++++++ 9 files changed, 384 insertions(+) create mode 100644 lambdas/functions/webhook-auth/jest.config.ts create mode 100644 lambdas/functions/webhook-auth/package.json create mode 100644 lambdas/functions/webhook-auth/src/lambda.test.ts create mode 100644 lambdas/functions/webhook-auth/src/lambda.ts create mode 100644 lambdas/functions/webhook-auth/tsconfig.json create mode 100644 modules/webhook/webhook-auth.tf diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 73f5fe9213..b5b45e8b0b 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -27,6 +27,7 @@ jobs: - name: "Fake zip files" # Validate will fail if it cannot find the zip files run: | touch lambdas/functions/webhook/webhook.zip + touch lambdas/functions/webhook/webhook-auth.zip touch lambdas/functions/control-plane/runners.zip touch lambdas/functions/gh-agent-syncer/runner-binaries-syncer.zip touch lambdas/functions/ami-housekeeper/ami-housekeeper.zip @@ -81,6 +82,7 @@ jobs: "ssm", "termination-watcher", "webhook", + "webhook-auth", ] defaults: run: diff --git a/lambdas/functions/webhook-auth/jest.config.ts b/lambdas/functions/webhook-auth/jest.config.ts new file mode 100644 index 0000000000..077707f923 --- /dev/null +++ b/lambdas/functions/webhook-auth/jest.config.ts @@ -0,0 +1,17 @@ +import type { Config } from 'jest'; + +import defaultConfig from '../../jest.base.config'; + +const config: Config = { + ...defaultConfig, + coverageThreshold: { + global: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, +}; + +export default config; diff --git a/lambdas/functions/webhook-auth/package.json b/lambdas/functions/webhook-auth/package.json new file mode 100644 index 0000000000..16d83c2db6 --- /dev/null +++ b/lambdas/functions/webhook-auth/package.json @@ -0,0 +1,60 @@ +{ + "name": "@aws-github-runner/webhook-auth", + "version": "1.0.0", + "main": "lambda.ts", + "license": "MIT", + "scripts": { + "start": "ts-node-dev src/local.ts", + "test": "NODE_ENV=test nx test", + "test:watch": "NODE_ENV=test nx test --watch", + "lint": "yarn eslint src", + "watch": "ts-node-dev --respawn --exit-child src/local.ts", + "build": "ncc build src/lambda.ts -o dist", + "dist": "yarn build && cd dist && zip ../webhook-auth.zip index.js", + "format": "prettier --write \"**/*.ts\"", + "format-check": "prettier --check \"**/*.ts\"", + "all": "yarn build && yarn format && yarn lint && yarn test" + }, + "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/aws-lambda": "^8.10.145", + "@types/jest": "^29.5.12", + "@types/node": "^22.5.4", + "@typescript-eslint/eslint-plugin": "^8.9.0", + "@typescript-eslint/parser": "^8.8.0", + "@vercel/ncc": "^0.38.1", + "aws-sdk-client-mock": "^4.0.2", + "aws-sdk-client-mock-jest": "^4.0.1", + "eslint": "^8.57.0", + "eslint-plugin-prettier": "5.2.1", + "jest": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "nock": "^13.5.4", + "prettier": "3.3.3", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0" + }, + "dependencies": { + "@aws-github-runner/aws-powertools-util": "*", + "@aws-github-runner/aws-ssm-util": "*", + "@aws-sdk/client-ec2": "^3.670.0", + "@aws-sdk/client-ssm": "^3.670.0", + "@aws-sdk/types": "^3.664.0", + "cron-parser": "^4.9.0", + "typescript": "^5.5.4" + }, + "nx": { + "includedScripts": [ + "build", + "dist", + "format", + "format-check", + "lint", + "start", + "watch", + "all" + ] + } +} diff --git a/lambdas/functions/webhook-auth/src/lambda.test.ts b/lambdas/functions/webhook-auth/src/lambda.test.ts new file mode 100644 index 0000000000..ec77c307aa --- /dev/null +++ b/lambdas/functions/webhook-auth/src/lambda.test.ts @@ -0,0 +1,111 @@ +import { handler } from './lambda'; +import { Context } from 'aws-lambda'; +import { APIGatewayRequestAuthorizerEventV2 } from 'aws-lambda/trigger/api-gateway-authorizer'; + +const context: Context = { + awsRequestId: '1', + callbackWaitsForEmptyEventLoop: false, + functionName: '', + functionVersion: '', + getRemainingTimeInMillis: () => 0, + invokedFunctionArn: '', + logGroupName: '', + logStreamName: '', + memoryLimitInMB: '', + done: () => { + return; + }, + fail: () => { + return; + }, + succeed: () => { + return; + }, +}; + +// Pretty much copy/paste from here: +// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html +const event: APIGatewayRequestAuthorizerEventV2 = { + version: '2.0', + type: 'REQUEST', + routeArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/request', + identitySource: ['user1', '123'], + routeKey: '$default', + rawPath: '/my/path', + rawQueryString: 'parameter1=value1¶meter1=value2¶meter2=value', + cookies: ['cookie1', 'cookie2'], + headers: { + header1: 'value1', + header2: 'value2', + }, + queryStringParameters: { + parameter1: 'value1,value2', + parameter2: 'value', + }, + requestContext: { + accountId: '123456789012', + apiId: 'api-id', + authentication: { + clientCert: { + clientCertPem: 'CERT_CONTENT', + subjectDN: 'www.example.com', + issuerDN: 'Example issuer', + serialNumber: '1', + validity: { + notBefore: 'May 28 12:30:02 2019 GMT', + notAfter: 'Aug 5 09:36:04 2021 GMT', + }, + }, + }, + domainName: 'id.execute-api.us-east-1.amazonaws.com', + domainPrefix: 'id', + http: { + method: 'POST', + path: '/my/path', + protocol: 'HTTP/1.1', + sourceIp: '81.123.56.13', + userAgent: 'agent', + }, + requestId: 'id', + routeKey: '$default', + stage: '$default', + time: '12/Mar/2020:19:03:58 +0000', + timeEpoch: 1583348638390, + }, + pathParameters: { parameter1: 'value1' }, + stageVariables: { stageVariable1: 'value1', stageVariable2: 'value2' }, +}; + +jest.mock('@aws-github-runner/aws-powertools-util'); + +describe('Webhook auth', () => { + beforeAll(() => { + jest.resetAllMocks(); + }); + it('should not pass if env var does not exist', async () => { + const result = await handler(event, context); + expect(result).toEqual({ isAuthorized: false }); + }); + it('should pass the IP allow list using exact ip', async () => { + process.env.CIDR_IPV4_ALLOW_LIST = '81.123.56.13/32,81.123.56.52/32,10.0.0.0/8'; + const result = await handler(event, context); + expect(result).toEqual({ isAuthorized: true }); + }); + + it('should not pass the IP allow list.', async () => { + process.env.CIDR_IPV4_ALLOW_LIST = '81.123.56.52/32,10.0.0.0/8'; + const result = await handler(event, context); + expect(result).toEqual({ isAuthorized: false }); + }); + + it('should pass the IP allow list using CIDR range', async () => { + process.env.CIDR_IPV4_ALLOW_LIST = '81.123.0.0/16,10.0.0.0/8'; + const result = await handler(event, context); + expect(result).toEqual({ isAuthorized: true }); + }); + it('should not pass of CIDR_IPV4_ALLOW_LIST has the wrong format', async () => { + process.env.CIDR_IPV4_ALLOW_LIST = '81.123.0.0/16,10.0.0.0'; + const result = await handler(event, context); + expect(result).toEqual({ isAuthorized: false }); + }); +}); diff --git a/lambdas/functions/webhook-auth/src/lambda.ts b/lambdas/functions/webhook-auth/src/lambda.ts new file mode 100644 index 0000000000..5082101a7d --- /dev/null +++ b/lambdas/functions/webhook-auth/src/lambda.ts @@ -0,0 +1,53 @@ +import { logger, setContext } from '@aws-github-runner/aws-powertools-util'; +import { BlockList } from 'net'; +import { APIGatewayRequestAuthorizerEventV2 } from 'aws-lambda/trigger/api-gateway-authorizer'; +import { Context } from 'aws-lambda'; + +export interface Response { + isAuthorized: boolean; +} + +const Allow: Response = { + isAuthorized: true, +}; + +const Deny: Response = { + isAuthorized: false, +}; + +export async function handler(event: APIGatewayRequestAuthorizerEventV2, context: Context): Promise { + setContext(context, 'lambda.ts'); + logger.logEventIfEnabled(event); + + const allowList = new BlockList(); + + const ipv4AllowList = process.env.CIDR_IPV4_ALLOW_LIST ?? null; + + if (ipv4AllowList === null) { + logger.error('CIDR_IPV4_ALLOW_LIST is not set.'); + return Deny; + } + + //Check if CIDR_IPV4_ALLOW_LIST matches the format of a comma-separated list of CIDR blocks + const regex = new RegExp('^(\\d{1,3}\\.){3}\\d{1,3}\\/\\d{1,2}(,(\\d{1,3}\\.){3}\\d{1,3}\\/\\d{1,2})*$'); + if (!regex.test(ipv4AllowList)) { + logger.error( + 'CIDR_IPV4_ALLOW_LIST is not in the correct format. ' + + 'Expected format is a comma-separated list of CIDR blocks. e.g. 10.0.0.0/8,81.32.12.3/32', + ); + return Deny; + } + + ipv4AllowList.split(',').forEach((cidrBlock) => { + const [subnet, mask] = cidrBlock.split('/'); + allowList.addSubnet(subnet, parseInt(mask), 'ipv4'); + }); + + const clientIP = event.requestContext.http.sourceIp; + + if (allowList.check(clientIP)) { + return Allow; + } else { + return Deny; + } +} diff --git a/lambdas/functions/webhook-auth/tsconfig.json b/lambdas/functions/webhook-auth/tsconfig.json new file mode 100644 index 0000000000..f34dbbda1e --- /dev/null +++ b/lambdas/functions/webhook-auth/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends" : "../../tsconfig.json", + "include": [ + "src/**/*" + ] +} diff --git a/modules/webhook/main.tf b/modules/webhook/main.tf index 71fc36fea1..13420b9b79 100644 --- a/modules/webhook/main.tf +++ b/modules/webhook/main.tf @@ -2,6 +2,7 @@ locals { webhook_endpoint = "webhook" role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/webhook/webhook.zip" : var.lambda_zip + auth_lambda_zip = var.auth_lambda_zip == null ? "${path.module}/../../lambdas/functions/webhook-auth/webhook-auth.zip" : var.auth_lambda_zip } resource "aws_apigatewayv2_api" "webhook" { @@ -73,3 +74,14 @@ resource "aws_ssm_parameter" "runner_matcher_config" { value = jsonencode(local.runner_matcher_config_sorted) tier = var.matcher_config_parameter_store_tier } + +resource "aws_apigatewayv2_authorizer" "webhook_auth" { + count = local.webhook_auth_enabled ? 1 : 0 + + name = "webhook-auth" + api_id = aws_apigatewayv2_api.webhook.id + authorizer_type = "REQUEST" + authorizer_uri = aws_lambda_function.webhook_auth[0].invoke_arn + enable_simple_responses = true + authorizer_result_ttl_in_seconds = 0 +} diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index f427fa3412..55cbd1f37f 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -54,6 +54,11 @@ variable "lambda_zip" { default = null } +variable "auth_lambda_zip" { + type = string + default = null +} + variable "lambda_memory_size" { description = "Memory size limit in MB for lambda." type = number @@ -102,12 +107,22 @@ variable "webhook_lambda_s3_key" { default = null } +variable "webhook_auth_lambda_s3_key" { + type = string + default = null +} + variable "webhook_lambda_s3_object_version" { description = "S3 object version for webhook lambda function. Useful if S3 versioning is enabled on source bucket." type = string default = null } +variable "webhook_auth_lambda_s3_object_version" { + type = string + default = null +} + variable "webhook_lambda_apigateway_access_log_settings" { description = "Access log settings for webhook API gateway." type = object({ @@ -201,6 +216,11 @@ variable "lambda_tags" { default = {} } +variable "auth_lambda_tags" { + type = map(string) + default = {} +} + variable "matcher_config_parameter_store_tier" { description = "The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`." type = string @@ -210,3 +230,15 @@ variable "matcher_config_parameter_store_tier" { error_message = "`matcher_config_parameter_store_tier` value is not valid, valid values are: `Standard`, and `Advanced`." } } + +variable "webhook_allow_list" { + type = object({ + ipv4_cidr_blocks = optional(list(string), []) + }) + + validation { + condition = can([for ip in var.webhook_allow_list.ipv4_cidr_blocks : regex("^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}/[0-9]{1,2}$", ip)]) + error_message = "Incorrect format for IPv4 CIDR range." + } +} + diff --git a/modules/webhook/webhook-auth.tf b/modules/webhook/webhook-auth.tf new file mode 100644 index 0000000000..5fa9f7c510 --- /dev/null +++ b/modules/webhook/webhook-auth.tf @@ -0,0 +1,91 @@ +locals { + + webhook_auth_enabled = length(var.webhook_allow_list.ipv4_cidr_blocks) > 0 ? true : false + +} +resource "aws_lambda_function" "webhook_auth" { + count = local.webhook_auth_enabled ? 1 : 0 + + s3_bucket = var.lambda_s3_bucket != null ? var.lambda_s3_bucket : null + s3_key = var.webhook_lambda_s3_key != null ? var.webhook_auth_lambda_s3_key : null + s3_object_version = var.webhook_auth_lambda_s3_object_version != null ? var.webhook_auth_lambda_s3_object_version : null + filename = var.lambda_s3_bucket == null ? local.auth_lambda_zip : null + source_code_hash = var.lambda_s3_bucket == null ? filebase64sha256(local.auth_lambda_zip) : null + function_name = "${var.prefix}-webhook-auth" + role = aws_iam_role.webhook_auth_lambda[0].arn + handler = "index.handler" + runtime = var.lambda_runtime + memory_size = var.lambda_memory_size + timeout = var.lambda_timeout + architectures = [var.lambda_architecture] + + environment { + variables = { + for k, v in { + LOG_LEVEL = var.log_level + POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" + POWERTOOLS_TRACE_ENABLED = var.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.tracing_config.capture_error + CIDR_IPV4_ALLOW_LIST = var.webhook_allow_list.ipv4_cidr_blocks + } : k => v if v != null + } + } + + dynamic "vpc_config" { + for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + } + } + + tags = merge(var.tags, var.auth_lambda_tags) + + dynamic "tracing_config" { + for_each = var.tracing_config.mode != null ? [true] : [] + content { + mode = var.tracing_config.mode + } + } +} + +resource "aws_iam_role" "webhook_auth_lambda" { + count = local.webhook_auth_enabled ? 1 : 0 + + name = "${var.prefix}-action-webhook-auth-lambda-role" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = local.role_path + permissions_boundary = var.role_permissions_boundary + tags = var.tags +} + +resource "aws_iam_role_policy" "xray-auth" { + count = var.tracing_config.mode != null && local.webhook_auth_enabled ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.webhook_auth_lambda[0].name +} + +resource "aws_iam_role_policy_attachment" "webhook_auth_vpc_execution_role" { + count = length(var.lambda_subnet_ids) > 0 && local.webhook_auth_enabled ? 1 : 0 + role = aws_iam_role.webhook_auth_lambda[0].name + policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "webhook_auth_logging" { + count = local.webhook_auth_enabled ? 1 : 0 + + name = "logging-policy" + role = aws_iam_role.webhook_auth_lambda.name + policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.webhook_auth.arn + }) +} + +resource "aws_cloudwatch_log_group" "webhook_auth" { + name = "/aws/lambda/${aws_lambda_function.webhook_auth.function_name}" + retention_in_days = var.logging_retention_in_days + kms_key_id = var.logging_kms_key_id + tags = var.tags +}