Skip to content

Commit

Permalink
feat(gatsby-plugin-utils): Encrypt image cdn image/file urls when env…
Browse files Browse the repository at this point in the history
… vars are present (#36585)

Co-authored-by: Ward Peeters <ward@coding-tech.com>
  • Loading branch information
TylerBarnes and wardpeet committed Sep 21, 2022
1 parent 87f280a commit a341115
Show file tree
Hide file tree
Showing 2 changed files with 161 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import crypto from "crypto"
import url from "url"

import {
generateFileUrl,
generateImageUrl,
Expand All @@ -7,6 +10,122 @@ import {
type ImageArgs = Parameters<typeof generateImageUrl>[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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand All @@ -29,7 +69,7 @@ export function generateFileUrl({
})}/${filenameWithoutExt}${fileExt}`
)

parsedURL.searchParams.append(ImageCDNUrlKeys.URL, url)
appendUrlParamToSearchParams(parsedURL.searchParams, url)

return `${parsedURL.pathname}${parsedURL.search}`
}
Expand All @@ -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,
Expand Down

0 comments on commit a341115

Please sign in to comment.