cf-custom-resources/lib/wkld-cert-validator.js (624 lines of code) (raw):

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 const { fromEnv, fromTemporaryCredentials } = require("@aws-sdk/credential-providers"); const { ACM, DescribeCertificateCommand, DeleteCertificateCommand, RequestCertificateCommand, waitUntilCertificateValidated } = require("@aws-sdk/client-acm"); const { ResourceGroupsTaggingAPI, GetResourcesCommand } = require("@aws-sdk/client-resource-groups-tagging-api"); const { Route53, ListResourceRecordSetsCommand, ChangeResourceRecordSetsCommand, ListHostedZonesByNameCommand, waitUntilResourceRecordSetsChanged } = require("@aws-sdk/client-route-53"); const CRYPTO = require("crypto"); const ATTEMPTS_VALIDATION_OPTIONS_READY = 10; const ATTEMPTS_RECORD_SETS_CHANGE = 10; const DELAY_RECORD_SETS_CHANGE_IN_S = 30; const ATTEMPTS_CERTIFICATE_VALIDATED = 19; const ATTEMPTS_CERTIFICATE_NOT_IN_USE = 12; const DELAY_CERTIFICATE_VALIDATED_IN_S = 30; let envHostedZoneID, appName, envName, serviceName, certificateDomain, domainTypes, rootDNSRole, domainName, isCloudFrontCert; let defaultSleep = function (ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }; let sleep = defaultSleep; let random = Math.random; const appRoute53Context = () => { let client; return () => { if (!client) { client = new Route53({ credentials: fromTemporaryCredentials({ params: { RoleArn: rootDNSRole }, masterCredentials: fromEnv("AWS"), }), }); } return client; }; }; const envRoute53Context = () => { let client; return () => { if (!client) { client = new Route53(); } return client; }; }; const acmContext = () => { let client; return () => { if (!client) { client = new ACM({ region: isCloudFrontCert ? "us-east-1" : undefined, }); } return client; }; }; const resourceGroupsTaggingAPIContext = () => { let client; return () => { if (!client) { client = new ResourceGroupsTaggingAPI({ region: isCloudFrontCert ? "us-east-1" : undefined, }); } return client; }; }; const clients = { app: { route53: appRoute53Context(), }, root: { route53: appRoute53Context(), }, env: { route53: envRoute53Context(), }, acm: acmContext(), resourceGroupsTaggingAPI: resourceGroupsTaggingAPIContext(), }; const appHostedZoneIDContext = () => { let id; return async () => { if (!id) { id = await hostedZoneIDByName(`${appName}.${domainName}`); } return id; }; }; const rootHostedZoneIDContext = () => { let id; return async () => { if (!id) { id = await hostedZoneIDByName(`${domainName}`); } return id; }; }; let hostedZoneID = { app: appHostedZoneIDContext(), root: rootHostedZoneIDContext(), }; /** * Upload a CloudFormation response object to S3. * * @param {object} event the Lambda event payload received by the handler function * @param {object} context the Lambda context received by the handler function * @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED' * @param {string} physicalResourceId CloudFormation physical resource ID * @param {object} [responseData] arbitrary response data object * @param {string} [reason] reason for failure, if any, to convey to the user * @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response */ function report(event, context, responseStatus, physicalResourceId, responseData, reason) { return new Promise((resolve, reject) => { const https = require("https"); const { URL } = require("url"); let reasonWithLogInfo = `${reason} (Log: ${context.logGroupName}/${context.logStreamName})`; let responseBody = JSON.stringify({ Status: responseStatus, Reason: reasonWithLogInfo, PhysicalResourceId: physicalResourceId || context.logStreamName, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, Data: responseData, }); const parsedUrl = new URL(event.ResponseURL); const options = { hostname: parsedUrl.hostname, port: 443, path: parsedUrl.pathname + parsedUrl.search, method: "PUT", headers: { "Content-Type": "", "Content-Length": responseBody.length, }, }; https .request(options) .on("error", reject) .on("response", (res) => { res.resume(); if (res.statusCode >= 400) { reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`)); } else { resolve(); } }) .end(responseBody, "utf8"); }); } exports.handler = async function (event, context) { // Destruct resource properties into local variables. const props = event.ResourceProperties; // TODO: LoadBalancerDNS only exists when we use this for NLB (not for Static Site CloudFront). Eventually when we switch to use Golang, // we'll make it dedicated to NLB or Static Site and reuse common logic. let { LoadBalancerDNS: loadBalancerDNS } = props; const aliases = new Set(props.Aliases); // Initialize global variables. envHostedZoneID = props.EnvHostedZoneId; envName = props.EnvName; appName = props.AppName; serviceName = props.ServiceName; domainName = props.DomainName; rootDNSRole = props.RootDNSRole; isCloudFrontCert = props.IsCloudFrontCertificate; certificateDomain = isCloudFrontCert ? `${serviceName}.${envName}.${appName}.${domainName}` : `${serviceName}-nlb.${envName}.${appName}.${domainName}`; domainTypes = { EnvDomainZone: { regex: new RegExp(`^([^\.]+\.)?${envName}.${appName}.${domainName}`), domain: `${envName}.${appName}.${domainName}`, }, AppDomainZone: { regex: new RegExp(`^([^\.]+\.)?${appName}.${domainName}`), domain: `${appName}.${domainName}`, }, RootDomainZone: { regex: new RegExp(`^([^\.]+\.)?${domainName}`), domain: `${domainName}`, }, }; let aliasesSorted = [...aliases].sort().join(","); let physicalResourceID = event.PhysicalResourceId; // The certificate ARN. By default, keep old physical resource ID unchanged. let handler = async function () { switch (event.RequestType) { case "Update": let oldAliases = new Set(event.OldResourceProperties.Aliases); let oldAliasesSorted = [...oldAliases].sort().join(","); if (oldAliasesSorted === aliasesSorted) { break; } // Fallthrough to "Create". When the aliases are different, the same actions are taken for both "Update" and "Create". case "Create": await validateAliases(aliases, loadBalancerDNS); const certificateARN = await requestCertificate({ aliases: aliases, idempotencyToken: CRYPTO.createHash("md5").update(`/${serviceName}/${aliasesSorted}`).digest("hex"), }); physicalResourceID = certificateARN; // Update the physical resource ID if a new certificate is created. const options = await waitForValidationOptionsToBeReady(certificateARN, aliases); await validate(certificateARN, options); break; case "Delete": if (!physicalResourceID || !physicalResourceID.startsWith("arn:")) { // This means no certificate has been created, nor any records. Exit without doing anything. break; } let unusedOptions = await unusedValidationOptions(physicalResourceID, loadBalancerDNS); await devalidate(unusedOptions); await deleteCertificate(physicalResourceID); break; default: throw new Error(`Unsupported request type ${event.RequestType}`); } }; try { await Promise.race([exports.deadlineExpired(), handler()]); await report(event, context, "SUCCESS", physicalResourceID); } catch (err) { console.log(`Caught error for service ${serviceName}: ${err.message}`); await report(event, context, "FAILED", physicalResourceID, null, err.message); } }; /** * Delete the certificate. * * @param certARN The ARN of the certificate to delete. * @returns {Promise<void>} */ async function deleteCertificate(certARN) { // NOTE: wait for certificate to be not in-used. let attempt; for (attempt = 0; attempt < ATTEMPTS_CERTIFICATE_NOT_IN_USE; attempt++) { let certificate; try { ({ Certificate: certificate } = await clients .acm() .send(new DescribeCertificateCommand({ CertificateArn: certARN, }))); } catch (err) { if (err.name === "ResourceNotFoundException") { return; } throw err; } if (!certificate.InUseBy || certificate.InUseBy.length <= 0) { break; } await sleep(30000); } if (attempt >= ATTEMPTS_CERTIFICATE_NOT_IN_USE) { throw new Error(`Certificate still in use after checking for ${ATTEMPTS_CERTIFICATE_NOT_IN_USE} attempts.`); } await clients .acm() .send(new DeleteCertificateCommand({ CertificateArn: certARN })) .catch((err) => { if (err.name !== "ResourceNotFoundException") { throw err; } }); } /** * Validate that the aliases are not in use. * * @param {Set<String>} aliases for the service. * @param {String} loadBalancerDNS the DNS of the service's load balancer. * @throws error if at least one of the aliases is not valid. */ async function validateAliases(aliases, loadBalancerDNS) { let promises = []; for (let alias of aliases) { let { hostedZoneID, route53Client } = await domainResources(alias); const promise = route53Client .send(new ListResourceRecordSetsCommand({ HostedZoneId: hostedZoneID, MaxItems: "1", StartRecordName: alias, StartRecordType: "A", })) .then(({ ResourceRecordSets: recordSet }) => { if (!targetRecordExists(alias, recordSet)) { return; } if (recordSet[0].Type !== "A") { return; } let aliasTarget = recordSet[0].AliasTarget; // If loadBalancerDNS is empty, it means we are using this lambda for dedicated CloudFront for Static Site. CloudFront can't perform the same validation, // because passing the CF domain would introduce a circular dependency (the CF can't be created/updated before cert is validated). // And in this scenario we can just error out if an A-record exists. if (aliasTarget && loadBalancerDNS && aliasTarget.DNSName.toLowerCase() === `${loadBalancerDNS.toLowerCase()}.`) { return; // The record is an alias record and is in use by myself, hence valid. } if (aliasTarget) { throw new Error(`Alias ${alias} is already in use by ${aliasTarget.DNSName}. This could be another load balancer of a different service.`); } throw new Error(`Alias ${alias} is already in use`); }); promises.push(promise); } await Promise.all(promises); } /** * Requests a public certificate from AWS Certificate Manager, using DNS validation. * * @param {Object} requestCertificateInput is the input to requestCertificate, containing the alias and idempotencyToken. * @return {String} The ARN of the requested certificate. */ async function requestCertificate({ aliases, idempotencyToken }) { const { CertificateArn } = await clients .acm() .send(new RequestCertificateCommand({ DomainName: certificateDomain, IdempotencyToken: idempotencyToken, SubjectAlternativeNames: aliases.size === 0 ? null : [...aliases], Tags: [ { Key: "copilot-application", Value: appName, }, { Key: "copilot-environment", Value: envName, }, { Key: "copilot-service", Value: serviceName, }, ], ValidationMethod: "DNS", })); return CertificateArn; } /** * Wait until the validation options are ready * * @param certificateARN * @param {Set<String>} aliases for the service. */ async function waitForValidationOptionsToBeReady(certificateARN, aliases) { // If the certificate domain is one of the aliases, expect one validation option for each alias. // Otherwise, include an extra validation option for the certificate domain itself. let expectedCount = aliases.has(certificateDomain) ? aliases.size : aliases.size + 1; let attempt; // TODO: This wait loops could be further abstracted. for (attempt = 0; attempt < ATTEMPTS_VALIDATION_OPTIONS_READY; attempt++) { let readyCount = 0; const { Certificate } = await clients .acm() .send(new DescribeCertificateCommand({ CertificateArn: certificateARN, })); const options = Certificate.DomainValidationOptions || []; options.forEach((option) => { if (option.ResourceRecord && (aliases.has(option.DomainName) || option.DomainName.toLowerCase() === certificateDomain.toLowerCase())) { readyCount++; } }); if (readyCount === expectedCount) { return options; } // Exponential backoff with jitter based on 200ms base // component of backoff fixed to ensure minimum total wait time on // slow targets. const base = Math.pow(2, attempt); await sleep(random() * base * 50 + base * 150); } throw new Error(`resource validation records are not ready after ${attempt} tries`); } /** * Validate the certificate. * * @param {String} certificateARN * @param {Array<Object>} validationOptions */ async function validate(certificateARN, validationOptions) { let promises = []; for (let option of validationOptions) { promises.push(validateOption(option)); } await Promise.all(promises); await exports.waitForCertificateValidation(certificateARN, clients.acm()); } const waitForCertificateValidation = async function (certificateARN, acm) { // Wait up to 9 minutes and 30 seconds await waitUntilCertificateValidated({ client: acm, maxWaitTime: ATTEMPTS_CERTIFICATE_VALIDATED * DELAY_CERTIFICATE_VALIDATED_IN_S, minDelay: DELAY_CERTIFICATE_VALIDATED_IN_S, // linear backoff with jitter. maxDelay: DELAY_CERTIFICATE_VALIDATED_IN_S, },{ CertificateArn: certificateARN, }, ); }; const waitForRecordChange = async function (route53, changeId) { // wait upto 5 minutes. await waitUntilResourceRecordSetsChanged({ client: route53, maxWaitTime: ATTEMPTS_RECORD_SETS_CHANGE * DELAY_RECORD_SETS_CHANGE_IN_S, minDelay: DELAY_RECORD_SETS_CHANGE_IN_S, maxDelay: DELAY_RECORD_SETS_CHANGE_IN_S, },{ Id: changeId, }); }; /** * Upsert the validation record for the alias. * * @param {Object} option */ async function validateOption(option) { let changes = [ { Action: "UPSERT", ResourceRecordSet: { Name: option.ResourceRecord.Name, Type: option.ResourceRecord.Type, TTL: 60, ResourceRecords: [ { Value: option.ResourceRecord.Value, }, ], }, }, ]; let { hostedZoneID, route53Client } = await domainResources(option.DomainName); let { ChangeInfo } = await route53Client .send(new ChangeResourceRecordSetsCommand({ ChangeBatch: { Comment: `Validate the certificate for the alias ${option.DomainName}`, Changes: changes, }, HostedZoneId: hostedZoneID, })); await exports.waitForRecordChange(route53Client,ChangeInfo.Id); } /** * Retrieve validation options that will be unused by any service. * * @param {String} ownedCertARN The ARN of the certificate that this custom resource manages. * @param {String} loadBalancerDNS The DNS of the load balancer used by this service. * @returns {Promise<Set<Object>>} */ async function unusedValidationOptions(ownedCertARN, loadBalancerDNS) { // Look for validation options that will be no longer needed by this service. const certificates = await serviceCertificates(); const { certOwned: certPendingDeletion, otherCerts: certInUse } = categorizeCertificates(certificates, ownedCertARN); if (!certPendingDeletion) { // Cannot find the certificate that is pending deletion; perhaps it is deleted already. Exit peacefully. return new Set(); } let optionsPendingDeletion = await unusedOptionsByService(certPendingDeletion, certInUse); // For each of the options pending deletion, validate if it is in use by other services. If it is, Copilot // will not delete it. let promises = []; for (const option of optionsPendingDeletion) { const domainName = option["DomainName"]; // NOTE: The client is initialized outside of the `inUseByOtherServices` function because AWS-SDK mocks cannot // mock its API calls if it is initialized in a callback. let route53Client; try { ({ route53Client } = await domainResources(domainName)); } catch (err) { // NOTE: The UnrecognizedDomainTypeError is swallowed here because it is preferably handled inside // `inUseByOtherServices`. if (!err instanceof UnrecognizedDomainTypeError) { throw err; } } const promise = inUseByOtherServices(loadBalancerDNS, domainName, route53Client).then((isUsed) => { if (isUsed) { optionsPendingDeletion.delete(option); } }); promises.push(promise); } await Promise.all(promises); return optionsPendingDeletion; } /** * De-validate the certificate by removing its validation options. * @param {Object} unusedOptions * @returns {Promise<void>} */ async function devalidate(unusedOptions) { let promises = []; for (let option of unusedOptions) { promises.push(devalidateOption(option)); } await Promise.all(promises); } /** * Delete the validation option from its corresponding hosted zone. * @param {Object} option * @returns {Promise<void>} */ async function devalidateOption(option) { let changes = [ { Action: "DELETE", ResourceRecordSet: { Name: option.ResourceRecord.Name, Type: option.ResourceRecord.Type, TTL: 60, ResourceRecords: [ { Value: option.ResourceRecord.Value, }, ], }, }, ]; let { hostedZoneID, route53Client } = await domainResources(option.DomainName); let changeResourceRecordSetsInput = { ChangeBatch: { Comment: `Delete the validation record for ${option.DomainName}`, Changes: changes, }, HostedZoneId: hostedZoneID, }; let changeInfo; try { ({ ChangeInfo: changeInfo } = await route53Client.send(new ChangeResourceRecordSetsCommand(changeResourceRecordSetsInput))); } catch (e) { let recordSetNotFoundErrMessageRegex = new RegExp(".*Tried to delete resource record set.*but it was not found.*"); if (recordSetNotFoundErrMessageRegex.test(e.message)) { return; // If we attempt to `DELETE` a record that doesn't exist, the job is already done, skip waiting. } throw new Error(`delete record ${option.ResourceRecord.Name}: ` + e.message); } await exports.waitForRecordChange(route53Client,changeInfo.Id) } /** * Retrieve all certificates used for the service and cache the results. * @returns {Array<Object>} An array of descriptions for the certificates used by the service. */ async function serviceCertificates() { let { ResourceTagMappingList } = await clients .resourceGroupsTaggingAPI() .send(new GetResourcesCommand({ TagFilters: [ { Key: "copilot-application", Values: [appName], }, { Key: "copilot-environment", Values: [envName], }, { Key: "copilot-service", Values: [serviceName], }, ], ResourceTypeFilters: ["acm:certificate"], })); let certificates = []; let promises = []; for (const { ResourceARN: arn } of ResourceTagMappingList) { let promise = clients .acm() .send(new DescribeCertificateCommand({ CertificateArn: arn, })) .then(({ Certificate }) => { certificates.push(Certificate); }); promises.push(promise); } await Promise.all(promises); return certificates; } /** * Retrieve the validation options that are pending deletion. An option is pending deletion if it is only used to * validate a certificate that is pending deletion. * @param {Object} certPendingDeletion The certificate that is pending deletion. * @param {Array<Object>} certsInUse * @returns {Promise<Set<Object>>} options that are pending deletion. */ async function unusedOptionsByService(certPendingDeletion, certsInUse) { let optionsPendingDeletion = new Map(); for (const option of certPendingDeletion["DomainValidationOptions"]) { if (option["ResourceRecord"]) { optionsPendingDeletion.set(JSON.stringify(option["ResourceRecord"]), option); } } for (const { DomainValidationOptions: validationOptions } of certsInUse) { for (const option of validationOptions) { if (option["ResourceRecord"]) { optionsPendingDeletion.delete(JSON.stringify(option["ResourceRecord"])); } } } let options = new Set(); for (const opt of optionsPendingDeletion.values()) { options.add(opt); } return options; } /** * Validate if the domain name is currently in use by other services. * @param loadBalancerDNS The DNS of the Network Load Balancer used by this service. The domain name in considered in use * by this service, not other services, if it is an alias target pointing to this service's load balancer DNS. * @param domainName * @param route53Client The Route53 client to use for the domain name. This client can be a app-level client, or an * env-level client, depending on the pattern of the domain name. It is initialized outside of the function because * AWS-SDK mocks cannot mock the API call if the client is initialized in a callback. * @returns {Promise<boolean>} True if it is considered in use; otherwise false. */ async function inUseByOtherServices(loadBalancerDNS, domainName, route53Client) { let hostedZoneID; try { ({ hostedZoneID } = await domainResources(domainName)); } catch (err) { if (err instanceof UnrecognizedDomainTypeError) { console.log( `Found ${domainName} in subject alternative names. ` + "It does not match any of these patterns: '.<env>.<app>.<domain>', '.<app>.<domain>' or '.<domain>'. " + "This is unexpected. We don't error out as it may not cause any issue." ); return true; // This option has unrecognized pattern, we can't check if it is in use, so we assume it is in use. } throw err; } const { ResourceRecordSets: recordSet } = await route53Client .send(new ListResourceRecordSetsCommand({ HostedZoneId: hostedZoneID, MaxItems: "1", StartRecordName: domainName, })); if (!targetRecordExists(domainName, recordSet)) { return false; // If there is no record using this domain, it is not in use. } // If there's no loadBalancerDNS, that means we are deleting validation records used by dedicated CloudFront. In that scenario, // the validation record is uniquely used. const inUseByMySelf = loadBalancerDNS ? recordSet[0].AliasTarget && recordSet[0].AliasTarget.DNSName.toLowerCase() === `${loadBalancerDNS.toLowerCase()}.` : true; return !inUseByMySelf; } /** * Categorize a list of certificates into the certificate that corresponds to this particular custom resource, and other certificates. * @param {Array<Object>} certificates * @param {String} ownedCertARN The ARN of the certificate that this custom resource manages. * @returns {Object},Array{Object} */ function categorizeCertificates(certificates, ownedCertARN) { let certOwned; let otherCerts = []; for (const cert of certificates) { if (cert["CertificateArn"].toLowerCase() === ownedCertARN.toLowerCase()) { certOwned = cert; } else { otherCerts.push(cert); } } return { certOwned, otherCerts }; } /** * Validate if the exact record exits in the set of records. * @param targetDomainName The domain name that the target record should have * @param recordSet * @returns {boolean} */ function targetRecordExists(targetDomainName, recordSet) { if (!recordSet || recordSet.length === 0) { return false; } return recordSet[0].Name === `${targetDomainName}.`; } async function hostedZoneIDByName(domain) { const { HostedZones } = await clients.app .route53() .send(new ListHostedZonesByNameCommand({ DNSName: domain, MaxItems: "1", })); if (!HostedZones || HostedZones.length === 0) { throw new Error(`Couldn't find any Hosted Zone with DNS name ${domainName}.`); } return HostedZones[0].Id.split("/").pop(); } async function domainResources(alias) { if (domainTypes.EnvDomainZone.regex.test(alias)) { return { domain: domainTypes.EnvDomainZone.domain, route53Client: clients.env.route53(), hostedZoneID: envHostedZoneID, }; } if (domainTypes.AppDomainZone.regex.test(alias)) { return { domain: domainTypes.AppDomainZone.domain, route53Client: clients.app.route53(), hostedZoneID: await hostedZoneID.app(), }; } if (domainTypes.RootDomainZone.regex.test(alias)) { return { domain: domainTypes.RootDomainZone.domain, route53Client: clients.root.route53(), hostedZoneID: await hostedZoneID.root(), }; } throw new UnrecognizedDomainTypeError(`unrecognized domain type for ${alias}`); } function setEqual(setA, setB) { if (setA.size !== setB.size) { return false; } for (let elem of setA) { if (!setB.has(elem)) { return false; } } return true; } function UnrecognizedDomainTypeError(message = "") { this.message = message; } UnrecognizedDomainTypeError.prototype = Object.create(Error.prototype, { constructor: { value: Error, enumerable: false, writable: true, configurable: true, }, }); exports.deadlineExpired = function () { return new Promise(function (resolve, reject) { setTimeout(reject, 14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/, new Error(`Lambda took longer than 14.5 minutes to update custom domain`)); }); }; exports.withSleep = function (s) { sleep = s; }; exports.reset = function () { sleep = defaultSleep; }; exports.withDeadlineExpired = function (d) { exports.deadlineExpired = d; }; exports.attemptsValidationOptionsReady = ATTEMPTS_VALIDATION_OPTIONS_READY; exports.waitForCertificateValidation = function (certificateARN, acm) { return waitForCertificateValidation(certificateARN, acm); } exports.waitForRecordChange = function (route53, changeId) { return waitForRecordChange(route53, changeId); }