Skip to content

Initial implementation of IPv4 allow list in the webhook endpoint #4205

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +82,7 @@ jobs:
"ssm",
"termination-watcher",
"webhook",
"webhook-auth",
]
defaults:
run:
Expand Down
17 changes: 17 additions & 0 deletions lambdas/functions/webhook-auth/jest.config.ts
Original file line number Diff line number Diff line change
@@ -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;
60 changes: 60 additions & 0 deletions lambdas/functions/webhook-auth/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
111 changes: 111 additions & 0 deletions lambdas/functions/webhook-auth/src/lambda.test.ts
Original file line number Diff line number Diff line change
@@ -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&parameter1=value2&parameter2=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 });
});
});
53 changes: 53 additions & 0 deletions lambdas/functions/webhook-auth/src/lambda.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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;
}
}
6 changes: 6 additions & 0 deletions lambdas/functions/webhook-auth/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends" : "../../tsconfig.json",
"include": [
"src/**/*"
]
}
12 changes: 12 additions & 0 deletions modules/webhook/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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
}
32 changes: 32 additions & 0 deletions modules/webhook/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand All @@ -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."
}
}

Loading
Loading