From a34111578982b11aaf511ca0ca43c86c5822b468 Mon Sep 17 00:00:00 2001 From: Tyler Barnes Date: Wed, 21 Sep 2022 06:20:06 -0700 Subject: [PATCH] feat(gatsby-plugin-utils): Encrypt image cdn image/file urls when env vars are present (#36585) Co-authored-by: Ward Peeters --- .../utils/__tests__/url-generator.ts | 119 ++++++++++++++++++ .../utils/url-generator.ts | 44 ++++++- 2 files changed, 161 insertions(+), 2 deletions(-) diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts index df2afe9bca26f..34fc65fa1fef1 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts @@ -1,3 +1,6 @@ +import crypto from "crypto" +import url from "url" + import { generateFileUrl, generateImageUrl, @@ -7,6 +10,122 @@ import { type ImageArgs = Parameters[1] describe(`url-generator`, () => { + describe(`URL encryption`, () => { + function decryptImageCdnUrl( + key: string, + iv: string, + encryptedUrl: string + ): { decryptedUrl: string; randomPadding: string } { + const decipher = crypto.createDecipheriv( + `aes-256-ctr`, + Buffer.from(key, `hex`), + Buffer.from(iv, `hex`) + ) + const decrypted = decipher.update(Buffer.from(encryptedUrl, `hex`)) + const clearText = Buffer.concat([decrypted, decipher.final()]).toString() + + const [randomPadding, ...url] = clearText.split(`:`) + + return { decryptedUrl: url.join(`:`), randomPadding } + } + + const fileUrlToEncrypt = `https://example.com/file.pdf` + const imageUrlToEncrypt = `https://example.com/image.png` + + const imageNode = { + url: imageUrlToEncrypt, + mimeType: `image/png`, + filename: `image.png`, + internal: { + contentDigest: `digest`, + }, + } + + const resizeArgs = { + width: 100, + height: 100, + format: `webp`, + quality: 80, + } + + const generateEncryptedUrlForType = (type: string): string => { + const url = { + file: generateFileUrl({ + url: fileUrlToEncrypt, + filename: `file.pdf`, + }), + image: generateImageUrl(imageNode, resizeArgs), + }[type] + + if (!url) { + throw new Error(`Unknown type: ${type}`) + } + + return url + } + + const getUnencryptedUrlForType = (type: string): string => { + if (type === `file`) { + return fileUrlToEncrypt + } else if (type === `image`) { + return imageUrlToEncrypt + } else { + throw new Error(`Unknown type: ${type}`) + } + } + + it.each([`file`, `image`])( + `should return %s URL's untouched if encryption is not enabled`, + type => { + const unencryptedUrl = generateEncryptedUrlForType(type) + + const { eu, u } = url.parse(unencryptedUrl, true).query + + expect(eu).toBe(undefined) + expect(u).toBeTruthy() + + expect(u).toBe(getUnencryptedUrlForType(type)) + } + ) + + it.each([`file`, `image`])( + `should return %s URL's encrypted if encryption is enabled`, + type => { + const key = crypto.randomBytes(32).toString(`hex`) + const iv = crypto.randomBytes(16).toString(`hex`) + + process.env.IMAGE_CDN_ENCRYPTION_SECRET_KEY = key + process.env.IMAGE_CDN_ENCRYPTION_IV = iv + + const urlWithEncryptedEuParam = generateEncryptedUrlForType(type) + + expect(urlWithEncryptedEuParam).not.toContain( + encodeURIComponent(getUnencryptedUrlForType(type)) + ) + + const { eu: encryptedUrlParam, u: urlParam } = url.parse( + urlWithEncryptedEuParam, + true + ).query + + expect(urlParam).toBeFalsy() + expect(encryptedUrlParam).toBeTruthy() + + const { decryptedUrl, randomPadding } = decryptImageCdnUrl( + key, + iv, + encryptedUrlParam as string + ) + + expect(decryptedUrl).toEqual(getUnencryptedUrlForType(type)) + expect(randomPadding.length).toBeGreaterThan(0) + + delete process.env.IMAGE_CDN_ENCRYPTION_SECRET_KEY + delete process.env.IMAGE_CDN_ENCRYPTION_IV + } + ) + }) + describe(`generateFileUrl`, () => { it(`should return a file based url`, () => { const source = { diff --git a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts index 0b58c6e0be7e8..be120cb8a0a12 100644 --- a/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts +++ b/packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts @@ -1,3 +1,4 @@ +import crypto from "crypto" import { basename, extname } from "path" import { URL } from "url" import { createContentDigest } from "gatsby-core-utils/create-content-digest" @@ -9,10 +10,49 @@ const ORIGIN = `https://gatsbyjs.com` export enum ImageCDNUrlKeys { URL = `u`, + ENCRYPTED_URL = `eu`, ARGS = `a`, CONTENT_DIGEST = `cd`, } +function encryptImageCdnUrl( + secretKey: string, + iv: string, + urlToEncrypt: string +): string { + const randomPadding = crypto + .randomBytes(crypto.randomInt(32, 64)) + .toString(`hex`) + + const toEncrypt = `${randomPadding}:${urlToEncrypt}` + const cipher = crypto.createCipheriv( + `aes-256-ctr`, + Buffer.from(secretKey, `hex`), + Buffer.from(iv, `hex`) + ) + const encrypted = cipher.update(toEncrypt) + const finalBuffer = Buffer.concat([encrypted, cipher.final()]) + + return finalBuffer.toString(`hex`) +} + +function appendUrlParamToSearchParams( + searchParams: URLSearchParams, + url: string +): void { + const key = process.env.IMAGE_CDN_ENCRYPTION_SECRET_KEY || `` + const iv = process.env.IMAGE_CDN_ENCRYPTION_IV || `` + const shouldEncrypt = !!(iv && key) + + const paramName = shouldEncrypt + ? ImageCDNUrlKeys.ENCRYPTED_URL + : ImageCDNUrlKeys.URL + + const finalUrl = shouldEncrypt ? encryptImageCdnUrl(key, iv, url) : url + + searchParams.append(paramName, finalUrl) +} + export function generateFileUrl({ url, filename, @@ -29,7 +69,7 @@ export function generateFileUrl({ })}/${filenameWithoutExt}${fileExt}` ) - parsedURL.searchParams.append(ImageCDNUrlKeys.URL, url) + appendUrlParamToSearchParams(parsedURL.searchParams, url) return `${parsedURL.pathname}${parsedURL.search}` } @@ -52,7 +92,7 @@ export function generateImageUrl( )}/${filenameWithoutExt}.${imageArgs.format}` ) - parsedURL.searchParams.append(ImageCDNUrlKeys.URL, source.url) + appendUrlParamToSearchParams(parsedURL.searchParams, source.url) parsedURL.searchParams.append(ImageCDNUrlKeys.ARGS, queryStr) parsedURL.searchParams.append( ImageCDNUrlKeys.CONTENT_DIGEST,