Skip to content

Commit 013c144

Browse files
pi0claude
andcommitted
fix(handlers): clamp output dimensions with maxOutputDimension option
Requested resize/width/height dimensions and extend edges are now capped to a configurable maximum output dimension (default 8192) to prevent memory exhaustion via requests like /enlarge,s_20000x20000/img.jpg. Set maxOutputDimension: false to disable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 30e92fb commit 013c144

8 files changed

Lines changed: 433 additions & 8 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ You can universally customize IPX configuration using `IPX_*` environment variab
193193

194194
- `IPX_ALIAS`
195195
- Default: `{}`
196+
- `IPX_MAX_OUTPUT_DIMENSION`
197+
- Default: `8192`
198+
- Maximum width and height (in pixels) of the output image (option: `maxOutputDimension`). Requested `width`, `height` and `resize` dimensions are clamped to it, preserving the requested aspect ratio, and `extend` edges are clamped so the extended canvas stays within it. This bounds how much memory a single request can allocate: sharp only limits the _input_ size, so without it `/enlarge,s_20000x20000/image.jpg` (or `/extend_10000_10000_10000_10000/image.jpg`) allocates gigabytes from a small source image. Set to `false` to disable, which is only safe when modifiers come from a trusted source.
196199

197200
### Filesystem Source Options
198201

src/handlers/handlers.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
VRequired,
88
VSize,
99
clampDimensionsPreservingAspectRatio,
10+
clampExtendEdges,
11+
clampToMaxDimension,
1012
} from "./utils.ts";
1113

1214
// Ranges below mirror the ones sharp enforces, so that invalid modifier
@@ -111,10 +113,19 @@ export const kernel: Handler = {
111113
},
112114
};
113115

