diff --git a/Makefile b/Makefile index 0c5b8824..9d35f7a3 100644 --- a/Makefile +++ b/Makefile @@ -101,9 +101,8 @@ test_live_integration: install yarn test:live test_unit: install - yarn typecheck yarn lint - cfn-lint cloudformation/**/* --ignore-templates cloudformation/phony-swagger.yml -i W3660 + cfn-lint cloudformation/**/* yarn prettier yarn test:unit diff --git a/cloudformation/custom-domain.yml b/cloudformation/custom-domain.yml deleted file mode 100644 index 99195246..00000000 --- a/cloudformation/custom-domain.yml +++ /dev/null @@ -1,52 +0,0 @@ -Parameters: - GWCertArn: - Description: Certificate ARN - Type: String - GWBaseDomainName: - Description: Base domain name - Type: String - GWApiId: - Description: API ID - Type: String - GWHostedZoneId: - Description: Hosted Zone ID - Type: String - RunEnvironment: - Type: String - AllowedValues: [ 'dev', 'prod' ] - RecordName: - Type: String - CloudfrontDomain: - Type: String - -Conditions: - IsDev: !Equals [!Ref RunEnvironment, 'dev'] - -Resources: - CustomDomainName: - Type: AWS::ApiGateway::DomainName - Properties: - RegionalCertificateArn: !Ref GWCertArn - EndpointConfiguration: - Types: - - REGIONAL - DomainName: !Sub "${RecordName}.${GWBaseDomainName}" - SecurityPolicy: TLS_1_2 - - CDApiMapping: - Type: 'AWS::ApiGatewayV2::ApiMapping' - Properties: - DomainName: !Ref CustomDomainName - ApiId: !Ref GWApiId - Stage: default - - CDRoute53RecordSetDev: - Condition: IsDev - Type: AWS::Route53::RecordSet - Properties: - HostedZoneId: !Ref GWHostedZoneId - Name: !Sub "${RecordName}.${GWBaseDomainName}" - Type: CNAME - TTL: 300 - ResourceRecords: - - !Ref CloudfrontDomain diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 19d60885..c5d8cda3 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -40,6 +40,7 @@ Parameters: Conditions: IsProd: !Equals [!Ref RunEnvironment, "prod"] + # IsDev: !Equals [!Ref RunEnvironment, "dev"] ShouldAttachVpc: !Equals [true, !Ref VpcRequired] Mappings: @@ -106,45 +107,132 @@ Resources: QueueName: !Sub ${ApplicationPrefix}-sqs MessageTimeout: !Ref SqsMessageTimeout - IcalDomainProxy: - Type: AWS::Serverless::Application + LinkryRecordSetv4: + Type: AWS::Route53::RecordSet Properties: - Location: ./custom-domain.yml - Parameters: - RunEnvironment: !Ref RunEnvironment - RecordName: ical - GWBaseDomainName: !FindInMap - - ApiGwConfig - - !Ref RunEnvironment - - EnvDomainName - GWCertArn: !FindInMap - - ApiGwConfig - - !Ref RunEnvironment - - EnvCertificateArn - GWApiId: !Ref AppApiGateway - GWHostedZoneId: - !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] - CloudfrontDomain: !GetAtt [AppIcalCloudfrontDistribution, DomainName] + HostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + Name: !Join + - "." + - - "go" + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt [AppLinkryCloudfrontDistribution, DomainName] + EvaluateTargetHealth: false + + LinkryRecordSetv6: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + Name: !Join + - "." + - - "go" + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName + Type: AAAA + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt [AppLinkryCloudfrontDistribution, DomainName] + EvaluateTargetHealth: false + + IcalRecordSetv4: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + Name: !Join + - "." + - - "ical" + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt [AppIcalCloudfrontDistribution, DomainName] + EvaluateTargetHealth: false - CoreUrlProd: - Type: AWS::Serverless::Application + + IcalRecordSetv6: + Type: AWS::Route53::RecordSet Properties: - Location: ./custom-domain.yml - Parameters: - RunEnvironment: !Ref RunEnvironment - RecordName: core - GWBaseDomainName: !FindInMap - - ApiGwConfig - - !Ref RunEnvironment - - EnvDomainName - GWCertArn: !FindInMap - - ApiGwConfig - - !Ref RunEnvironment - - EnvCertificateArn - GWApiId: !Ref AppApiGateway - GWHostedZoneId: - !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] - CloudfrontDomain: !GetAtt [AppFrontendCloudfrontDistribution, DomainName] + HostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + Name: !Join + - "." + - - "ical" + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName + Type: AAAA + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt [AppIcalCloudfrontDistribution, DomainName] + EvaluateTargetHealth: false + + + CoreRecordSetv4: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + Name: !Join + - "." + - - "core" + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName + Type: A + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt [AppFrontendCloudfrontDistribution, DomainName] + EvaluateTargetHealth: false + + CoreRecordSetv6: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + Name: !Join + - "." + - - "core" + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName + Type: AAAA + AliasTarget: + HostedZoneId: Z2FDTNDATAQYW2 + DNSName: !GetAtt [AppFrontendCloudfrontDistribution, DomainName] + EvaluateTargetHealth: false + + AppLambdaUrl: + Type: AWS::Lambda::Url + Properties: + AuthType: NONE + Cors: + AllowHeaders: + - Content-Type + - Authorization + - X-Amz-Date + AllowOrigins: ["*"] + MaxAge: 300 + InvokeMode: BUFFERED + TargetFunctionArn: !GetAtt AppApiLambdaFunction.Arn + + + AppLambdaUrlInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !GetAtt AppApiLambdaFunction.Arn + Action: lambda:InvokeFunctionUrl + Principal: "*" + FunctionUrlAuthType: NONE AppApiLambdaFunction: Type: AWS::Serverless::Function @@ -167,6 +255,7 @@ Resources: EntraRoleArn: !GetAtt AppSecurityRoles.Outputs.EntraFunctionRoleArn LinkryKvArn: !GetAtt LinkryRecordsCloudfrontStore.Arn AWS_CRT_NODEJS_BINARY_RELATIVE_PATH: node_modules/aws-crt/dist/bin/linux-x64-glibc/aws-crt-nodejs.node + ORIGIN_VERIFY_KEY: !Join ['-', ['secret', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] VpcConfig: Ipv6AllowedForDualStack: !If [ShouldAttachVpc, True, !Ref AWS::NoValue] SecurityGroupIds: @@ -186,12 +275,6 @@ Resources: !Ref AWS::NoValue, ] Events: - ApiEvent: - Type: Api - Properties: - RestApiId: !Ref AppApiGateway - Path: /{proxy+} - Method: ANY WarmingSchedule: Type: Schedule Properties: @@ -520,120 +603,6 @@ Resources: AttributeName: "expireAt" Enabled: true - AppApiGateway: - Type: AWS::Serverless::Api - DependsOn: - - AppApiLambdaFunction - Properties: - Name: !Sub ${ApplicationPrefix}-gateway - Description: !Sub "${ApplicationFriendlyName} API Gateway" - MinimumCompressionSize: 2048 # 2kb to compress - AlwaysDeploy: True - DefinitionBody: - Fn::Transform: - Name: AWS::Include - Parameters: - Location: ./phony-swagger.yml - Route53: - HostedZoneId: - !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] - StageName: default - EndpointConfiguration: - Type: REGIONAL - Cors: - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date'" - AllowOrigin: "'*'" - MaxAge: "'300'" - - APIDefault4XXResponse: - Type: AWS::ApiGateway::GatewayResponse - Properties: - RestApiId: !Ref AppApiGateway - ResponseType: DEFAULT_4XX - StatusCode: "404" - ResponseParameters: - gatewayresponse.header.Access-Control-Allow-Origin: "'*'" - ResponseTemplates: - application/json: '{"error": true, "message": "Resource not found. Check your URL or contact support."}' - - APIAccessDeniedResponse: - Type: AWS::ApiGateway::GatewayResponse - Properties: - RestApiId: !Ref AppApiGateway - ResponseType: ACCESS_DENIED - StatusCode: "403" - ResponseParameters: - gatewayresponse.header.Access-Control-Allow-Origin: "'*'" - ResponseTemplates: - application/json: '{"error": true, "message": "Access denied. Perhaps reauthenticate and try again?"}' - - APIUnauthorizedResponse: - Type: AWS::ApiGateway::GatewayResponse - Properties: - RestApiId: !Ref AppApiGateway - ResponseType: UNAUTHORIZED - StatusCode: "401" - ResponseParameters: - gatewayresponse.header.Access-Control-Allow-Origin: "'*'" - ResponseTemplates: - application/json: '{"error": true, "message": "Request could not be authenticated. Perhaps reauthenticate and try again?"}' - - AppApiGatewayLatencyAlarm: - Type: "AWS::CloudWatch::Alarm" - Condition: IsProd - Properties: - AlarmName: !Sub ${ApplicationPrefix}-gateway-latency-high - AlarmDescription: "Trailing Mean - 95% API gateway latency is > 1.25s for 2 times in 4 minutes." - Namespace: "AWS/ApiGateway" - MetricName: "Latency" - ExtendedStatistic: "tm95" - Period: "120" - EvaluationPeriods: "2" - ComparisonOperator: "GreaterThanThreshold" - Threshold: "1250" - AlarmActions: - - !Ref AlertSNSArn - Dimensions: - - Name: "ApiName" - Value: !Sub ${ApplicationPrefix}-gateway - - AppApiGatewayNoRequestsAlarm: - Type: "AWS::CloudWatch::Alarm" - Condition: IsProd - Properties: - AlarmName: !Sub ${ApplicationPrefix}-gateway-no-requests - AlarmDescription: "No requests have been received in the past 5 minutes." - Namespace: "AWS/ApiGateway" - MetricName: "Count" - Statistic: "Sum" - Period: "300" - EvaluationPeriods: "1" - ComparisonOperator: "LessThanThreshold" - Threshold: "1" - AlarmActions: - - !Ref PriorityAlertSNSArn - Dimensions: - - Name: "ApiName" - Value: !Sub ${ApplicationPrefix}-gateway - - AppApiGateway5XXErrorAlarm: - Type: "AWS::CloudWatch::Alarm" - Condition: IsProd - Properties: - AlarmName: !Sub ${ApplicationPrefix}-gateway-5xx - AlarmDescription: "More than 2 API gateway 5XX errors were detected." - Namespace: "AWS/ApiGateway" - MetricName: "5XXError" - Statistic: "Average" - Period: "60" - EvaluationPeriods: "1" - ComparisonOperator: "GreaterThanThreshold" - Threshold: "2" - AlarmActions: - - !Ref PriorityAlertSNSArn - Dimensions: - - Name: "ApiName" - Value: !Sub ${ApplicationPrefix}-gateway AppDLQMessagesAlarm: Type: "AWS::CloudWatch::Alarm" @@ -654,23 +623,6 @@ Resources: AlarmActions: - !Ref PriorityAlertSNSArn - APILambdaPermission: - Type: AWS::Lambda::Permission - Properties: - FunctionName: !GetAtt AppApiLambdaFunction.Arn - Action: lambda:InvokeFunction - Principal: apigateway.amazonaws.com - SourceArn: - Fn::Join: - - "" - - - "arn:aws:execute-api:" - - !Ref AWS::Region - - ":" - - !Ref AWS::AccountId - - ":" - - !Ref AppApiGateway - - "/*/*/*" - AppFrontendS3Bucket: Type: AWS::S3::Bucket Properties: @@ -694,13 +646,15 @@ Resources: DomainName: !GetAtt AppFrontendS3Bucket.RegionalDomainName S3OriginConfig: OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" - - Id: ApiGatewayOrigin - DomainName: !Sub "${AppApiGateway}.execute-api.${AWS::Region}.amazonaws.com" - OriginPath: "/default" + - Id: LambdaOrigin + DomainName: !Select [0, !Split ['/', !Select [1, !Split ['https://', !GetAtt AppLambdaUrl.FunctionUrl]]]] CustomOriginConfig: HTTPPort: 80 HTTPSPort: 443 OriginProtocolPolicy: https-only + OriginCustomHeaders: + - HeaderName: X-Origin-Verify + HeaderValue: !Join ['-', ['secret', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] Enabled: true DefaultRootObject: index.html Aliases: @@ -729,7 +683,7 @@ Resources: LambdaFunctionARN: !Ref AppFrontendEdgeLambdaVersion CacheBehaviors: - PathPattern: "/api/v1/events*" - TargetOriginId: ApiGatewayOrigin + TargetOriginId: LambdaOrigin ViewerProtocolPolicy: redirect-to-https AllowedMethods: - GET @@ -743,10 +697,10 @@ Resources: - GET - HEAD CachePolicyId: !Ref CloudfrontCachePolicy - OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac Compress: true - PathPattern: "/api/v1/organizations" - TargetOriginId: ApiGatewayOrigin + TargetOriginId: LambdaOrigin ViewerProtocolPolicy: redirect-to-https AllowedMethods: - GET @@ -760,10 +714,10 @@ Resources: - GET - HEAD CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6" - OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac Compress: true - PathPattern: "/api/documentation*" - TargetOriginId: ApiGatewayOrigin + TargetOriginId: LambdaOrigin ViewerProtocolPolicy: redirect-to-https AllowedMethods: - GET @@ -777,10 +731,10 @@ Resources: - GET - HEAD CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6" - OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac Compress: true - PathPattern: "/api/*" - TargetOriginId: ApiGatewayOrigin + TargetOriginId: LambdaOrigin ViewerProtocolPolicy: redirect-to-https AllowedMethods: - GET @@ -793,8 +747,9 @@ Resources: CachedMethods: - GET - HEAD + - OPTIONS CachePolicyId: !Ref CloudfrontNoCachePolicy # caching disabled - OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac Compress: true ViewerCertificate: AcmCertificateArn: !FindInMap @@ -854,7 +809,6 @@ Resources: Headers: - x-method-override - origin - - host - x-http-method - x-http-method-override QueryStringsConfig: @@ -896,13 +850,16 @@ Resources: DistributionConfig: HttpVersion: 'http2and3' Origins: - - Id: ApiGatewayOrigin - DomainName: !Sub "${AppApiGateway}.execute-api.${AWS::Region}.amazonaws.com" - OriginPath: "/default" + - Id: LambdaOrigin + DomainName: !Select [0, !Split ['/', !Select [1, !Split ['https://', !GetAtt AppLambdaUrl.FunctionUrl]]]] + OriginPath: "/api/v1/ical" CustomOriginConfig: HTTPPort: 80 HTTPSPort: 443 OriginProtocolPolicy: https-only + OriginCustomHeaders: + - HeaderName: X-Origin-Verify + HeaderValue: !Join ['-', ['secret', !Select [4, !Split ['-', !Select [2, !Split ['/', !Ref AWS::StackId]]]]]] Enabled: true Aliases: - !Join @@ -914,7 +871,7 @@ Resources: - EnvDomainName DefaultCacheBehavior: Compress: true - TargetOriginId: ApiGatewayOrigin + TargetOriginId: LambdaOrigin ViewerProtocolPolicy: redirect-to-https AllowedMethods: - GET @@ -932,7 +889,7 @@ Resources: Cookies: Forward: none CachePolicyId: !Ref CloudfrontCachePolicy - OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac ViewerCertificate: AcmCertificateArn: !FindInMap - ApiGwConfig @@ -1031,26 +988,6 @@ Resources: SslSupportMethod: sni-only PriceClass: PriceClass_100 - LinkryDomainProxy: - Type: AWS::Serverless::Application - Properties: - Location: ./custom-domain.yml - Parameters: - RunEnvironment: !Ref RunEnvironment - RecordName: go - GWBaseDomainName: !FindInMap - - ApiGwConfig - - !Ref RunEnvironment - - EnvDomainName - GWCertArn: !FindInMap - - ApiGwConfig - - !Ref RunEnvironment - - EnvCertificateArn - GWApiId: !Ref AppApiGateway - GWHostedZoneId: - !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] - CloudfrontDomain: !GetAtt [AppLinkryCloudfrontDistribution, DomainName] - Outputs: DomainName: Description: Domain name that the UI is hosted at diff --git a/cloudformation/phony-swagger.yml b/cloudformation/phony-swagger.yml deleted file mode 100644 index a8088147..00000000 --- a/cloudformation/phony-swagger.yml +++ /dev/null @@ -1,28 +0,0 @@ -openapi: 3.0.3 -info: - title: ACM UIUC Redirect All API - version: "1.0.0" - contact: - name: ACM Infrastructure Team - email: infra@acm.illinois.edu - -paths: - /{proxy+}: - x-amazon-apigateway-any-method: - responses: - 200: - description: OK - - x-amazon-apigateway-auth: - type: NONE - - x-amazon-apigateway-integration: - responses: - default: - statusCode: 200 - passthroughBehavior: when_no_match - httpMethod: POST - contentHandling: CONVERT_TO_TEXT - type: aws_proxy - uri: - Fn::Sub: "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${ApplicationPrefix}-lambda/invocations" diff --git a/src/api/index.ts b/src/api/index.ts index d82be73f..c529a3ab 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -79,18 +79,6 @@ async function init(prettyPrint: boolean = false) { level: process.env.LOG_LEVEL || "info", transport, }, - rewriteUrl: (req) => { - const url = req.url; - const hostname = req.headers.host || ""; - const customDomainBaseMappers: Record = { - "ical.acm.illinois.edu": `/api/v1/ical${url}`, - "ical.aws.qa.acmuiuc.org": `/api/v1/ical${url}`, - }; - if (hostname in customDomainBaseMappers) { - return customDomainBaseMappers[hostname]; - } - return url || "/"; - }, disableRequestLogging: true, genReqId: (request) => { const header = request.headers["x-apigateway-event"]; diff --git a/src/api/lambda.ts b/src/api/lambda.ts index 835870aa..7fd2d456 100644 --- a/src/api/lambda.ts +++ b/src/api/lambda.ts @@ -3,7 +3,7 @@ import awsLambdaFastify, { LambdaResponse } from "@fastify/aws-lambda"; import init from "./index.js"; import warmer from "lambda-warmer"; import { type APIGatewayEvent, type Context } from "aws-lambda"; -import { InternalServerError } from "common/errors/index.js"; +import { InternalServerError, ValidationError } from "common/errors/index.js"; const app = await init(); const realHandler = awsLambdaFastify(app, { @@ -16,6 +16,26 @@ const handler = async (event: APIGatewayEvent, context: Context) => { if (await warmer(event, { correlationId: context.awsRequestId }, context)) { return "warmed"; } + if (process.env.ORIGIN_VERIFY_KEY) { + // check that the request has the right header (coming from cloudfront) + if ( + !event.headers || + !(event.headers["x-origin-verify"] === process.env.ORIGIN_VERIFY_KEY) + ) { + const newError = new ValidationError({ + message: "Request is not valid.", + }); + const json = JSON.stringify(newError.toJson()); + return { + statusCode: newError.httpStatusCode, + body: json, + headers: { + "Content-Type": "application/json", + }, + isBase64Encoded: false, + }; + } + } // else proceed with handler logic return await realHandler(event, context).catch((e) => { console.error(e); diff --git a/tests/unit/vitest.config.ts b/tests/unit/vitest.config.ts index 5179d932..d52b4f45 100644 --- a/tests/unit/vitest.config.ts +++ b/tests/unit/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ exclude: ["src/api/lambda.ts", "src/api/sqs/handlers/templates/*.ts"], thresholds: { statements: 50, - functions: 65, + functions: 60, lines: 50, }, },