diff --git a/README.md b/README.md index 95a87cfd..99ab68bf 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ route53:ChangeResourceRecordSets hostedzone/{HostedZoneId} route53:GetHostedZone * route53:ListResourceRecordSets * iam:CreateServiceLinkedRole arn:aws:iam::${AWS::AccountId}: role/aws-service-role/ops.apigateway.amazonaws.com/AWSServiceRoleForAPIGateway +s3:ListBucket * +s3:GetObject * ``` ### CloudFormation Alternatively you can generate an least privileged IAM Managed Policy for deployment with this: @@ -153,6 +155,8 @@ custom: | route53Region | `(none)` | Region to send Route53 services requests to (only applicable if also using route53Profile option) | | endpointType | edge | Defines the endpoint type, accepts `regional` or `edge`. | | apiType | rest | Defines the api type, accepts `rest`, `http` or `websocket`. | +| tlsTruststoreUri | `undefined` | An Amazon S3 url that specifies the truststore for mutual TLS authentication, for example `s3://bucket-name/key-name`. The truststore can contain certificates from public or private certificate authorities. Be aware mutual TLS is only available for `regional` APIs. | +| tlsTruststoreVersion | `undefined` | The version of the S3 object that contains your truststore. To specify a version, you must have versioning enabled for the S3 bucket. | | hostedZoneId | | If hostedZoneId is set the route53 record set will be created in the matching zone, otherwise the hosted zone will be figured out from the domainName (hosted zone with matching domain). | | hostedZonePrivate | | If hostedZonePrivate is set to `true` then only private hosted zones will be used for route 53 records. If it is set to `false` then only public hosted zones will be used for route53 records. Setting this parameter is specially useful if you have multiple hosted zones with the same domain name (e.g. a public and a private one) | | enabled | true | Sometimes there are stages for which is not desired to have custom domain names. This flag allows the developer to disable the plugin for such cases. Accepts either `boolean` or `string` values and defaults to `true` for backwards compatibility. | diff --git a/src/aws/api-gateway-wrapper.ts b/src/aws/api-gateway-wrapper.ts index 5f6eaaa0..7766114f 100644 --- a/src/aws/api-gateway-wrapper.ts +++ b/src/aws/api-gateway-wrapper.ts @@ -29,6 +29,7 @@ class APIGatewayWrapper { // For EDGE domain name or TLS 1.0, create with APIGateway (v1) const isEdgeType = domain.endpointType === Globals.endpointTypes.edge; + const hasMutualTls = !!domain.tlsTruststoreUri; if (isEdgeType || domain.securityPolicy === "TLS_1_0") { // Set up parameters const params = { @@ -41,6 +42,16 @@ class APIGatewayWrapper { tags: providerTags, }; + if (!isEdgeType && hasMutualTls) { + params.mutualTlsAuthentication = { + truststoreUri: domain.tlsTruststoreUri + }; + + if (domain.tlsTruststoreVersion) { + params.truststoreVersion = domain.tlsTruststoreVersion; + } + } + // Make API call to create domain try { // Creating EDGE domain so use APIGateway (v1) service @@ -51,7 +62,7 @@ class APIGatewayWrapper { } } else { // For Regional domain name create with ApiGatewayV2 - const params = { + const params: any = { DomainName: domain.givenDomainName, DomainNameConfigurations: [{ CertificateArn: domain.certificateArn, @@ -61,6 +72,16 @@ class APIGatewayWrapper { Tags: providerTags }; + if (!isEdgeType && hasMutualTls) { + params.MutualTlsAuthentication = { + TruststoreUri: domain.tlsTruststoreUri + }; + + if (domain.tlsTruststoreVersion) { + params.TruststoreVersion = domain.tlsTruststoreVersion; + } + } + // Make API call to create domain try { // Creating Regional domain so use ApiGatewayV2 diff --git a/src/aws/s3-wrapper.ts b/src/aws/s3-wrapper.ts new file mode 100644 index 00000000..745d1314 --- /dev/null +++ b/src/aws/s3-wrapper.ts @@ -0,0 +1,45 @@ +import {S3} from "aws-sdk"; +import {throttledCall} from "../utils"; +import DomainConfig = require("../domain-config"); +import Globals from "../globals"; + +class S3Wrapper { + public s3: S3; + + constructor(credentials: any) { + this.s3 = new S3(credentials); + } + + /** + * * Checks whether the Mutual TLS certificate exists in S3 or not + */ + public async assertTlsCertObjectExists(domain: DomainConfig): Promise { + try { + const {Bucket, Key} = this.extractBucketAndKey(domain.tlsTruststoreUri); + const params: S3.Types.HeadObjectRequest = {Bucket, Key}; + + if (domain.tlsTruststoreVersion) { + params.VersionId = domain.tlsTruststoreVersion; + } + + await throttledCall(this.s3, "headObject", params); + } catch (err) { + if (err.code !== "AccessDenied") { + throw Error(`Could not head S3 object at ${domain.tlsTruststoreUri}.\n${err.message}`); + } + + Globals.logWarning(`Unable to check existance of S3 object at ${domain.tlsTruststoreUri} due to\n${err.message}`); + } + } + + /** + * * Extracts Bucket and Key from the given s3 uri + */ + private extractBucketAndKey(uri: string): { Bucket: string; Key: string } { + const { hostname, pathname } = new URL(uri); + + return { Bucket: hostname, Key: pathname.substring(1) }; + } +} + +export = S3Wrapper; diff --git a/src/domain-config.ts b/src/domain-config.ts index de5d6bf3..d5da9a89 100644 --- a/src/domain-config.ts +++ b/src/domain-config.ts @@ -21,6 +21,8 @@ class DomainConfig { public route53Region: string | undefined; public endpointType: string | undefined; public apiType: string | undefined; + public tlsTruststoreUri: string | undefined; + public tlsTruststoreVersion: string | undefined; public hostedZoneId: string | undefined; public hostedZonePrivate: boolean | undefined; public enabled: boolean | string | undefined; @@ -78,6 +80,17 @@ class DomainConfig { } this.apiType = apiTypeToUse; + const isEdgeType = this.endpointType === Globals.endpointTypes.edge; + const hasMutualTls = !!config.tlsTruststoreUri; + if (isEdgeType && hasMutualTls) { + throw new Error(`${this.endpointType} APIs do not support mutual TLS, remove tlsTruststoreUri or change to a regional API.`); + } + if (config.tlsTruststoreUri) { + this.validateS3Uri(config.tlsTruststoreUri); + } + this.tlsTruststoreUri = config.tlsTruststoreUri; + this.tlsTruststoreVersion = config.tlsTruststoreVersion; + const securityPolicyDefault = config.securityPolicy || Globals.tlsVersions.tls_1_2; const tlsVersionToUse = Globals.tlsVersions[securityPolicyDefault.toLowerCase()]; if (!tlsVersionToUse) { @@ -106,6 +119,14 @@ class DomainConfig { healthCheckId: config.route53Params?.healthCheckId } } + + private validateS3Uri(uri: string): void { + const { protocol, pathname } = new URL(uri); + + if (protocol !== "s3:" && !pathname.substring(1).includes("/")) { + throw new Error(`${uri} is not a valid s3 uri, try something like s3://bucket-name/key-name.`); + } + } } export = DomainConfig; diff --git a/src/index.ts b/src/index.ts index 56933a23..30fd13cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import ACMWrapper = require("./aws/acm-wrapper"); import APIGatewayWrapper = require("./aws/api-gateway-wrapper"); import CloudFormationWrapper = require("./aws/cloud-formation-wrapper"); import Route53Wrapper = require("./aws/route53-wrapper"); +import S3Wrapper = require("./aws/s3-wrapper"); import DomainConfig = require("./domain-config"); import Globals from "./globals"; import {CustomDomain, ServerlessInstance, ServerlessOptions, ServerlessUtils} from "./types"; @@ -14,6 +15,7 @@ class ServerlessCustomDomain { // AWS SDK resources public apiGatewayWrapper: APIGatewayWrapper; public cloudFormationWrapper: CloudFormationWrapper; + public s3Wrapper: S3Wrapper; // Serverless specific properties public serverless: ServerlessInstance; @@ -168,6 +170,7 @@ class ServerlessCustomDomain { this.apiGatewayWrapper = new APIGatewayWrapper(credentials); this.cloudFormationWrapper = new CloudFormationWrapper(credentials); + this.s3Wrapper = new S3Wrapper(credentials); } /** @@ -190,6 +193,10 @@ class ServerlessCustomDomain { const route53 = new Route53Wrapper(domain.route53Profile, domain.route53Region); const acm = new ACMWrapper(domain.endpointType); try { + if (domain.tlsTruststoreUri) { + await this.s3Wrapper.assertTlsCertObjectExists(domain); + } + if (!domain.domainInfo) { if (!domain.certificateArn) { const searchName = domain.certificateName || domain.givenDomainName; diff --git a/src/types.ts b/src/types.ts index 2230186c..a1f6e35a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,8 @@ export interface CustomDomain { // tslint:disable-line route53Region: string | undefined; endpointType: string | undefined; apiType: string | undefined; + tlsTruststoreUri: string | undefined; + tlsTruststoreVersion: string | undefined; hostedZoneId: string | undefined; hostedZonePrivate: boolean | undefined; enabled: boolean | string | undefined; @@ -52,6 +54,7 @@ export interface ServerlessInstance { // tslint:disable-line Route53: any, CloudFormation: any, ACM: any, + S3: any, config: { httpOptions: HTTPOptions, update(toUpdate: object): void, diff --git a/test/unit-tests/index.test.ts b/test/unit-tests/index.test.ts index 55973d3c..4bc7b69e 100644 --- a/test/unit-tests/index.test.ts +++ b/test/unit-tests/index.test.ts @@ -10,6 +10,7 @@ import ServerlessCustomDomain = require("../../src/index"); import {getAWSPagedResults} from "../../src/utils"; import Route53Wrapper = require("../../src/aws/route53-wrapper"); import ACMWrapper = require("../../src/aws/acm-wrapper"); +import S3Wrapper = require("../../src/aws/s3-wrapper"); const expect = chai.expect; chai.use(spies); @@ -58,6 +59,8 @@ const constructPlugin = (customDomainOptions, multiple: boolean = false) => { domainName: customDomainOptions.domainName, enabled: customDomainOptions.enabled, endpointType: customDomainOptions.endpointType, + tlsTruststoreUri: customDomainOptions.tlsTruststoreUri, + tlsTruststoreVersion: customDomainOptions.tlsTruststoreVersion, hostedZoneId: customDomainOptions.hostedZoneId, hostedZonePrivate: customDomainOptions.hostedZonePrivate, route53Profile: customDomainOptions.route53Profile, @@ -88,6 +91,7 @@ const constructPlugin = (customDomainOptions, multiple: boolean = false) => { CloudFormation: aws.CloudFormation, Route53: aws.Route53, SharedIniFileCredentials: aws.SharedIniFileCredentials, + S3: aws.S3, config: { httpOptions: { timeout: 5000, @@ -568,6 +572,107 @@ describe("Custom Domain Plugin", () => { }); }); + describe("Check Mutual TLS certificate existance in S3", () => { + it("Should check existance of certificate in S3", async () => { + AWS.mock("S3", "headObject", (params, callback) => { + callback(null, params); + }); + const options = { + domainName: "test_domain", + endpointType: "regional", + tlsTruststoreUri: 's3://test_bucket/test_key' + }; + const plugin = constructPlugin(options); + plugin.initializeVariables(); + + const s3Wrapper = new S3Wrapper({}); + const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); + + const spy = chai.spy.on(s3Wrapper.s3, "headObject"); + await s3Wrapper.assertTlsCertObjectExists(dc); + const expectedParams = { + Bucket: 'test_bucket', + Key: 'test_key' + } + expect(spy).to.have.been.called.with(expectedParams); + }); + + it("Should check existance of a concrete certificate version in S3", async () => { + AWS.mock("S3", "headObject", (params, callback) => { + callback(null, params); + }); + const options = { + domainName: "test_domain", + endpointType: "regional", + tlsTruststoreUri: 's3://test_bucket/test_key', + tlsTruststoreVersion: 'test_version' + }; + const plugin = constructPlugin(options); + plugin.initializeVariables(); + + const s3Wrapper = new S3Wrapper({}); + const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); + + const spy = chai.spy.on(s3Wrapper.s3, "headObject"); + await s3Wrapper.assertTlsCertObjectExists(dc); + const expectedParams = { + Bucket: 'test_bucket', + Key: 'test_key', + VersionId: 'test_version' + } + expect(spy).to.have.been.called.with(expectedParams); + }); + + it('should fail when the mutual TLS certificate is not stored in S3', async () => { + AWS.mock("S3", "headObject", (params, callback) => { + // @ts-ignore + callback({code: 'NotFound'}, null); + }); + const options = { + domainName: "test_domain", + endpointType: "regional", + tlsTruststoreUri: 's3://test_bucket/test_key' + }; + const plugin = constructPlugin(options); + plugin.initializeVariables(); + + const s3Wrapper = new S3Wrapper({}); + const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); + + try { + await s3Wrapper.assertTlsCertObjectExists(dc); + } catch(e) { + expect(e.message).to.contain('Could not head S3 object'); + } + }); + + it("Should not fail due to lack of S3 permissions", async () => { + AWS.mock("S3", "headObject", (params, callback) => { + // @ts-ignore + callback({code: 'AccessDenied'}, null); + }); + const options = { + domainName: "test_domain", + endpointType: "regional", + tlsTruststoreUri: 's3://test_bucket/test_key' + }; + const plugin = constructPlugin(options); + plugin.initializeVariables(); + + const s3Wrapper = new S3Wrapper({}); + const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); + + let err; + try { + await s3Wrapper.assertTlsCertObjectExists(dc); + } catch(e) { + err = e; + } finally { + expect(err).to.equal(undefined); + } + }); + }); + describe("Create a New Domain Name", () => { it("Get a given certificate by given domain name ", async () => { AWS.mock("ACM", "listCertificates", certTestData); @@ -709,6 +814,80 @@ describe("Custom Domain Plugin", () => { expect(spy).to.have.been.called.with(expectedParams); }); + it("Create a domain name with mutual TLS authentication", async () => { + AWS.mock("APIGateway", "createDomainName", (params, callback) => { + callback(null, {distributionDomainName: "foo", securityPolicy: "TLS_1_0"}); + }); + + const plugin = constructPlugin({ + domainName: "test_domain", + endpointType: "regional", + securityPolicy: "tls_1_0", + tlsTruststoreUri: "s3://bucket-name/key-name" + }); + plugin.initializeVariables(); + plugin.initAWSResources(); + + const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); + dc.certificateArn = "fake_cert"; + + const spy = chai.spy.on(plugin.apiGatewayWrapper.apiGateway, "createDomainName"); + await plugin.apiGatewayWrapper.createCustomDomain(dc); + const expectedParams = { + domainName: dc.givenDomainName, + endpointConfiguration: { + types: [dc.endpointType], + }, + mutualTlsAuthentication: { + truststoreUri: dc.tlsTruststoreUri + }, + securityPolicy: dc.securityPolicy, + tags: { + ...plugin.serverless.service.provider.stackTags, + ...plugin.serverless.service.provider.tags, + }, + regionalCertificateArn: dc.certificateArn + } + expect(spy).to.have.been.called.with(expectedParams); + }); + + it("Create an HTTP domain name with mutual TLS authentication", async () => { + AWS.mock("ApiGatewayV2", "createDomainName", (params, callback) => { + callback(null, params); + }); + + const plugin = constructPlugin({ + domainName: "test_domain", + endpointType: "regional", + apiType: "http", + tlsTruststoreUri: "s3://bucket-name/key-name" + }); + plugin.initializeVariables(); + plugin.initAWSResources(); + + const dc: DomainConfig = new DomainConfig(plugin.serverless.service.custom.customDomain); + dc.certificateArn = "fake_cert"; + + const spy = chai.spy.on(plugin.apiGatewayWrapper.apiGatewayV2, "createDomainName"); + await plugin.apiGatewayWrapper.createCustomDomain(dc); + const expectedParams = { + DomainName: dc.givenDomainName, + DomainNameConfigurations: [{ + CertificateArn: dc.certificateArn, + EndpointType: dc.endpointType, + SecurityPolicy: dc.securityPolicy + }], + MutualTlsAuthentication: { + TruststoreUri: dc.tlsTruststoreUri + }, + Tags: { + ...plugin.serverless.service.provider.stackTags, + ...plugin.serverless.service.provider.tags, + } + } + expect(spy).to.have.been.called.with(expectedParams); + }); + it("Create new A and AAAA Alias Records", async () => { AWS.mock("Route53", "listHostedZones", (params, callback) => { // @ts-ignore @@ -1623,6 +1802,29 @@ describe("Custom Domain Plugin", () => { expect(consoleOutput[0]).to.contain("test message"); }); + it('should fail when the mutual TLS certificate is not stored in S3', async () => { + AWS.mock("ApiGatewayV2", "getDomainName", (params, callback) => { + callback(null, {DomainName: "test_domain", DomainNameConfigurations: [{HostedZoneId: "test_id"}]}); + }); + AWS.mock("S3", "headObject", (params, callback) => { + // @ts-ignore + callback({code: 'NotFound'}, null); + }); + const plugin = constructPlugin({ + domainName: "test_domain", + endpointType: "regional", + tlsTruststoreUri: 's3://test_bucket/test_key' + }); + plugin.initializeVariables(); + plugin.initAWSResources(); + + try { + await plugin.createDomains(); + } catch(e) { + expect(e.message).to.contain('Could not head S3 object'); + } + }); + afterEach(() => { AWS.restore(); consoleOutput = []; @@ -1780,6 +1982,32 @@ describe("Custom Domain Plugin", () => { expect(errored).to.equal(true); }); + it("Should throw an Error when mutual TLS is enabled for edge APIs", async () => { + const plugin = constructPlugin({endpointType: "edge", tlsTruststoreUri: "s3://bucket-name/key-name"}); + + let errored = false; + try { + await plugin.hookWrapper(null); + } catch (err) { + errored = true; + expect(err.message).to.equal(`EDGE APIs do not support mutual TLS, remove tlsTruststoreUri or change to a regional API.`); + } + expect(errored).to.equal(true); + }); + + it("Should throw an Error when mutual TLS uri is not an S3 uri", async () => { + const plugin = constructPlugin({endpointType: "regional", tlsTruststoreUri: "http://example.com"}); + + let errored = false; + try { + await plugin.hookWrapper(null); + } catch (err) { + errored = true; + expect(err.message).to.equal(`http://example.com is not a valid s3 uri, try something like s3://bucket-name/key-name.`); + } + expect(errored).to.equal(true); + }); + afterEach(() => { consoleOutput = []; });