Skip to content

Commit ffd3937

Browse files
authored
Fix security issue with images resizing (#2595)
1 parent 575587e commit ffd3937

File tree

3 files changed

+33
-31
lines changed

3 files changed

+33
-31
lines changed

.changeset/purple-tables-join.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': patch
3+
---
4+
5+
Fix security issue with image resizing that could be used for phishing

packages/gitbook/src/app/(global)/~gitbook/image/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
resizeImage,
66
CloudflareImageOptions,
77
checkIsSizableImageURL,
8+
imagesResizingSignVersion,
89
} from '@/lib/images';
910
import { parseImageAPIURL } from '@/lib/urls';
1011

@@ -18,9 +19,9 @@ export const runtime = 'edge';
1819
export async function GET(request: NextRequest) {
1920
let urlParam = request.nextUrl.searchParams.get('url');
2021
const signature = request.nextUrl.searchParams.get('sign');
21-
// The current signature algorithm sets version as 1, but we need to support the older version as well
22-
// for previously generated content. In this case, we default to version 0.
23-
const signatureVersion = (request.nextUrl.searchParams.get('sv') as '1') || '0';
22+
23+
// The current signature algorithm sets version as 2, but we need to support the older version as well
24+
const signatureVersion = request.nextUrl.searchParams.get('sv') as string | undefined;
2425
if (!urlParam || !signature) {
2526
return new Response('Missing url/sign parameters', { status: 400 });
2627
}
@@ -32,14 +33,19 @@ export async function GET(request: NextRequest) {
3233
return new Response('Invalid url parameter', { status: 400 });
3334
}
3435

36+
// For older signatures, we redirect to the url.
37+
if (signatureVersion !== imagesResizingSignVersion) {
38+
return Response.redirect(url, 302);
39+
}
40+
3541
// Check again if the image can be sized, even though we checked when rendering the Image component
3642
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
3743
if (!checkIsSizableImageURL(url)) {
3844
return new Response('Invalid url parameter', { status: 400 });
3945
}
4046

4147
// Verify the signature
42-
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
48+
const verified = await verifyImageSignature(url, { signature });
4349
if (!verified) {
4450
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
4551
}

packages/gitbook/src/lib/images.ts

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import fnv1a from '@sindresorhus/fnv1a';
44

55
import { noCacheFetchOptions } from '@/lib/cache/http';
66

7-
import { rootUrl } from './links';
7+
import { host, rootUrl } from './links';
88
import { getImageAPIUrl } from './urls';
99

1010
export interface CloudflareImageJsonFormat {
@@ -31,6 +31,8 @@ export interface CloudflareImageOptions {
3131
quality?: number;
3232
}
3333

34+
export const imagesResizingSignVersion = '2';
35+
3436
/**
3537
* Return true if images resizing is enabled.
3638
*/
@@ -83,7 +85,7 @@ export function getResizedImageURLFactory(
8385
return null;
8486
}
8587

86-
const signature = generateSignatureV1(input);
88+
const signature = generateSignature(input);
8789

8890
return (options) => {
8991
const url = new URL('/~gitbook/image', rootUrl());
@@ -103,7 +105,7 @@ export function getResizedImageURLFactory(
103105
}
104106

105107
url.searchParams.set('sign', signature);
106-
url.searchParams.set('sv', '1');
108+
url.searchParams.set('sv', imagesResizingSignVersion);
107109

108110
return url.toString();
109111
};
@@ -123,11 +125,9 @@ export function getResizedImageURL(input: string, options: ResizeImageOptions):
123125
*/
124126
export async function verifyImageSignature(
125127
input: string,
126-
{ signature, version }: { signature: string; version: '1' | '0' },
128+
{ signature }: { signature: string },
127129
): Promise<boolean> {
128-
const expectedSignature =
129-
version === '1' ? generateSignatureV1(input) : await generateSignatureV0(input);
130-
return expectedSignature === signature;
130+
return generateSignature(input) === signature;
131131
}
132132

133133
/**
@@ -229,26 +229,17 @@ function stringifyOptions(options: CloudflareImageOptions): string {
229229
const fnv1aUtf8Buffer = new Uint8Array(512);
230230

231231
/**
232-
* New and faster algorithm to generate a signature for an image.
233-
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
234-
* to know that it was the algorithm that was used.
232+
* Generate a signature for an image.
233+
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
235234
*/
236-
function generateSignatureV1(input: string): string {
237-
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
235+
function generateSignature(input: string) {
236+
const hostName = host();
237+
const all = [
238+
input,
239+
hostName, // The hostname is used to avoid serving images from other sites on the same domain
240+
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
241+
]
242+
.filter(Boolean)
243+
.join(':');
238244
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
239245
}
240-
241-
/**
242-
* Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL.
243-
* We still need it to validate older signatures that were generated without versioning
244-
* but still exist in previously generated and cached content.
245-
*/
246-
async function generateSignatureV0(input: string): Promise<string> {
247-
const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
248-
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
249-
250-
// Convert ArrayBuffer to hex string
251-
const hashArray = Array.from(new Uint8Array(hash));
252-
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
253-
return hashHex;
254-
}

0 commit comments

Comments
 (0)