Fix a vulnerability issue for images using an older version of the image signing parameter. (#2664)

This commit is contained in:
Steven H
2024-12-30 11:29:30 +00:00
committed by GitHub
parent 2f6540e008
commit 99579ac29c
4 changed files with 134 additions and 51 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix a vulnerability issue for images using an older version of the image signing parameter.
@@ -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;
}
@@ -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;
}
+4 -36
View File
@@ -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<boolean> {
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);
}