116+
// `withoutEnlargement` is disabled by the `enlarge` modifier, so the requested
117+
// dimensions are additionally capped to `maxOutputDimension` (see
118+
// `clampToMaxDimension`) to bound how much memory the output can take.
119+
114120
export const width: Handler = {
115121
args: [VNumber("width", { min: 1, integer: true })],
116122
apply: (context, pipe, width) => {
117-
return pipe.resize(width, undefined, {
123+
const clamped = clampToMaxDimension(
124+
context.maxOutputDimension,
125+
{ width },
126+
context.meta,
127+
);
128+
return pipe.resize(clamped.width, undefined, {
118129
withoutEnlargement: !context.enlarge,
119130
});
120131
},
@@ -123,7 +134,12 @@ export const width: Handler = {
123134
export const height: Handler = {
124135
args: [VNumber("height", { min: 1, integer: true })],
125136
apply: (context, pipe, height) => {
126-
return pipe.resize(undefined, height, {
137+
const clamped = clampToMaxDimension(
138+
context.maxOutputDimension,
139+
{ height },
140+
context.meta,
141+
);
142+
return pipe.resize(undefined, clamped.height, {
127143
withoutEnlargement: !context.enlarge,
128144
});
129145
},
@@ -145,7 +161,12 @@ export const resize: Handler = {
145161
width = clamped.width;
146162
height = clamped.height;
147163
}
148-
return pipe.resize(width, height, {
164+
// Applies with `enlarge` too, where nothing else bounds the output size.
165+
const capped = clampToMaxDimension(context.maxOutputDimension, {
166+
width,
167+
height,
168+
});
169+
return pipe.resize(capped.width, capped.height, {
149170
fit: context.fit,
150171
position: context.position,
151172
background: context.background,
@@ -177,11 +198,18 @@ export const extend: Handler = {
177198
// `background` is set by the `background` / `b` modifier and only used when
178199
// extending with `background` (the sharp default).
179200
apply: (context, pipe, top, right, bottom, left, extendWith) => {
201+
// Edges grow the canvas without any input-size relation, so they are capped
202+
// to keep the extended canvas within `maxOutputDimension`.
203+
const edges = clampExtendEdges(
204+
context.maxOutputDimension,
205+
{ top, right, bottom, left },
206+
context.meta,
207+
);
180208
return pipe.extend({
181-
top,
182-
left,
183-
bottom,
184-
right,
209+
top: edges.top,
210+
left: edges.left,
211+
bottom: edges.bottom,
212+
right: edges.right,
185213
background: context.background,
186214
extendWith,
187215
});

src/handlers/utils.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,106 @@ export function applyHandler(
252252
}
253253
}
254254

255+
/**
256+
* Clamps requested output dimensions to `max`, preserving the requested aspect
257+
* ratio.
258+
*
259+
* Sharp's `limitInputPixels` only bounds the *input*, so without this cap a
260+
* request such as `/enlarge,s_20000x20000/img.jpg` makes sharp allocate a
261+
* ~1.6GB output buffer from an arbitrarily small source image.
262+
*
263+
* A side that was not requested stays omitted (sharp then derives it from the
264+
* source aspect ratio), but `source` is used to make sure that derived side
265+
* cannot exceed the limit either.
266+
*
267+
* @param max The limit, or `false`/`undefined` to leave the dimensions as-is.
268+
* @param desired The requested dimensions.
269+
* @param source The source dimensions, used to derive an omitted side.
270+
*/
271+
export function clampToMaxDimension(
272+
max: number | false | undefined,
273+
desired: { width?: number; height?: number },
274+
source?: { width?: number; height?: number },
275+
): { width?: number; height?: number } {
276+
const { width, height } = desired;
277+
if (!max || (width === undefined && height === undefined)) {
278+
return desired;
279+
}
280+
281+
// Sharp preserves the source aspect ratio when only one side is requested.
282+
const ratio =
283+
source?.width && source?.height ? source.width / source.height : undefined;
284+
const effectiveWidth =
285+
width ?? (ratio && height ? height * ratio : undefined);
286+
const effectiveHeight =
287+
height ?? (ratio && width ? width / ratio : undefined);
288+
289+
const scale = Math.min(
290+
effectiveWidth ? max / effectiveWidth : 1,
291+
effectiveHeight ? max / effectiveHeight : 1,
292+
);
293+
if (scale >= 1) {
294+
return desired;
295+
}
296+
297+
// Both sides are scaled by the same factor, so the requested aspect ratio is
298+
// preserved. `Math.min` guards against floating point rounding above `max`.
299+
const clamp = (value?: number) =>
300+
value === undefined
301+
? undefined
302+
: Math.max(1, Math.min(max, Math.round(value * scale)));
303+
return { width: clamp(width), height: clamp(height) };
304+
}
305+
306+
/**
307+
* Clamps `extend` edges so that the extended canvas cannot exceed `max` in
308+
* either dimension.
309+
*
310+
* Both edges of an axis are scaled down by the same factor rather than the
311+
* request being rejected, for consistency with how resize dimensions are
312+
* clamped. Omitted edges stay omitted so sharp applies its own default (`0`).
313+
*
314+
* @param max The limit, or `false`/`undefined` to leave the edges as-is.
315+
* @param edges The requested per-side extend values.
316+
* @param source The dimensions of the image being extended.
317+
*/
318+
export function clampExtendEdges(
319+
max: number | false | undefined,
320+
edges: { top?: number; right?: number; bottom?: number; left?: number },
321+
source?: { width?: number; height?: number },
322+
) {
323+
if (!max) {
324+
return edges;
325+
}
326+
const [top, bottom] = clampEdges(
327+
max - (source?.height || 0),
328+
edges.top,
329+
edges.bottom,
330+
);
331+
const [left, right] = clampEdges(
332+
max - (source?.width || 0),
333+
edges.left,
334+
edges.right,
335+
);
336+
return { top, right, bottom, left };
337+
}
338+
339+
/** Clamps the two edges of one axis to the space left within the limit. */
340+
function clampEdges(
341+
available: number,
342+
a: number | undefined,
343+
b: number | undefined,
344+
): [number | undefined, number | undefined] {
345+
const total = (a || 0) + (b || 0);
346+
if (total === 0 || total <= available) {
347+
return [a, b];
348+
}
349+
const scale = Math.max(0, available) / total;
350+
const clamp = (value?: number) =>
351+
value === undefined ? undefined : Math.floor(value * scale);
352+
return [clamp(a), clamp(b)];
353+
}
354+
255355
export function clampDimensionsPreservingAspectRatio(
256356
sourceDimensions: ImageMeta,
257357
desiredDimensions: { width: number; height: number },

src/ipx.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,27 @@ export type IPXOptions = {
128128
*/
129129
maxAge?: number;
130130

131+
/**
132+
* Maximum width and height (in pixels) of the output image.
133+
*
134+
* Modifiers are user input and sharp's `limitInputPixels` only bounds the
135+
* *input*, so without this limit a request such as
136+
* `/enlarge,s_20000x20000/img.jpg` or `/extend_10000_10000_10000_10000/img.jpg`
137+
* makes sharp allocate a multi-gigabyte output buffer from a tiny source
138+
* image, which is an easy way to exhaust the server memory.
139+
*
140+
* Requested `width`, `height`, `resize` dimensions are clamped to this value
141+
* (preserving the requested aspect ratio) and `extend` edges are clamped so
142+
* that the extended canvas stays within it.
143+
*
144+
* Set to `false` to disable the limit (only safe when modifiers come from a
145+
* trusted source).
146+
*
147+
* @default 8192
148+
* @optional
149+
*/
150+
maxOutputDimension?: number | false;
151+
131152
/**
132153
* A mapping of URL aliases to their corresponding URLs, used to simplify resource identifiers.
133154
* @optional
@@ -177,6 +198,10 @@ export type IPXOptions = {
177198
};
178199
};
179200

201+
// A common CDN limit. `8192 x 8192 x 4B` is ~268MB of uncompressed pixels,
202+
// which bounds what a single request can allocate.
203+
const DEFAULT_MAX_OUTPUT_DIMENSION = 8192;
204+
180205
// https://sharp.pixelplumbing.com/#formats
181206
// (gif and svg are not supported as output)
182207
const SUPPORTED_FORMATS = new Set([
@@ -203,6 +228,10 @@ export function createIPX(userOptions: IPXOptions): IPX {
203228
userOptions.alias || getEnv<Record<string, string>>("IPX_ALIAS") || {},
204229
maxAge:
205230
userOptions.maxAge ?? getEnv<number>("IPX_MAX_AGE") ?? 60 /* 1 minute */,
231+
maxOutputDimension:
232+
userOptions.maxOutputDimension ??
233+
getEnv<number | false>("IPX_MAX_OUTPUT_DIMENSION") ??
234+
DEFAULT_MAX_OUTPUT_DIMENSION,
206235
sharpOptions: {
207236
jpegProgressive: true,
208237
...userOptions.sharpOptions,
@@ -400,7 +429,10 @@ export function createIPX(userOptions: IPXOptions): IPX {
400429
});
401430

402431
// Apply handlers
403-
const handlerContext: any = { meta: imageMeta };
432+
const handlerContext: any = {
433+
meta: imageMeta,
434+
maxOutputDimension: options.maxOutputDimension,
435+
};
404436
for (const h of handlers) {
405437
sharp =
406438
applyHandler(handlerContext, sharp, h.handler, h.args.toString()) ||

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ export interface HandlerContext {
4040
*/
4141
kernel?: keyof KernelEnum;
4242

43+
/**
44+
* The maximum width and height of the output image, or `false` when
45+
* unlimited. Set from the `maxOutputDimension` IPX option.
46+
* @optional
47+
*/
48+
maxOutputDimension?: number | false;
49+
4350
/**
4451
* Metadata about the image being processed.
4552
*/

test/handlers/handlers.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,103 @@ describe("handlers", () => {
404404
});
405405
});
406406

407+
// Modifiers are user input and sharp only limits the *input* size, so handlers
408+
// growing the output are capped by `context.maxOutputDimension`.
409+
describe("maxOutputDimension", () => {
410+
const context = (maxOutputDimension: number | false | undefined) =>
411+
({
412+
// `enlarge` disables every other bound on the output size
413+
enlarge: true,
414+
maxOutputDimension,
415+
meta: { width: 400, height: 200 },
416+
}) as any;
417+
418+
it("resize is clamped, preserving the requested aspect ratio", () => {
419+
const pipe = { resize: vi.fn() };
420+
421+
applyHandler(context(1000), pipe as any, resize, "8000x4000");
422+
423+
expect(pipe.resize).toHaveBeenCalledWith(1000, 500, expect.anything());
424+
});
425+
426+
it("width is clamped", () => {
427+
const pipe = { resize: vi.fn() };
428+
429+
applyHandler(context(1000), pipe as any, width, "8000");
430+
431+
expect(pipe.resize).toHaveBeenCalledWith(1000, undefined, {
432+
withoutEnlargement: false,
433+
});
434+
});
435+
436+
it("height is clamped", () => {
437+
const pipe = { resize: vi.fn() };
438+
439+
applyHandler(context(1000), pipe as any, height, "8000");
440+
441+
// 500, not 1000: the source is twice as wide as it is tall, so a height of
442+
// 1000 would give a derived width of 2000.
443+
expect(pipe.resize).toHaveBeenCalledWith(undefined, 500, {
444+
withoutEnlargement: false,
445+
});
446+
});
447+
448+
// Sharp derives the omitted side from the source aspect ratio, so a single
449+
// dimension within the limit can still blow up the other one.
450+
it("width is clamped for the derived height", () => {
451+
const pipe = { resize: vi.fn() };
452+
const tall = { ...context(1000), meta: { width: 200, height: 4000 } };
453+
454+
applyHandler(tall, pipe as any, width, "1000");
455+
456+
expect(pipe.resize).toHaveBeenCalledWith(50, undefined, {
457+
withoutEnlargement: false,
458+
});
459+
});
460+
461+
it("extend edges are clamped to fit the canvas within the limit", () => {
462+
const pipe = { extend: vi.fn() };
463+
464+
applyHandler(context(1000), pipe as any, extend, "5000_5000_5000_5000");
465+
466+
expect(pipe.extend).toHaveBeenCalledWith({
467+
top: 400,
468+
bottom: 400,
469+
left: 300,
470+
right: 300,
471+
background: undefined,
472+
extendWith: undefined,
473+
});
474+
});
475+
476+
it.each<false | undefined>([false, undefined])(
477+
"is disabled with `%s`",
478+
(max) => {
479+
const pipe = { resize: vi.fn(), extend: vi.fn() };
480+
481+
applyHandler(context(max), pipe as any, resize, "8000x4000");
482+
applyHandler(context(max), pipe as any, extend, "5000");
483+
484+
expect(pipe.resize).toHaveBeenCalledWith(8000, 4000, expect.anything());
485+
expect(pipe.extend).toHaveBeenCalledWith(
486+
expect.objectContaining({ top: 5000 }),
487+
);
488+
},
489+
);
490+
491+
it("does not affect requests within the limit", () => {
492+
const pipe = { resize: vi.fn(), extend: vi.fn() };
493+
494+
applyHandler(context(1000), pipe as any, resize, "300x150");
495+
applyHandler(context(1000), pipe as any, extend, "10_20_30_40");
496+
497+
expect(pipe.resize).toHaveBeenCalledWith(300, 150, expect.anything());
498+
expect(pipe.extend).toHaveBeenCalledWith(
499+
expect.objectContaining({ top: 10, right: 20, bottom: 30, left: 40 }),
500+
);
501+
});
502+
});
503+
407504
// Every modifier argument is validated up front so that bad input is a 400
408505
// instead of an unhandled sharp error (a 500) further down the pipeline.
409506
describe("handler args", () => {

0 commit comments

Comments
 (0)