Skip to content

Commit a341115

Browse files
feat(gatsby-plugin-utils): Encrypt image cdn image/file urls when env vars are present (#36585)
Co-authored-by: Ward Peeters <[email protected]>
1 parent 87f280a commit a341115

File tree

2 files changed

+161
-2
lines changed

2 files changed

+161
-2
lines changed

packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/__tests__/url-generator.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import crypto from "crypto"
2+
import url from "url"
3+
14
import {
25
generateFileUrl,
36
generateImageUrl,
@@ -7,6 +10,122 @@ import {
710
type ImageArgs = Parameters<typeof generateImageUrl>[1]
811

912
describe(`url-generator`, () => {
13+
describe(`URL encryption`, () => {
14+
function decryptImageCdnUrl(
15+
key: string,
16+
iv: string,
17+
encryptedUrl: string
18+
): { decryptedUrl: string; randomPadding: string } {
19+
const decipher = crypto.createDecipheriv(
20+
`aes-256-ctr`,
21+
Buffer.from(key, `hex`),
22+
Buffer.from(iv, `hex`)
23+
)
24+
const decrypted = decipher.update(Buffer.from(encryptedUrl, `hex`))
25+
const clearText = Buffer.concat([decrypted, decipher.final()]).toString()
26+
27+
const [randomPadding, ...url] = clearText.split(`:`)
28+
29+
return { decryptedUrl: url.join(`:`), randomPadding }
30+
}
31+
32+
const fileUrlToEncrypt = `https://example.com/file.pdf`
33+
const imageUrlToEncrypt = `https://example.com/image.png`
34+
35+
const imageNode = {
36+
url: imageUrlToEncrypt,
37+
mimeType: `image/png`,
38+
filename: `image.png`,
39+
internal: {
40+
contentDigest: `digest`,
41+
},
42+
}
43+
44+
const resizeArgs = {
45+
width: 100,
46+
height: 100,
47+
format: `webp`,
48+
quality: 80,
49+
}
50+
51+
const generateEncryptedUrlForType = (type: string): string => {
52+
const url = {
53+
file: generateFileUrl({
54+
url: fileUrlToEncrypt,
55+
filename: `file.pdf`,
56+
}),
57+
image: generateImageUrl(imageNode, resizeArgs),
58+
}[type]
59+
60+
if (!url) {
61+
throw new Error(`Unknown type: ${type}`)
62+
}
63+
64+
return url
65+
}
66+
67+
const getUnencryptedUrlForType = (type: string): string => {
68+
if (type === `file`) {
69+
return fileUrlToEncrypt
70+
} else if (type === `image`) {
71+
return imageUrlToEncrypt
72+
} else {
73+
throw new Error(`Unknown type: ${type}`)
74+
}
75+
}
76+
77+
it.each([`file`, `image`])(
78+
`should return %s URL's untouched if encryption is not enabled`,
79+
type => {
80+
const unencryptedUrl = generateEncryptedUrlForType(type)
81+
82+
const { eu, u } = url.parse(unencryptedUrl, true).query
83+
84+
expect(eu).toBe(undefined)
85+
expect(u).toBeTruthy()
86+
87+
expect(u).toBe(getUnencryptedUrlForType(type))
88+
}
89+
)
90+
91+
it.each([`file`, `image`])(
92+
`should return %s URL's encrypted if encryption is enabled`,
93+
type => {
94+
const key = crypto.randomBytes(32).toString(`hex`)
95+
const iv = crypto.randomBytes(16).toString(`hex`)
96+
97+
process.env.IMAGE_CDN_ENCRYPTION_SECRET_KEY = key
98+
process.env.IMAGE_CDN_ENCRYPTION_IV = iv
99+
100+
const urlWithEncryptedEuParam = generateEncryptedUrlForType(type)
101+
102+
expect(urlWithEncryptedEuParam).not.toContain(
103+
encodeURIComponent(getUnencryptedUrlForType(type))
104+
)
105+
106+
const { eu: encryptedUrlParam, u: urlParam } = url.parse(
107+
urlWithEncryptedEuParam,
108+
true
109+
).query
110+
111+
expect(urlParam).toBeFalsy()
112+
expect(encryptedUrlParam).toBeTruthy()
113+
114+
const { decryptedUrl, randomPadding } = decryptImageCdnUrl(
115+
key,
116+
iv,
117+
encryptedUrlParam as string
118+
)
119+
120+
expect(decryptedUrl).toEqual(getUnencryptedUrlForType(type))
121+
expect(randomPadding.length).toBeGreaterThan(0)
122+
123+
delete process.env.IMAGE_CDN_ENCRYPTION_SECRET_KEY
124+
delete process.env.IMAGE_CDN_ENCRYPTION_IV
125+
}
126+
)
127+
})
128+
10129
describe(`generateFileUrl`, () => {
11130
it(`should return a file based url`, () => {
12131
const source = {

packages/gatsby-plugin-utils/src/polyfill-remote-file/utils/url-generator.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from "crypto"
12
import { basename, extname } from "path"
23
import { URL } from "url"
34
import { createContentDigest } from "gatsby-core-utils/create-content-digest"
@@ -9,10 +10,49 @@ const ORIGIN = `https://gatsbyjs.com`
910

1011
export enum ImageCDNUrlKeys {
1112
URL = `u`,
13+
ENCRYPTED_URL = `eu`,
1214
ARGS = `a`,
1315
CONTENT_DIGEST = `cd`,
1416
}
1517

18+
function encryptImageCdnUrl(
19+
secretKey: string,
20+
iv: string,
21+
urlToEncrypt: string
22+
): string {
23+
const randomPadding = crypto
24+
.randomBytes(crypto.randomInt(32, 64))
25+
.toString(`hex`)
26+
27+
const toEncrypt = `${randomPadding}:${urlToEncrypt}`
28+
const cipher = crypto.createCipheriv(
29+
`aes-256-ctr`,
30+
Buffer.from(secretKey, `hex`),
31+
Buffer.from(iv, `hex`)
32+
)
33+
const encrypted = cipher.update(toEncrypt)
34+
const finalBuffer = Buffer.concat([encrypted, cipher.final()])
35+
36+
return finalBuffer.toString(`hex`)
37+
}
38+
39+
function appendUrlParamToSearchParams(
40+
searchParams: URLSearchParams,
41+
url: string
42+
): void {
43+
const key = process.env.IMAGE_CDN_ENCRYPTION_SECRET_KEY || ``
44+
const iv = process.env.IMAGE_CDN_ENCRYPTION_IV || ``
45+
const shouldEncrypt = !!(iv && key)
46+
47+
const paramName = shouldEncrypt
48+
? ImageCDNUrlKeys.ENCRYPTED_URL
49+
: ImageCDNUrlKeys.URL
50+
51+
const finalUrl = shouldEncrypt ? encryptImageCdnUrl(key, iv, url) : url
52+
53+
searchParams.append(paramName, finalUrl)
54+
}
55+
1656
export function generateFileUrl({
1757
url,
1858
filename,
@@ -29,7 +69,7 @@ export function generateFileUrl({
2969
})}/${filenameWithoutExt}${fileExt}`
3070
)
3171

32-
parsedURL.searchParams.append(ImageCDNUrlKeys.URL, url)
72+
appendUrlParamToSearchParams(parsedURL.searchParams, url)
3373

3474
return `${parsedURL.pathname}${parsedURL.search}`
3575
}
@@ -52,7 +92,7 @@ export function generateImageUrl(
5292
)}/${filenameWithoutExt}.${imageArgs.format}`
5393
)
5494

55-
parsedURL.searchParams.append(ImageCDNUrlKeys.URL, source.url)
95+
appendUrlParamToSearchParams(parsedURL.searchParams, source.url)
5696
parsedURL.searchParams.append(ImageCDNUrlKeys.ARGS, queryStr)
5797
parsedURL.searchParams.append(
5898
ImageCDNUrlKeys.CONTENT_DIGEST,

0 commit comments

Comments
 (0)