ipx
v4.1.0
Published
High performance, secure and easy-to-use image optimizer.
Readme
🖼️ IPX
High performance, secure and easy-to-use image optimizer powered by sharp and svgo.
Point IPX at a directory or a list of allowed domains, and every image is available in any size, format and quality straight from its URL:
/w_512,f_webp/photos/buffalo.png[!NOTE] This is the active development branch for IPX v4. Check out v3 for v3 docs, and release notes (alpha, beta) if you are upgrading.
Quick Start
Start a server for the images in the current directory with the ipx command:
npx ipx serve --dir ./Using bun:
bunx ipx serve --dir ./Then open the printed URL and add modifiers to any image path, for example http://localhost:3000/w_200/buffalo.png.
To embed IPX in your own app instead, see the Programmatic API.
Image URLs
A request URL is a list of modifiers, followed by the id of the source image:
/<modifiers>/<id>Multiple modifiers are separated by , and their arguments by _. Use _ alone when no modifier is needed.
| URL | Result |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| /_/static/buffalo.png | The original image. |
| /w_200/static/buffalo.png | Width set to 200, original format (png) kept. |
| /f_webp/static/buffalo.png | Format changed to webp, everything else kept as in the source. |
| /f_auto/static/buffalo.png | Best format for the client (avif/webp/jpeg), negotiated from the browser's accept header. |
| /s_200x200,fit_contain,f_webp/static/buffalo.png | Resized to fit inside 200x200px on a background canvas and converted to webp. |
The URL style is configurable, see Custom URL Style.
Modifiers
Modifier arguments are separated with _ and validated before they reach sharp. Invalid input is rejected with a 400 IPX_INVALID_MODIFIER_ARG (or 400 IPX_MISSING_MODIFIER_ARG for a required argument) instead of failing the request. Some arguments can only be validated by libvips once it runs, which happens after the whole pipeline is set up; those surface as a 400 IPX_INVALID_MODIFIER. Trailing arguments may be omitted to keep the sharp default, except where noted.
Colours (background, tint) accept any colour sharp understands: hex (f00, ff0000, ff000080) with an optional leading # — which cannot be used inside a URL path, so it may be dropped — or a CSS colour name (red). Boolean arguments accept true / false as well as the shorter 1 / 0.
Programmatic API
Create an IPX instance with createIPX(), then either serve it directly or mount it as a handler in your own app.
Example: Using built-in server
import { serveIPX, createIPX, ipxFSStorage, ipxHttpStorage } from "ipx";
const ipx = createIPX({
storage: ipxFSStorage({ dir: "./public" }),
httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }),
alias: { "/picsum": "https://picsum.photos" },
});
// http://localhost:3000/w_512/picsum/1000
serveIPX(ipx);Example: Using with h3
import { H3, serve } from "h3";
import {
createIPX,
ipxFSStorage,
ipxHttpStorage,
createIPXFetchHandler,
} from "ipx";
const ipx = createIPX({
storage: ipxFSStorage({ dir: "./public" }),
httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }),
alias: { "/picsum": "https://picsum.photos" },
});
const app = new H3();
app.mount("/ipx", createIPXFetchHandler(ipx));
// http://localhost:3000/ipx/w_512/picsum/1000
serve(app);Example: Using with express
import Express from "express";
import {
createIPX,
ipxFSStorage,
ipxHttpStorage,
createIPXNodeHandler,
} from "ipx";
import type { RequestHandler } from "express";
const ipx = createIPX({
storage: ipxFSStorage({ dir: "./public" }),
httpStorage: ipxHttpStorage({ domains: ["picsum.photos"] }),
alias: { "/picsum": "https://picsum.photos" },
});
const app = Express();
app.use("/ipx", createIPXNodeHandler(ipx) as RequestHandler);
// http://localhost:3000/ipx/w_512/picsum/1000
app.listen(3000, () => {
console.log("Server is running on http://localhost:3000");
});Config
Every option can also be set universally with an IPX_* environment variable, which is how the CLI is configured. Explicit options win over the environment.
General
| Option | Environment variable | Default | Description |
| --------------------- | --------------------------- | ------- | ---------------------------------------------------------------------- |
| 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. |
| allowedInputFormats | IPX_ALLOWED_INPUT_FORMATS | (all)* | Source image formats that are allowed to be processed. |
| restrictDecoders | IPX_RESTRICT_DECODERS | false | Also block the libvips decoders of every other format (sharp.block). |
* With restrictDecoders, allowedInputFormats defaults to the web formats instead (WEB_INPUT_FORMATS).
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.
When all pages of a multi-page image are decoded (animated, GIF output, or animated / pages in sharpOptions), the total height (page height × number of pages) must stay below libvips' VIPS_MAX_COORD (default 100000000), or the request is rejected with 400 IPX_INVALID_IMAGE before any page is decoded. Like maxOutputDimension, this bounds what a single request can make the decoder produce. Setting the VIPS_MAX_COORD environment variable to a smaller number lowers the limit; it cannot raise it.
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, ...) cannot be triggered. Only modifier names are checked, not their values: allowing format allows every supported output format, including the slow-to-encode avif. The same image also stays reachable under many URLs (?cb=1, w_10 vs w_1e1, ...), so this does not prevent busting a CDN cache.
The source bytes are as untrusted as the URL, and libvips ships a decoder for many more formats than a site usually serves. allowedInputFormats keeps the rarely-used ones out of reach, for example allowedInputFormats: WEB_INPUT_FORMATS (jpeg, png, webp, gif, svg, avif, heic) or IPX_ALLOWED_INPUT_FORMATS=jpeg,png,webp. The format is the one detected from the source bytes, not the file extension (jpeg/jpg and heif/heic are interchangeable). A source of any other format is rejected with 403 IPX_FORBIDDEN_INPUT_FORMAT before sharp or SVGO see any of it. Detection only looks at the first bytes, so a file can be crafted to be detected as an allowed format while libvips reads it as another; restrictDecoders closes that gap.
restrictDecoders adds a second layer: it blocks every libvips decoder except those of allowedInputFormats (which then defaults to WEB_INPUT_FORMATS), so libvips refuses a blocked format even if a file is crafted to look like an allowed one. Formats that libvips only reads through ImageMagick in custom builds (bmp, ico, psd, ...) stay blocked. Output formats are not affected.
[!WARNING]
restrictDecodersusessharp.block()/sharp.unblock(), which change process-wide libvips state. It affects every user of sharp in the process, not only IPX: in a host app (such as Nuxt), the app's own sharp code can no longer decode the blocked formats either, and the decoders IPX re-enables override any the app blocked on purpose. The block is applied when an instance first hands an image to sharp, so nothing is blocked before that. The decoders left enabled are the union of every such instance's formats: a later instance can widen the set for the whole process but never narrows it, and each instance's own format check still applies. An allowed SVG can still embed PNG or JPEG images, which the SVG renderer decodes on its own.IPX_RESTRICT_DECODERSaccepts onlytrue/false(or1/0). Only enable it when IPX owns the process, or when the whole app agrees with the policy. The format check ofallowedInputFormatsitself is per instance and touches no global state.
The ipx serve CLI owns its process, so it is a good fit for IPX_RESTRICT_DECODERS=true. Combine it with IPX_ALLOWED_INPUT_FORMATS to change the allowed formats (for example to add tiff).
Server (createIPXFetchHandler, createIPXNodeHandler, serveIPX)
| Option | Environment variable | Default | Description |
| ------------- | -------------------- | ---------------------------------------------------- | ------------------------------------------------------- |
| autoFormats | IPX_AUTO_FORMATS | avif, webp, jpeg, png, tiff, heif, gif | Formats f_auto can pick from, in order of preference. |
f_auto serves the format that the client's accept header lists with the highest q-value, and equal q-values go to the earlier entry in autoFormats. Wildcards such as image/* and */* do not match a format, so browsers only negotiate the formats they list explicitly (in practice avif, webp and png). When nothing matches, jpeg is served, even if it is not in the list. Animated images only consider webp and gif, and fall back to gif.
AVIF compresses best but is much slower to encode, so leave it out when encoding time matters more than size:
createIPXFetchHandler(ipx, { autoFormats: ["webp", "jpeg"] });With the CLI: IPX_AUTO_FORMATS=webp,jpeg ipx serve. Unknown formats throw when the handler is created.
Filesystem source (ipxFSStorage)
Enabled by default with the CLI only.
| Option | Environment variable | Default | Description |
| ------------------------- | ----------------------------------- | ----------------------- | ------------------------------------------------------ |
| dir | IPX_FS_DIR | . (current directory) | Directory (or directories) files are served from. |
| maxAge | IPX_FS_MAX_AGE | 60 (via maxAge) | cache-control max-age, in seconds, for served files. |
| allowSymlinksOutsideDir | IPX_FS_ALLOW_SYMLINKS_OUTSIDE_DIR | false | Allows symlinks inside dir to resolve outside of it. |
HTTP(s) source (ipxHttpStorage)
Enabled by default with the CLI only.
| Option | Environment variable | Default | Description |
| ----------------- | ---------------------------- | ------- | --------------------------------------------------------------------------------------------- |
| domains | IPX_HTTP_DOMAINS | [] | Allowlist of hostnames images can be fetched from. |
| maxAge | IPX_HTTP_MAX_AGE | 300 | cache-control max-age, in seconds, when upstream sends none. |
| fetchOptions | IPX_HTTP_FETCH_OPTIONS | {} | Passed to fetch(). |
| allowAllDomains | IPX_HTTP_ALLOW_ALL_DOMAINS | false | Disables the allowlist. Unsafe on a public server. |
| blockPrivateIPs | IPX_HTTP_BLOCK_PRIVATE_IPS | false | Rejects hosts that are, or resolve to, a non-public IP address (best effort, see note below). |
Only http: and https: URLs are allowed (anything else is rejected with 403 IPX_FORBIDDEN_PROTOCOL), and redirects are followed only within the allowlist, up to 3 hops: each redirect target is re-validated and a redirect to a host that is not listed is rejected with 403 IPX_FORBIDDEN_HOST (502 IPX_TOO_MANY_REDIRECTS beyond 3 hops). Previously redirects were followed blindly, which let an allowlisted host with an open redirect bounce IPX to internal addresses such as the cloud metadata service (SSRF). If an allowlisted host redirects to a CDN, add the CDN hostname to the allowlist as well. Redirect re-validation is skipped when allowAllDomains is enabled without blockPrivateIPs (nothing to validate) or when redirect is explicitly set in fetchOptions.
Blocking private IP addresses
The allowlist matches hostnames, not addresses, so an allowlisted host whose DNS record points at 127.0.0.1, 169.254.169.254 or an RFC1918 address is fetched like any other. That is intended — allowlisted domains are trusted — but blockPrivateIPs: true adds a second line of defense for setups where the allowlist is broad (or allowAllDomains is on):
ipxHttpStorage({ domains: ["cdn.example.com"], blockPrivateIPs: true });The host of the requested URL and of every redirect hop must be a public address. IP literals are checked directly, hostnames are resolved with the OS resolver (dns.lookup, so /etc/hosts counts) and all returned addresses must be public. Loopback, 0.0.0.0/8, RFC1918, CGNAT (100.64.0.0/10), link-local (169.254.0.0/16, fe80::/10), unique-local (fc00::/7), multicast, reserved/test ranges and the IPv4-mapped/compatible IPv6 forms of all of them (::ffff:127.0.0.1 and friends) are rejected with 403 IPX_FORBIDDEN_IP. A host that cannot be resolved is a 502 IPX_DNS_LOOKUP_FAILED, and a runtime without node:net/node:dns a 500 IPX_IP_CHECK_UNAVAILABLE — the check fails closed rather than quietly turning itself off.
It is off by default: many deployments legitimately fetch from in-cluster origins (internal object storage, a sidecar, localhost in development), and enabling it by default would break them.
[!WARNING]
blockPrivateIPsis best effort and does not protect against DNS rebinding. The hostname is resolved once to validate it and again whenfetchopens the socket, so a DNS record the attacker controls (with a very short TTL) can answer with a public address during validation and a private one during the fetch. This matters when the attacker can choose the hostname: withallowAllDomains, or an allowlisted domain whose DNS you do not control. If IPX must not reach internal services, enforce it at the network level: run it behind an egress proxy, or a firewall / network policy that blocks private ranges and the cloud metadata service (on AWS, also require IMDSv2).
It is also not a substitute for the allowlist — a public address that happens to be reachable is still fetched.
SVG Images
SVG images are not processed by sharp. They are sanitized, optimized with svgo and served as image/svg+xml. Input that is not well-formed XML (an unescaped & is a common cause) is rejected with a 400 IPX_INVALID_SVG.
createIPX({
storage,
svg: {
// SVGO config, or `false` to disable optimization
optimize: { multipass: true },
// Serve SVG images unsanitized. Only for fully trusted sources!
unsafeSkipSanitize: false,
},
});Optimization
SVGO's preset-default is applied unless you configure plugins yourself. Output is always a re-serialized document, never byte-identical to the source: ids are renamed, elements that are neither visible nor referenced within the same file are dropped, shapes are converted to paths and <style> rules are inlined.
That is fine for images used as <img src> or as CSS backgrounds, but it can break consumers that reach into the document:
- Sprite sheets: a
<symbol id="icon">with no<use>in the same file is removed, so<use href="/sprite.svg#icon">renders nothing. - References by id from outside the file, since ids are renamed (
icon-homebecomesa). - Selectors in a host page that inlines the SVG, since
<rect>becomes<path>and class-based rules are inlined.
Use svg: { optimize: false } to sanitize without optimizing, or keep optimization with the structural plugins disabled (about 1% larger output for typical icons):
createIPX({
storage,
svg: {
optimize: {
plugins: [
{
name: "preset-default",
params: {
overrides: { cleanupIds: false, removeHiddenElems: false },
},
},
],
},
},
});Sanitization
SVG documents can carry active content, so IPX always sanitizes them before serving. Sanitization is independent of optimization: svg: { optimize: false } only disables SVGO's optimization plugins.
Removed from every SVG:
<script>elements (including namespaced ones such as<svg:script>)- Event handler attributes (any
on*attribute) - Embedded foreign documents:
<foreignObject>,<iframe>,<embed>,<object>,<base>,<link>and<meta> - Event handler elements:
<handler>and<listener> - SMIL animations (
<animate>,<animateMotion>,<animateTransform>and<set>) that assign anon*attribute or an unsafe URI, which could otherwise re-introduce a handler after load - URIs (
href,xlink:hrefandsrc) with a scheme other thanhttp:,https:,mailto:,tel:,ftp:or a non-SVGdata:image/*— in particularjavascript:, including obfuscated variants using entities or control characters - The
<!DOCTYPE>declaration and all processing instructions (<?…?>), which are serialized unescaped and can smuggle markup past an HTML parser
External references are kept. Attributes such as <image href="https://…">, <use href="…">, external fonts and @import inside <style> are preserved, since stripping them would break legitimate images. They are not a script execution vector, but they do allow the SVG to make requests to third-party origins (and thereby leak the viewer's IP address) when rendered as a document. If this matters for your threat model, host such images from a separate origin or block the requests with a Content-Security-Policy.
The bundled server sends content-security-policy: default-src 'none' with successful responses by default, which blocks both script execution and external references in browsers that honor it. Custom servers built on the programmatic API should send the same header, since sanitization cannot cover every future browser behavior on its own.
Sanitization can be disabled with svg: { unsafeSkipSanitize: true }. Only do this when every source is fully trusted: IPX will then serve SVG images with XSS payloads unchanged.
Custom URL Style
The parseURL option accepts a function that extracts the resource id and modifiers from the request URL, allowing any URL style you like. It receives the raw (still percent-encoded) request URL, so it is free to decode it however the URL style requires.
Example: modifiers in the filename (/<id>@@<modifiers>.<format>), which can be preferable when prerendering images for static hosting.
import { createIPXFetchHandler, parseIPXURL } from "ipx";
const handler = createIPXFetchHandler(ipx, {
parseURL(url) {
const path = decodeURIComponent(new URL(url).pathname.slice(1));
const match = path.match(/^(.+)@@(.+)\.([^.]+)$/);
if (!match) {
// Not our style, fall back to the default `/<modifiers>/<id>`
return parseIPXURL(url);
}
const [, id = "", modifiersString = "", format = ""] = match;
const modifiers = Object.fromEntries(
modifiersString.split(",").map((m) => {
const [key = "", ...values] = m.split("_");
return [key, values.join("_")];
}),
);
return { id, modifiers: { ...modifiers, format } };
},
});
// http://localhost:3000/static/buffalo.png@@s_200x200.webp
// http://localhost:3000/static/buffalo.png@@grayscale,w_200.webpThe parser may be async, and can throw an HTTPError (re-exported from ipx) to reject a request with a specific status code.
Returned values are escaped by IPX, so custom parsers don't need to do it themselves. Note this is not an access check — exactly as with the default URL style, what the resulting id is allowed to resolve to is enforced by the storage layer (ipxFSStorage's directory boundary, ipxHttpStorage's domain allowlist).
