diff --git a/AGENTS.md b/AGENTS.md index 3183224..63b53c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Both the resource `id` and every modifier are attacker-controlled. Existing prot - `ipxHttpStorage`: domain allowlist, `http(s)` only, redirects followed manually one hop at a time and re-validated (SSRF). - `ipxFSStorage`: resolved path must stay inside the configured dir (traversal). - `maxOutputDimension` (default 8192) clamps `width`/`height`/`resize`/`extend` so a tiny source cannot force a multi-GB allocation. +- `allowedModifiers` (opt-in) rejects any modifier not listed (aliases follow their modifier) with a `400` before the source is fetched. - SVG is always sanitized (`src/svg.ts`), even with optimization disabled. - Server sends `content-security-policy: default-src 'none'` and `x-content-type-options: nosniff`. - `safeString()` in `server.ts` escapes parser output — custom `parseURL` results are never trusted. diff --git a/README.md b/README.md index 3cde43c..54a806e 100644 --- a/README.md +++ b/README.md @@ -480,9 +480,12 @@ Every option can also be set universally with an `IPX_*` environment variable, w | -------------------- | -------------------------- | ------- | --------------------------------------------------------- | | `alias` | `IPX_ALIAS` | `{}` | Map URL prefixes to other prefixes or remote origins. | | `maxOutputDimension` | `IPX_MAX_OUTPUT_DIMENSION` | `8192` | Maximum width and height (in pixels) of the output image. | +| `allowedModifiers` | `IPX_ALLOWED_MODIFIERS` | (all) | Modifiers that requests are allowed to use. | Requested `width`, `height` and `resize` dimensions are clamped to `maxOutputDimension`, 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. +`allowedModifiers` restricts a public endpoint to the modifiers a site actually uses, for example `allowedModifiers: ["width", "height", "format", "quality"]` or `IPX_ALLOWED_MODIFIERS=width,height,format,quality`. Aliases follow their modifier (allowing `width` also allows `w`). Any other modifier, including an unknown one, is rejected with a `400` before the source is fetched, so expensive operations (`blur`, `median`, an `avif` re-encode, ...) cannot be triggered and junk modifiers cannot be used to bypass a CDN cache. + ### Filesystem source (`ipxFSStorage`) Enabled by default with the CLI only. diff --git a/src/ipx.ts b/src/ipx.ts index b321def..75a0841 100644 --- a/src/ipx.ts +++ b/src/ipx.ts @@ -162,6 +162,22 @@ export type IPXOptions = { */ maxOutputDimension?: number | false; + /** + * Modifiers that requests are allowed to use. Any other modifier (including + * an unknown one) is rejected with a `400` before the source is fetched. + * + * Every modifier is allowed by default. Restricting them to the ones a site + * actually uses (for example `["width", "height", "format", "quality"]`) + * shrinks the attack surface of the public endpoint: expensive operations + * such as `blur`, `median` or an `avif` re-encode cannot be triggered, and + * junk modifiers cannot be used to bypass a CDN cache. + * + * Aliases follow their modifier: allowing `width` also allows `w`. + * + * @optional + */ + allowedModifiers?: (keyof IPXModifiers)[]; + /** * A mapping of URL aliases to their corresponding URLs, used to simplify resource identifiers. * @optional @@ -215,6 +231,45 @@ export type IPXOptions = { // which bounds what a single request can allocate. const DEFAULT_MAX_OUTPUT_DIMENSION = 8192; +// `format` and `animated` are read directly by `createIPX` rather than through +// a handler, so their aliases are spelled out here. +const NON_HANDLER_MODIFIERS = new Map([ + ["format", "format"], + ["f", "format"], + ["animated", "animated"], + ["a", "animated"], +]); + +// Handler aliases share the handler object (`w === width`), so comparing by +// handler makes an allowed modifier cover all of its aliases. +function getModifierKey(name: string): unknown { + return NON_HANDLER_MODIFIERS.get(name) ?? getHandler(name as HandlerName); +} + +function resolveAllowedModifiers( + names: string | string[] | undefined, +): Set | undefined { + if (names === undefined) { + return undefined; + } + if (typeof names === "string") { + names = names + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + return new Set( + names.map((name) => { + const key = getModifierKey(name); + // Fail loudly on a typo rather than rejecting every request using it. + if (!key) { + throw new Error(`[ipx] Unknown modifier in allowedModifiers: ${name}`); + } + return key; + }), + ); +} + // https://sharp.pixelplumbing.com/#formats // (gif and svg are not supported as output) const SUPPORTED_FORMATS = new Set([ @@ -253,6 +308,11 @@ export function createIPX(userOptions: IPXOptions): IPX { } as SharpOptions, } satisfies Omit; + const allowedModifiers = resolveAllowedModifiers( + userOptions.allowedModifiers ?? + getEnv("IPX_ALLOWED_MODIFIERS"), + ); + // Normalize alias to start with leading slash options.alias = Object.fromEntries( Object.entries(options.alias || {}).map((e) => [ @@ -283,6 +343,19 @@ export function createIPX(userOptions: IPXOptions): IPX { }); } + // Validate modifiers (before any source is fetched or processed) + if (allowedModifiers) { + for (const name of Object.keys(modifiers)) { + if (!allowedModifiers.has(getModifierKey(name))) { + throw new HTTPError({ + statusCode: 400, + statusText: `IPX_FORBIDDEN_MODIFIER`, + message: `Modifier is not allowed: ${name}`, + }); + } + } + } + // Enforce leading slash for non absolute urls id = hasProtocol(id) ? id : withLeadingSlash(id); diff --git a/test/index.test.ts b/test/index.test.ts index 5566697..23e5b83 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -6,6 +6,7 @@ import { imageMeta } from "image-meta"; import { type IPX, + type IPXOptions, type IPXStorage, createIPX, createIPXFetchHandler, @@ -411,6 +412,99 @@ describe("ipx", () => { }); }); + describe("allowedModifiers", () => { + const createRestrictedIPX = ( + allowedModifiers?: IPXOptions["allowedModifiers"], + ) => + createIPX({ + storage: ipxFSStorage({ dir: resolve(__dirname, "assets") }), + allowedModifiers, + }); + + const forbidden = { statusCode: 400, statusText: "IPX_FORBIDDEN_MODIFIER" }; + + it("allows listed modifiers and their aliases", async () => { + const restricted = createRestrictedIPX(["width", "format"]); + for (const modifiers of [ + { width: "100", format: "png" }, + { w: "100", f: "png" }, + ]) { + const { data, format } = await restricted( + "bliss.jpg", + modifiers, + ).process(); + expect(format).toBe("png"); + expect(imageMeta(data as Uint8Array).width).toBe(100); + } + }); + + it("allows a modifier listed by its alias", async () => { + const restricted = createRestrictedIPX(["w"]); + await expect( + restricted("bliss.jpg", { width: "100" }).process(), + ).resolves.toBeDefined(); + }); + + it.each<[string, Record]>([ + ["another modifier", { width: "100", blur: "5" }], + ["another modifier's alias", { w: "100", h: "100" }], + ["`animated` alias", { a: "" }], + ["an unknown modifier", { width: "100", cachebust: "1" }], + ])("rejects %s", (_name, modifiers) => { + const restricted = createRestrictedIPX(["width", "format"]); + expect(() => restricted("bliss.jpg", modifiers)).toThrow( + expect.objectContaining(forbidden), + ); + }); + + // Rejected up front, so the source is never fetched (or found missing). + it("rejects before resolving the source", () => { + const restricted = createRestrictedIPX(["width"]); + expect(() => restricted("missing.jpg", { blur: "5" })).toThrow( + expect.objectContaining(forbidden), + ); + }); + + it("allows no modifiers when empty", async () => { + const restricted = createRestrictedIPX([]); + expect(() => restricted("bliss.jpg", { width: "100" })).toThrow( + expect.objectContaining(forbidden), + ); + await expect(restricted("bliss.jpg").process()).resolves.toBeDefined(); + }); + + it("throws on an unknown modifier name", () => { + expect(() => createRestrictedIPX(["widht" as any])).toThrow( + "Unknown modifier in allowedModifiers: widht", + ); + }); + + it("can be set with `IPX_ALLOWED_MODIFIERS`", () => { + vi.stubEnv("IPX_ALLOWED_MODIFIERS", "width, format"); + try { + const restricted = createRestrictedIPX(); + expect(() => + restricted("bliss.jpg", { w: "100", f: "png" }), + ).not.toThrow(); + expect(() => restricted("bliss.jpg", { blur: "5" })).toThrow( + expect.objectContaining(forbidden), + ); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("responds with a 400 from the server", async () => { + const handler = createIPXFetchHandler(createRestrictedIPX(["width"])); + expect((await handler("http://localhost/w_100/bliss.jpg")).status).toBe( + 200, + ); + expect( + (await handler("http://localhost/w_100,blur_5/bliss.jpg")).status, + ).toBe(400); + }); + }); + describe("maxAge", () => { const sourceMaxAge = async (maxAge: unknown) => { const storage: IPXStorage = {