diff --git a/.gitignore b/.gitignore index 25df26a593d..f863097590c 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,6 @@ web-client/coverage-integration/* web-client/coverage-unit/* web-client/pa11y/pa11y-screenshots/*.png web-client/reports -web-client/terraform/common/cloudfront-edge/index.js.zip +web-client/terraform/common/cloudfront-edge/header-security-lambda.js.zip +web-client/terraform/common/cloudfront-edge/strip-basepath-lambda.js.zip web-client/tests_output/ diff --git a/iam/terraform/environment-specific/main/strip-basepath.tf b/iam/terraform/environment-specific/main/strip-basepath.tf new file mode 100644 index 00000000000..3e4b893ad6b --- /dev/null +++ b/iam/terraform/environment-specific/main/strip-basepath.tf @@ -0,0 +1,42 @@ +resource "aws_iam_role" "strip_basepath_lambda_role" { + name = "strip_basepath_lambda_role_${var.environment}" + + assume_role_policy = < { .fn() .mockImplementation(() => new Uint8Array([])), docketNumberGenerator: mockCreateDocketNumberGenerator, + documentUrlTranslator: jest.fn().mockImplementation(documentUrlTranslator), environment: { + appEndpoint: 'localhost:1234', stage: 'local', tempDocumentsBucketName: 'MockDocumentBucketName', }, filterCaseMetadata: jest.fn(), + getAppEndpoint: () => 'localhost:1234', getBaseUrl: () => 'http://localhost', getCaseTitle: jest.fn().mockImplementation(Case.getCaseTitle), getChiefJudgeNameForSigning: jest.fn(), diff --git a/shared/src/business/utilities/documentUrlTranslator.js b/shared/src/business/utilities/documentUrlTranslator.js new file mode 100644 index 00000000000..3836e4dc76c --- /dev/null +++ b/shared/src/business/utilities/documentUrlTranslator.js @@ -0,0 +1,26 @@ +/** + * + * @params {object} params the params object + * @param {string} documentUrl URL to the document in regionalEndpoint.com/bucket/path format + * @param {boolean} useTempBucket If the document is in the temporary documents or not + * @param {object} applicationContext the application context + * + * @returns {string} the translated URL + */ +exports.documentUrlTranslator = ({ + applicationContext, + documentUrl, + useTempBucket, +}) => { + if (applicationContext.environment.stage === 'local') { + return documentUrl; + } + + const url = new URL(documentUrl); + const path = useTempBucket ? 'temp-documents' : 'documents'; + + url.host = applicationContext.getAppEndpoint(); + url.pathname = [path, ...url.pathname.split('/').slice(2)].join('/'); + + return url.toString(); +}; diff --git a/shared/src/business/utilities/documentUrlTranslator.test.js b/shared/src/business/utilities/documentUrlTranslator.test.js new file mode 100644 index 00000000000..a7f2707096e --- /dev/null +++ b/shared/src/business/utilities/documentUrlTranslator.test.js @@ -0,0 +1,47 @@ +const { applicationContext } = require('../test/createTestApplicationContext'); +const { documentUrlTranslator } = require('./documentUrlTranslator'); + +describe('documentUrlTranslator', () => { + const documentUrl = + 'https://s3.region-name.amazonaws.com/bucketName/documentPath?AWSAccessKeyId=KeyId&Expires=999&Signature=SignatureString'; + + test('the original url is returned when the environment’s stage is local', () => { + applicationContext.environment.stage = 'local'; + + const translatedUrl = documentUrlTranslator({ + applicationContext, + documentUrl, + useTempBucket: false, + }); + + expect(translatedUrl).toBe(documentUrl); + }); + + test('temporary documents are rewritten to the app host’s cloudfront temp-documents endpoint', () => { + applicationContext.environment.stage = 'prod'; + + const translatedUrl = documentUrlTranslator({ + applicationContext, + documentUrl, + useTempBucket: true, + }); + + const expectedUrl = + 'https://localhost:1234/temp-documents/documentPath?AWSAccessKeyId=KeyId&Expires=999&Signature=SignatureString'; + expect(translatedUrl).toBe(expectedUrl); + }); + + test('non-temporary documents are rewritten to the app host’s cloudfront document endpoint', () => { + applicationContext.environment.stage = 'prod'; + + const translatedUrl = documentUrlTranslator({ + applicationContext, + documentUrl, + useTempBucket: false, + }); + + const expectedUrl = + 'https://localhost:1234/documents/documentPath?AWSAccessKeyId=KeyId&Expires=999&Signature=SignatureString'; + expect(translatedUrl).toBe(expectedUrl); + }); +}); diff --git a/shared/src/persistence/s3/getDownloadPolicyUrl.js b/shared/src/persistence/s3/getDownloadPolicyUrl.js index bc04f08ff8e..a45d117930b 100644 --- a/shared/src/persistence/s3/getDownloadPolicyUrl.js +++ b/shared/src/persistence/s3/getDownloadPolicyUrl.js @@ -10,13 +10,15 @@ exports.getDownloadPolicyUrl = ({ documentId, useTempBucket, }) => { + const bucketName = useTempBucket + ? applicationContext.getTempDocumentsBucketName() + : applicationContext.getDocumentsBucketName(); + return new Promise((resolve, reject) => { applicationContext.getStorageClient().getSignedUrl( 'getObject', { - Bucket: useTempBucket - ? applicationContext.getTempDocumentsBucketName() - : applicationContext.getDocumentsBucketName(), + Bucket: bucketName, Expires: 120, Key: documentId, }, @@ -25,7 +27,11 @@ exports.getDownloadPolicyUrl = ({ return reject(new Error(err)); } resolve({ - url: data, + url: applicationContext.documentUrlTranslator({ + applicationContext, + documentUrl: data, + useTempBucket, + }), }); }, ); diff --git a/shared/src/persistence/s3/getPublicDownloadPolicyUrl.js b/shared/src/persistence/s3/getPublicDownloadPolicyUrl.js index 4662d845308..7778dd60f0e 100644 --- a/shared/src/persistence/s3/getPublicDownloadPolicyUrl.js +++ b/shared/src/persistence/s3/getPublicDownloadPolicyUrl.js @@ -19,7 +19,11 @@ exports.getPublicDownloadPolicyUrl = ({ applicationContext, documentId }) => { return reject(err); } resolve({ - url: data, + url: applicationContext.documentUrlTranslator({ + applicationContext, + documentUrl: data, + useTempBucket: false, + }), }); }, ); diff --git a/web-api/src/applicationContext.js b/web-api/src/applicationContext.js index 5fe81bd1331..27e85c39d51 100644 --- a/web-api/src/applicationContext.js +++ b/web-api/src/applicationContext.js @@ -256,6 +256,9 @@ const { const { deleteWorkItemFromSection, } = require('../../shared/src/persistence/dynamo/workitems/deleteWorkItemFromSection'); +const { + documentUrlTranslator, +} = require('../../shared/src/business/utilities/documentUrlTranslator'); const { fetchPendingItems, } = require('../../shared/src/business/useCaseHelper/pendingItems/fetchPendingItems'); @@ -971,6 +974,9 @@ const { const execPromise = util.promisify(exec); const environment = { + appEndpoint: process.env.EFCMS_DOMAIN + ? `app.${process.env.EFCMS_DOMAIN}` + : 'localhost:1234', documentsBucketName: process.env.DOCUMENTS_BUCKET_NAME || '', dynamoDbEndpoint: process.env.DYNAMODB_ENDPOINT || 'http://localhost:8000', elasticsearchEndpoint: @@ -1201,7 +1207,11 @@ module.exports = appContextUser => { return { barNumberGenerator, docketNumberGenerator, + documentUrlTranslator, environment, + getAppEndpoint: () => { + return environment.appEndpoint; + }, getCaseTitle: Case.getCaseTitle, getChromiumBrowser, getCognito: () => { diff --git a/web-client/terraform/common/cloudfront-edge/index.js b/web-client/terraform/common/cloudfront-edge/header-security-lambda.js similarity index 98% rename from web-client/terraform/common/cloudfront-edge/index.js rename to web-client/terraform/common/cloudfront-edge/header-security-lambda.js index ea4afa5700f..48b5ba8f6bb 100644 --- a/web-client/terraform/common/cloudfront-edge/index.js +++ b/web-client/terraform/common/cloudfront-edge/header-security-lambda.js @@ -48,7 +48,7 @@ exports.handler = (event, context, callback) => { `style-src 'self' 'unsafe-inline' ${dynamsoftUrl}`, `img-src ${applicationUrl} ${subdomainsUrl} data:`, `font-src ${applicationUrl} ${subdomainsUrl}`, - `frame-src ${s3Url} blob:`, + `frame-src ${s3Url} ${subdomainsUrl} blob:`, "frame-ancestors 'none'", ]; headers['content-security-policy'] = [ diff --git a/web-client/terraform/common/cloudfront-edge/strip-basepath-lambda.js b/web-client/terraform/common/cloudfront-edge/strip-basepath-lambda.js new file mode 100644 index 00000000000..13597ce3deb --- /dev/null +++ b/web-client/terraform/common/cloudfront-edge/strip-basepath-lambda.js @@ -0,0 +1,12 @@ +/** + * Removes the base path (the top-level folder) from the request URL. + * + * @param {object} event the AWS event object + * @param {object} context the context + * @param {object} callback the callback + */ +exports.handler = (event, context, callback) => { + const { request } = event.Records[0].cf; + request.uri = `/${[...request.uri.split('/').slice(2)].join('/')}`; + callback(null, request); +}; diff --git a/web-client/terraform/common/frontend.tf b/web-client/terraform/common/frontend.tf index c4da2c7620f..de2eba8bd6e 100644 --- a/web-client/terraform/common/frontend.tf +++ b/web-client/terraform/common/frontend.tf @@ -136,6 +136,58 @@ resource "aws_cloudfront_distribution" "distribution" { } } + origin_group { + origin_id = "group-documents.${var.dns_domain}" + + failover_criteria { + status_codes = [403, 404, 500, 502, 503, 504] + } + + member { + origin_id = "primary-documents.${var.dns_domain}" + } + + member { + origin_id = "failover-documents.${var.dns_domain}" + } + } + + origin_group { + origin_id = "group-temp-documents.${var.dns_domain}" + + failover_criteria { + status_codes = [403, 404, 500, 502, 503, 504] + } + + member { + origin_id = "primary-temp-documents.${var.dns_domain}" + } + + member { + origin_id = "failover-temp-documents.${var.dns_domain}" + } + } + + origin { + domain_name = "${var.dns_domain}-documents-${var.environment}-us-east-1.s3.amazonaws.com" + origin_id = "primary-documents.${var.dns_domain}" + } + + origin { + domain_name = "${var.dns_domain}-documents-${var.environment}-us-west-1.s3.amazonaws.com" + origin_id = "failover-documents.${var.dns_domain}" + } + + origin { + domain_name = "${var.dns_domain}-temp-documents-${var.environment}-us-east-1.s3.amazonaws.com" + origin_id = "primary-temp-documents.${var.dns_domain}" + } + + origin { + domain_name = "${var.dns_domain}-temp-documents-${var.environment}-us-west-1.s3.amazonaws.com" + origin_id = "failover-temp-documents.${var.dns_domain}" + } + custom_error_response { error_caching_min_ttl = 0 error_code = 404 @@ -198,6 +250,58 @@ resource "aws_cloudfront_distribution" "distribution" { viewer_protocol_policy = "redirect-to-https" } + ordered_cache_behavior { + path_pattern = "/documents/*" + viewer_protocol_policy = "redirect-to-https" + compress = true + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "group-documents.${var.dns_domain}" + min_ttl = 0 + default_ttl = 0 + max_ttl = 0 + + lambda_function_association { + event_type = "origin-request" + lambda_arn = aws_lambda_function.strip_basepath_lambda.qualified_arn + include_body = false + } + + forwarded_values { + query_string = true + + cookies { + forward = "none" + } + } + } + + ordered_cache_behavior { + path_pattern = "/temp-documents/*" + viewer_protocol_policy = "redirect-to-https" + compress = true + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "group-temp-documents.${var.dns_domain}" + min_ttl = 0 + default_ttl = 0 + max_ttl = 0 + + lambda_function_association { + event_type = "origin-request" + lambda_arn = aws_lambda_function.strip_basepath_lambda.qualified_arn + include_body = false + } + + forwarded_values { + query_string = true + + cookies { + forward = "none" + } + } + } + aliases = ["app.${var.dns_domain}"] restrictions { diff --git a/web-client/terraform/common/header-security-lambda.tf b/web-client/terraform/common/header-security-lambda.tf index bb238446b5a..cceabc43056 100644 --- a/web-client/terraform/common/header-security-lambda.tf +++ b/web-client/terraform/common/header-security-lambda.tf @@ -1,17 +1,17 @@ data "aws_caller_identity" "current" {} -data "archive_file" "zip_cloudfront_edge" { +data "archive_file" "zip_header_security_lambda" { type = "zip" - source_file = "${path.module}/cloudfront-edge/index.js" - output_path = "${path.module}/cloudfront-edge/index.js.zip" + source_file = "${path.module}/cloudfront-edge/header-security-lambda.js" + output_path = "${path.module}/cloudfront-edge/header-security-lambda.js.zip" } resource "aws_lambda_function" "header_security_lambda" { - filename = data.archive_file.zip_cloudfront_edge.output_path + filename = data.archive_file.zip_header_security_lambda.output_path function_name = "header_security_lambda_${var.environment}" role = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/header_security_lambda_role_${var.environment}" - handler = "index.handler" - source_code_hash = data.archive_file.zip_cloudfront_edge.output_base64sha256 + handler = "header-security-lambda.handler" + source_code_hash = data.archive_file.zip_header_security_lambda.output_base64sha256 publish = true runtime = "nodejs10.x" diff --git a/web-client/terraform/common/strip-basepath-lambda.tf b/web-client/terraform/common/strip-basepath-lambda.tf new file mode 100644 index 00000000000..9dba144f78b --- /dev/null +++ b/web-client/terraform/common/strip-basepath-lambda.tf @@ -0,0 +1,16 @@ +data "archive_file" "zip_strip_basepath_lambda" { + type = "zip" + source_file = "${path.module}/cloudfront-edge/strip-basepath-lambda.js" + output_path = "${path.module}/cloudfront-edge/strip-basepath-lambda.js.zip" +} + +resource "aws_lambda_function" "strip_basepath_lambda" { + filename = data.archive_file.zip_strip_basepath_lambda.output_path + function_name = "strip_basepath_lambda_${var.environment}" + role = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/strip_basepath_lambda_role_${var.environment}" + handler = "strip-basepath-lambda.handler" + source_code_hash = data.archive_file.zip_strip_basepath_lambda.output_base64sha256 + publish = true + + runtime = "nodejs10.x" +}