Skip to content

Commit 51622b4

Browse files
committed
Initial implementation of IPv4 allow list in the webhook endooint
1 parent 3b9bba2 commit 51622b4

File tree

9 files changed

+384
-0
lines changed

9 files changed

+384
-0
lines changed

.github/workflows/terraform.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
- name: "Fake zip files" # Validate will fail if it cannot find the zip files
2828
run: |
2929
touch lambdas/functions/webhook/webhook.zip
30+
touch lambdas/functions/webhook/webhook-auth.zip
3031
touch lambdas/functions/control-plane/runners.zip
3132
touch lambdas/functions/gh-agent-syncer/runner-binaries-syncer.zip
3233
touch lambdas/functions/ami-housekeeper/ami-housekeeper.zip
@@ -81,6 +82,7 @@ jobs:
8182
"ssm",
8283
"termination-watcher",
8384
"webhook",
85+
"webhook-auth",
8486
]
8587
defaults:
8688
run:
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Config } from 'jest';
2+
3+
import defaultConfig from '../../jest.base.config';
4+
5+
const config: Config = {
6+
...defaultConfig,
7+
coverageThreshold: {
8+
global: {
9+
statements: 100,
10+
branches: 100,
11+
functions: 100,
12+
lines: 100,
13+
},
14+
},
15+
};
16+
17+
export default config;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"name": "@aws-github-runner/webhook-auth",
3+
"version": "1.0.0",
4+
"main": "lambda.ts",
5+
"license": "MIT",
6+
"scripts": {
7+
"start": "ts-node-dev src/local.ts",
8+
"test": "NODE_ENV=test nx test",
9+
"test:watch": "NODE_ENV=test nx test --watch",
10+
"lint": "yarn eslint src",
11+
"watch": "ts-node-dev --respawn --exit-child src/local.ts",
12+
"build": "ncc build src/lambda.ts -o dist",
13+
"dist": "yarn build && cd dist && zip ../webhook-auth.zip index.js",
14+
"format": "prettier --write \"**/*.ts\"",
15+
"format-check": "prettier --check \"**/*.ts\"",
16+
"all": "yarn build && yarn format && yarn lint && yarn test"
17+
},
18+
"devDependencies": {
19+
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
20+
"@types/aws-lambda": "^8.10.145",
21+
"@types/jest": "^29.5.12",
22+
"@types/node": "^22.5.4",
23+
"@typescript-eslint/eslint-plugin": "^8.9.0",
24+
"@typescript-eslint/parser": "^8.8.0",
25+
"@vercel/ncc": "^0.38.1",
26+
"aws-sdk-client-mock": "^4.0.2",
27+
"aws-sdk-client-mock-jest": "^4.0.1",
28+
"eslint": "^8.57.0",
29+
"eslint-plugin-prettier": "5.2.1",
30+
"jest": "^29.7.0",
31+
"jest-mock": "^29.7.0",
32+
"jest-mock-extended": "^3.0.7",
33+
"nock": "^13.5.4",
34+
"prettier": "3.3.3",
35+
"ts-jest": "^29.2.5",
36+
"ts-node": "^10.9.2",
37+
"ts-node-dev": "^2.0.0"
38+
},
39+
"dependencies": {
40+
"@aws-github-runner/aws-powertools-util": "*",
41+
"@aws-github-runner/aws-ssm-util": "*",
42+
"@aws-sdk/client-ec2": "^3.670.0",
43+
"@aws-sdk/client-ssm": "^3.670.0",
44+
"@aws-sdk/types": "^3.664.0",
45+
"cron-parser": "^4.9.0",
46+
"typescript": "^5.5.4"
47+
},
48+
"nx": {
49+
"includedScripts": [
50+
"build",
51+
"dist",
52+
"format",
53+
"format-check",
54+
"lint",
55+
"start",
56+
"watch",
57+
"all"
58+
]
59+
}
60+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { handler } from './lambda';
2+
import { Context } from 'aws-lambda';
3+
import { APIGatewayRequestAuthorizerEventV2 } from 'aws-lambda/trigger/api-gateway-authorizer';
4+
5+
const context: Context = {
6+
awsRequestId: '1',
7+
callbackWaitsForEmptyEventLoop: false,
8+
functionName: '',
9+
functionVersion: '',
10+
getRemainingTimeInMillis: () => 0,
11+
invokedFunctionArn: '',
12+
logGroupName: '',
13+
logStreamName: '',
14+
memoryLimitInMB: '',
15+
done: () => {
16+
return;
17+
},
18+
fail: () => {
19+
return;
20+
},
21+
succeed: () => {
22+
return;
23+
},
24+
};
25+
26+
// Pretty much copy/paste from here:
27+
// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html
28+
const event: APIGatewayRequestAuthorizerEventV2 = {
29+
version: '2.0',
30+
type: 'REQUEST',
31+
routeArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/test/GET/request',
32+
identitySource: ['user1', '123'],
33+
routeKey: '$default',
34+
rawPath: '/my/path',
35+
rawQueryString: 'parameter1=value1&parameter1=value2&parameter2=value',
36+
cookies: ['cookie1', 'cookie2'],
37+
headers: {
38+
header1: 'value1',
39+
header2: 'value2',
40+
},
41+
queryStringParameters: {
42+
parameter1: 'value1,value2',
43+
parameter2: 'value',
44+
},
45+
requestContext: {
46+
accountId: '123456789012',
47+
apiId: 'api-id',
48+
authentication: {
49+
clientCert: {
50+
clientCertPem: 'CERT_CONTENT',
51+
subjectDN: 'www.example.com',
52+
issuerDN: 'Example issuer',
53+
serialNumber: '1',
54+
validity: {
55+
notBefore: 'May 28 12:30:02 2019 GMT',
56+
notAfter: 'Aug 5 09:36:04 2021 GMT',
57+
},
58+
},
59+
},
60+
domainName: 'id.execute-api.us-east-1.amazonaws.com',
61+
domainPrefix: 'id',
62+
http: {
63+
method: 'POST',
64+
path: '/my/path',
65+
protocol: 'HTTP/1.1',
66+
sourceIp: '81.123.56.13',
67+
userAgent: 'agent',
68+
},
69+
requestId: 'id',
70+
routeKey: '$default',
71+
stage: '$default',
72+
time: '12/Mar/2020:19:03:58 +0000',
73+
timeEpoch: 1583348638390,
74+
},
75+
pathParameters: { parameter1: 'value1' },
76+
stageVariables: { stageVariable1: 'value1', stageVariable2: 'value2' },
77+
};
78+
79+
jest.mock('@aws-github-runner/aws-powertools-util');
80+
81+
describe('Webhook auth', () => {
82+
beforeAll(() => {
83+
jest.resetAllMocks();
84+
});
85+
it('should not pass if env var does not exist', async () => {
86+
const result = await handler(event, context);
87+
expect(result).toEqual({ isAuthorized: false });
88+
});
89+
it('should pass the IP allow list using exact ip', async () => {
90+
process.env.CIDR_IPV4_ALLOW_LIST = '81.123.56.13/32,81.123.56.52/32,10.0.0.0/8';
91+
const result = await handler(event, context);
92+
expect(result).toEqual({ isAuthorized: true });
93+
});
94+
95+
it('should not pass the IP allow list.', async () => {
96+
process.env.CIDR_IPV4_ALLOW_LIST = '81.123.56.52/32,10.0.0.0/8';
97+
const result = await handler(event, context);
98+
expect(result).toEqual({ isAuthorized: false });
99+
});
100+
101+
it('should pass the IP allow list using CIDR range', async () => {
102+
process.env.CIDR_IPV4_ALLOW_LIST = '81.123.0.0/16,10.0.0.0/8';
103+
const result = await handler(event, context);
104+
expect(result).toEqual({ isAuthorized: true });
105+
});
106+
it('should not pass of CIDR_IPV4_ALLOW_LIST has the wrong format', async () => {
107+
process.env.CIDR_IPV4_ALLOW_LIST = '81.123.0.0/16,10.0.0.0';
108+
const result = await handler(event, context);
109+
expect(result).toEqual({ isAuthorized: false });
110+
});
111+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { logger, setContext } from '@aws-github-runner/aws-powertools-util';
2+
import { BlockList } from 'net';
3+
import { APIGatewayRequestAuthorizerEventV2 } from 'aws-lambda/trigger/api-gateway-authorizer';
4+
import { Context } from 'aws-lambda';
5+
6+
export interface Response {
7+
isAuthorized: boolean;
8+
}
9+
10+
const Allow: Response = {
11+
isAuthorized: true,
12+
};
13+
14+
const Deny: Response = {
15+
isAuthorized: false,
16+
};
17+
18+
export async function handler(event: APIGatewayRequestAuthorizerEventV2, context: Context): Promise<Response> {
19+
setContext(context, 'lambda.ts');
20+
logger.logEventIfEnabled(event);
21+
22+
const allowList = new BlockList();
23+
24+
const ipv4AllowList = process.env.CIDR_IPV4_ALLOW_LIST ?? null;
25+
26+
if (ipv4AllowList === null) {
27+
logger.error('CIDR_IPV4_ALLOW_LIST is not set.');
28+
return Deny;
29+
}
30+
31+
//Check if CIDR_IPV4_ALLOW_LIST matches the format of a comma-separated list of CIDR blocks
32+
const regex = new RegExp('^(\\d{1,3}\\.){3}\\d{1,3}\\/\\d{1,2}(,(\\d{1,3}\\.){3}\\d{1,3}\\/\\d{1,2})*$');
33+
if (!regex.test(ipv4AllowList)) {
34+
logger.error(
35+
'CIDR_IPV4_ALLOW_LIST is not in the correct format. ' +
36+
'Expected format is a comma-separated list of CIDR blocks. e.g. 10.0.0.0/8,81.32.12.3/32',
37+
);
38+
return Deny;
39+
}
40+
41+
ipv4AllowList.split(',').forEach((cidrBlock) => {
42+
const [subnet, mask] = cidrBlock.split('/');
43+
allowList.addSubnet(subnet, parseInt(mask), 'ipv4');
44+
});
45+
46+
const clientIP = event.requestContext.http.sourceIp;
47+
48+
if (allowList.check(clientIP)) {
49+
return Allow;
50+
} else {
51+
return Deny;
52+
}
53+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends" : "../../tsconfig.json",
3+
"include": [
4+
"src/**/*"
5+
]
6+
}

modules/webhook/main.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ locals {
22
webhook_endpoint = "webhook"
33
role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path
44
lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/webhook/webhook.zip" : var.lambda_zip
5+
auth_lambda_zip = var.auth_lambda_zip == null ? "${path.module}/../../lambdas/functions/webhook-auth/webhook-auth.zip" : var.auth_lambda_zip
56
}
67

78
resource "aws_apigatewayv2_api" "webhook" {
@@ -73,3 +74,14 @@ resource "aws_ssm_parameter" "runner_matcher_config" {
7374
value = jsonencode(local.runner_matcher_config_sorted)
7475
tier = var.matcher_config_parameter_store_tier
7576
}
77+
78+
resource "aws_apigatewayv2_authorizer" "webhook_auth" {
79+
count = local.webhook_auth_enabled ? 1 : 0
80+
81+
name = "webhook-auth"
82+
api_id = aws_apigatewayv2_api.webhook.id
83+
authorizer_type = "REQUEST"
84+
authorizer_uri = aws_lambda_function.webhook_auth[0].invoke_arn
85+
enable_simple_responses = true
86+
authorizer_result_ttl_in_seconds = 0
87+
}

modules/webhook/variables.tf

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ variable "lambda_zip" {
5454
default = null
5555
}
5656

57+
variable "auth_lambda_zip" {
58+
type = string
59+
default = null
60+
}
61+
5762
variable "lambda_memory_size" {
5863
description = "Memory size limit in MB for lambda."
5964
type = number
@@ -102,12 +107,22 @@ variable "webhook_lambda_s3_key" {
102107
default = null
103108
}
104109

110+
variable "webhook_auth_lambda_s3_key" {
111+
type = string
112+
default = null
113+
}
114+
105115
variable "webhook_lambda_s3_object_version" {
106116
description = "S3 object version for webhook lambda function. Useful if S3 versioning is enabled on source bucket."
107117
type = string
108118
default = null
109119
}
110120

121+
variable "webhook_auth_lambda_s3_object_version" {
122+
type = string
123+
default = null
124+
}
125+
111126
variable "webhook_lambda_apigateway_access_log_settings" {
112127
description = "Access log settings for webhook API gateway."
113128
type = object({
@@ -201,6 +216,11 @@ variable "lambda_tags" {
201216
default = {}
202217
}
203218

219+
variable "auth_lambda_tags" {
220+
type = map(string)
221+
default = {}
222+
}
223+
204224
variable "matcher_config_parameter_store_tier" {
205225
description = "The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`."
206226
type = string
@@ -210,3 +230,15 @@ variable "matcher_config_parameter_store_tier" {
210230
error_message = "`matcher_config_parameter_store_tier` value is not valid, valid values are: `Standard`, and `Advanced`."
211231
}
212232
}
233+
234+
variable "webhook_allow_list" {
235+
type = object({
236+
ipv4_cidr_blocks = optional(list(string), [])
237+
})
238+
239+
validation {
240+
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)])
241+
error_message = "Incorrect format for IPv4 CIDR range."
242+
}
243+
}
244+

0 commit comments

Comments
 (0)