Skip to content

Commit

Permalink
Fix a vulnerability issue for images using an older version of the im…
Browse files Browse the repository at this point in the history
…age signing parameter. (#2664)
  • Loading branch information
emmerich authored Dec 30, 2024
1 parent 2f6540e commit 99579ac
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 51 deletions.
5 changes: 5 additions & 0 deletions .changeset/twelve-books-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': minor
---

Fix a vulnerability issue for images using an older version of the image signing parameter.
41 changes: 26 additions & 15 deletions packages/gitbook/src/app/(global)/~gitbook/image/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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;
}
99 changes: 99 additions & 0 deletions packages/gitbook/src/lib/image-signatures.ts
Original file line number Diff line number Diff line change
@@ -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<SignatureVersion, (input: string) => MaybePromise<string>> =
{
'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<boolean> {
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<string> {
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;
}
40 changes: 4 additions & 36 deletions packages/gitbook/src/lib/images.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -31,8 +30,6 @@ export interface CloudflareImageOptions {
quality?: number;
}

export const imagesResizingSignVersion = '2';

/**
* Return true if images resizing is enabled.
*/
Expand Down Expand Up @@ -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());
Expand All @@ -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();
};
Expand All @@ -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<boolean> {
return generateSignature(input) === signature;
}

/**
* Get the size of an image.
*/
Expand Down Expand Up @@ -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);
}

0 comments on commit 99579ac

Please sign in to comment.