From 99579ac29cb2e4a5bb2d37094fe2b9038df8f8e4 Mon Sep 17 00:00:00 2001 From: Steven H Date: Mon, 30 Dec 2024 11:29:30 +0000 Subject: [PATCH] Fix a vulnerability issue for images using an older version of the image signing parameter. (#2664) --- .changeset/twelve-books-dream.md | 5 + .../src/app/(global)/~gitbook/image/route.ts | 41 +++++--- packages/gitbook/src/lib/image-signatures.ts | 99 +++++++++++++++++++ packages/gitbook/src/lib/images.ts | 40 +------- 4 files changed, 134 insertions(+), 51 deletions(-) create mode 100644 .changeset/twelve-books-dream.md create mode 100644 packages/gitbook/src/lib/image-signatures.ts diff --git a/.changeset/twelve-books-dream.md b/.changeset/twelve-books-dream.md new file mode 100644 index 000000000..45abb14a0 --- /dev/null +++ b/.changeset/twelve-books-dream.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Fix a vulnerability issue for images using an older version of the image signing parameter. diff --git a/packages/gitbook/src/app/(global)/~gitbook/image/route.ts b/packages/gitbook/src/app/(global)/~gitbook/image/route.ts index 7d1bfde3b..1e00ca5ec 100644 --- a/packages/gitbook/src/app/(global)/~gitbook/image/route.ts +++ b/packages/gitbook/src/app/(global)/~gitbook/image/route.ts @@ -1,12 +1,7 @@ import { NextRequest } from 'next/server'; -import { - verifyImageSignature, - resizeImage, - CloudflareImageOptions, - imagesResizingSignVersion, - checkIsSizableImageURL, -} from '@/lib/images'; +import { isSignatureVersion, SignatureVersion, verifyImageSignature } from '@/lib/image-signatures'; +import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images'; import { parseImageAPIURL } from '@/lib/urls'; export const runtime = 'edge'; @@ -20,12 +15,15 @@ export async function GET(request: NextRequest) { let urlParam = request.nextUrl.searchParams.get('url'); const signature = request.nextUrl.searchParams.get('sign'); - // The current signature algorithm sets version as 2, but we need to support the older version as well - const signatureVersion = request.nextUrl.searchParams.get('sv') as string | undefined; if (!urlParam || !signature) { return new Response('Missing url/sign parameters', { status: 400 }); } + const signatureVersion = parseSignatureVersion(request.nextUrl.searchParams.get('sv')); + if (!signatureVersion) { + return new Response(`Invalid sv parameter`, { status: 400 }); + } + const url = parseImageAPIURL(urlParam); // Check again if the image can be sized, even though we checked when rendering the Image component @@ -35,13 +33,8 @@ export async function GET(request: NextRequest) { return new Response('Invalid url parameter', { status: 400 }); } - // For older signatures, we redirect to the url. - if (signatureVersion !== imagesResizingSignVersion) { - return Response.redirect(url, 302); - } - // Verify the signature - const verified = await verifyImageSignature(url, { signature }); + const verified = await verifyImageSignature(url, { signature, version: signatureVersion }); if (!verified) { return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 }); } @@ -93,3 +86,21 @@ export async function GET(request: NextRequest) { return Response.redirect(url, 302); } } + +/** + * Parse the image signature version from a query param. Returns null if the version is invalid. + */ +function parseSignatureVersion(input: string | null): SignatureVersion | null { + // Before introducing the sv parameter, all signatures were generated with version 0. + if (!input) { + return '0'; + } + + // If the query param explicitly asks for a signature version. + if (isSignatureVersion(input)) { + return input; + } + + // Otherwise the version is invalid. + return null; +} diff --git a/packages/gitbook/src/lib/image-signatures.ts b/packages/gitbook/src/lib/image-signatures.ts new file mode 100644 index 000000000..aa3a90fe3 --- /dev/null +++ b/packages/gitbook/src/lib/image-signatures.ts @@ -0,0 +1,99 @@ +import 'server-only'; + +import fnv1a from '@sindresorhus/fnv1a'; +import type { MaybePromise } from 'p-map'; + +import { host } from './links'; + +/** + * GitBook has supported different version of image signing in the past. To maintain backwards + * compatibility, we retain the ability to verify older signatures. + */ +export type SignatureVersion = '0' | '1' | '2'; + +/** + * A mapping of signature versions to signature functions. + */ +const IMAGE_SIGNATURE_FUNCTIONS: Record MaybePromise> = + { + '0': generateSignatureV0, + '1': generateSignatureV1, + '2': generateSignatureV2, + }; + +export function isSignatureVersion(input: string): input is SignatureVersion { + return Object.keys(IMAGE_SIGNATURE_FUNCTIONS).includes(input); +} + +/** + * Verify a signature of an image URL + */ +export async function verifyImageSignature( + input: string, + { signature, version }: { signature: string; version: SignatureVersion }, +): Promise { + const generator = IMAGE_SIGNATURE_FUNCTIONS[version]; + const generated = await generator(input); + return generated === signature; +} + +/** + * Generate an image signature. Also returns the version of the image signing algorithm that was used. + * + * This function is sync. If you need to implement an async version of image signing, you'll need to change + * ths signature of this fn and where it's used. + */ +export function generateImageSignature(input: string): { + signature: string; + version: SignatureVersion; +} { + const result = generateSignatureV2(input); + return { signature: result, version: '2' }; +} + +// Reused buffer for FNV-1a hashing in the v2 algorithm +const fnv1aUtf8Buffer = new Uint8Array(512); + +/** + * Generate a signature for an image. + * The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain. + */ +function generateSignatureV2(input: string): string { + const hostName = host(); + const all = [ + input, + hostName, // The hostname is used to avoid serving images from other sites on the same domain + process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY, + ] + .filter(Boolean) + .join(':'); + return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16); +} + +// Reused buffer for FNV-1a hashing in the v1 algorithm +const fnv1aUtf8BufferV1 = new Uint8Array(512); + +/** + * New and faster algorithm to generate a signature for an image. + * When setting it in a URL, we use version '1' for the 'sv' querystring parameneter + * to know that it was the algorithm that was used. + */ +function generateSignatureV1(input: string): string { + const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':'); + return fnv1a(all, { utf8Buffer: fnv1aUtf8BufferV1 }).toString(16); +} + +/** + * Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL. + * We still need it to validate older signatures that were generated without versioning + * but still exist in previously generated and cached content. + */ +async function generateSignatureV0(input: string): Promise { + const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':'); + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all)); + + // Convert ArrayBuffer to hex string + const hashArray = Array.from(new Uint8Array(hash)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex; +} diff --git a/packages/gitbook/src/lib/images.ts b/packages/gitbook/src/lib/images.ts index 66ffdeb10..1ab3e01d9 100644 --- a/packages/gitbook/src/lib/images.ts +++ b/packages/gitbook/src/lib/images.ts @@ -1,10 +1,9 @@ import 'server-only'; -import fnv1a from '@sindresorhus/fnv1a'; - import { noCacheFetchOptions } from '@/lib/cache/http'; -import { host, rootUrl } from './links'; +import { generateImageSignature } from './image-signatures'; +import { rootUrl } from './links'; import { getImageAPIUrl } from './urls'; export interface CloudflareImageJsonFormat { @@ -31,8 +30,6 @@ export interface CloudflareImageOptions { quality?: number; } -export const imagesResizingSignVersion = '2'; - /** * Return true if images resizing is enabled. */ @@ -94,7 +91,7 @@ export function getResizedImageURLFactory( return null; } - const signature = generateSignature(input); + const { signature, version } = generateImageSignature(input); return (options) => { const url = new URL('/~gitbook/image', rootUrl()); @@ -114,7 +111,7 @@ export function getResizedImageURLFactory( } url.searchParams.set('sign', signature); - url.searchParams.set('sv', imagesResizingSignVersion); + url.searchParams.set('sv', version); return url.toString(); }; @@ -129,16 +126,6 @@ export function getResizedImageURL(input: string, options: ResizeImageOptions): return factory?.(options) ?? input; } -/** - * Verify a signature of an image URL - */ -export async function verifyImageSignature( - input: string, - { signature }: { signature: string }, -): Promise { - return generateSignature(input) === signature; -} - /** * Get the size of an image. */ @@ -233,22 +220,3 @@ function stringifyOptions(options: CloudflareImageOptions): string { return `${rest}${rest ? ',' : ''}${key}=${value}`; }, ''); } - -// Reused buffer for FNV-1a hashing -const fnv1aUtf8Buffer = new Uint8Array(512); - -/** - * Generate a signature for an image. - * The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain. - */ -function generateSignature(input: string) { - const hostName = host(); - const all = [ - input, - hostName, // The hostname is used to avoid serving images from other sites on the same domain - process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY, - ] - .filter(Boolean) - .join(':'); - return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16); -}