pixel-serve-server
Version:
A robust Node.js utility for handling and processing images. This package provides features like resizing, format conversion and etc.
455 lines (449 loc) • 23.3 kB
text/typescript
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as http from 'node:http';
import * as https from 'node:https';
type ImageType = "avatar" | "normal";
type ImageFormat = "jpeg" | "jpg" | "png" | "webp" | "gif" | "tiff" | "avif";
/**
* The known failure phases reported to `onError`. Listed for documentation;
* the actual `phase` field is typed as `string` so future call sites can
* introduce new identifiers without a breaking change.
*
* - `"sharp"` — Sharp decode / resize / encode pipeline failed.
* - `"fetch"` — Network fetch (axios) failed or rejected by SSRF guards.
* - `"fs"` — Local filesystem read or path validation failed.
* - `"idHandler"` — User-supplied `idHandler` threw, returned a non-string,
* or exceeded the `idHandlerTimeoutMs` budget.
* - `"getUserFolder"` — User-supplied `getUserFolder` threw or exceeded
* `requestTimeoutMs`.
* - `"schema"` — Zod parsing of `PixelServeOptions` failed.
* - `"validation"`— Per-request user data validation failed (e.g., bad query).
*/
type PixelServeErrorPhase = "sharp" | "fetch" | "fs" | "idHandler" | "getUserFolder" | "schema" | "validation" | string;
/**
* Context passed to the `onError` observability hook. `phase` always reflects
* the operation that failed; `src` and `userId` are populated when they have
* been parsed by the time the failure occurred. Additional fields may be
* appended in the future and consumers should treat the shape as open.
*/
type PixelServeErrorContext = {
phase: PixelServeErrorPhase;
src?: string;
userId?: string;
};
/**
* Observability hook fired at every catch site in the request pipeline. The
* callback runs synchronously (any returned promise is ignored) and must
* not throw — throws are swallowed so a buggy logger cannot crash a request.
* Use this hook to emit structured logs, increment metric counters, or
* surface unexpected failures to your APM / error tracker.
*/
type PixelServeOnError = (err: unknown, context: PixelServeErrorContext) => void;
/**
* Context passed to the `onComplete` observability hook on the happy path
* (200 response after a successful Sharp pipeline) and on the 304 short-
* circuit (when `If-None-Match` matched a deterministic ETag and Sharp was
* skipped entirely).
*
* - `src` / `userId` carry the validated request inputs.
* - `format` is the output format actually used by the response.
* - `outputBytes` is the size of the response body in bytes (0 for 304s).
* - `cached` is `true` when the response was served as 304 Not Modified.
* - `durationMs` measures end-to-end pipeline latency from the start of
* `serveImage` to the moment `res.send` (or `res.end`) was invoked,
* captured via `process.hrtime.bigint()` for monotonic precision.
*
* Additional fields may be appended in the future; consumers should treat
* the shape as open.
*/
type PixelServeCompletionContext = {
src?: string;
userId?: string;
format: ImageFormat;
outputBytes: number;
cached: boolean;
durationMs: number;
};
/**
* Observability hook fired after the response has been flushed on the
* happy path (200 + image bytes) and on the 304 cached short-circuit. The
* callback runs synchronously (any returned promise is ignored) and must
* not throw — throws are swallowed so a buggy logger cannot crash a request.
* Use this hook to emit structured logs, ship per-request latency metrics
* to your APM, or count cache-hit ratios.
*/
type PixelServeOnComplete = (context: PixelServeCompletionContext) => void;
type PixelServeOptions = {
baseDir: string;
/**
* Transforms an incoming `userId` before it is handed to `getUserFolder`.
* May be sync or async. Throws and non-string returns are caught by the
* framework and fall back to the raw `userId`. Async handlers are awaited
* under `idHandlerTimeoutMs` (defaults to `requestTimeoutMs`).
*/
idHandler?: (id: string) => string | Promise<string>;
getUserFolder?: (req: Request, id?: string) => Promise<string> | string;
/**
* Optional containment root for `getUserFolder` results. When set, the
* framework validates that the path returned by `getUserFolder` resolves
* (via `fs.realpath` + `path.relative`) to a descendant of
* `getUserFolderRootDir`. If the returned path escapes the root (e.g.,
* `path.join(PRIVATE_DIR, "../etc")` or a symlink that points outside),
* the framework treats the call as a `getUserFolder` failure: the
* `onError` hook fires with `phase: "getUserFolder"` and the request
* falls back to the public `baseDir`.
*
* When unset, no containment check runs and the caller is fully
* responsible for sanitizing the `userId` input inside their own
* `getUserFolder` implementation.
*/
getUserFolderRootDir?: string;
websiteURL?: string;
/**
* Regex stripped from an internal URL pathname before resolving the local
* file. Only applied when `websiteURL` matches the incoming `src` host.
*
* **ReDoS warning.** This regex is executed against arbitrary client-
* controlled `url.pathname` values via `String.prototype.replace`. A
* vulnerable pattern (e.g., `/^(a+)+\/$/`, nested quantifiers, ambiguous
* alternations) opens the deployment to catastrophic-backtracking denial of
* service. Prefer `apiPrefix` (a plain string `startsWith` check) when you
* only need to strip a literal prefix. If you must supply a custom regex,
* audit it with a tool like
* [safe-regex](https://www.npmjs.com/package/safe-regex) and keep it anchored
* (`^…`) so partial matches cannot drift across pathological inputs.
*
* Defaults to `/^\/api\/v1\//` — a fixed, anchored, literal pattern that is
* not vulnerable to ReDoS.
*/
apiRegex?: RegExp;
/**
* Optional literal-string prefix stripped from internal URL pathnames before
* resolving the local file. When set, `apiPrefix` takes precedence over
* `apiRegex` — the pathname is checked with `startsWith(apiPrefix)` and
* sliced when it matches, with no regex evaluation at all. This is the
* recommended option whenever you only need to strip a literal path prefix
* (no captures, no alternations) because it sidesteps the ReDoS risk
* inherent to a user-supplied `apiRegex`.
*
* Example: `apiPrefix: "/api/v1/"` is equivalent in behavior to the default
* `apiRegex: /^\/api\/v1\//` but cannot be made vulnerable by mistake.
*
* Defaults to `undefined`, in which case `apiRegex` is used.
*/
apiPrefix?: string;
allowedNetworkList?: string[];
cacheControl?: string;
etag?: boolean;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
defaultQuality?: number;
requestTimeoutMs?: number;
/**
* Timeout (ms) applied when awaiting an async `idHandler`. Defaults to
* `requestTimeoutMs` when unset.
*/
idHandlerTimeoutMs?: number;
maxDownloadBytes?: number;
/**
* Maximum number of HTTP redirects to follow when fetching a remote image.
* Each hop is re-validated against `allowedNetworkList`, the http/https
* protocol guard, and the public-IP DNS check (SSRF protection).
* Defaults to 3.
*/
maxRedirects?: number;
/**
* Maximum number of input pixels accepted by Sharp before failing the
* request. Protects against decompression bombs (small encoded buffer that
* decompresses to billions of pixels). Defaults to 16000 * 16000.
*/
maxInputPixels?: number;
/**
* Whether to allow SVG inputs through to Sharp/libvips. SVG decoding has
* historically been a vector for XML-bomb / billion-laughs style attacks.
* Defaults to `false`; SVG inputs are detected by magic bytes and rejected
* with the fallback image.
*/
allowSvgInput?: boolean;
/**
* Optional observability hook invoked whenever the pipeline catches an
* error. The framework continues to serve a fallback image as before; the
* hook is purely for logging / metrics / APM integration.
*
* The hook is invoked at every catch site (Sharp pipeline, network fetch,
* filesystem read, `idHandler` failure, `getUserFolder` failure, schema
* validation failure). It is best-effort: throws from the hook are
* suppressed and never escape the middleware.
*/
onError?: PixelServeOnError;
/**
* Optional observability hook invoked after the response has been flushed
* on the happy path (200 response with image bytes) and after the 304
* cached short-circuit. Use this hook to ship per-request latency metrics,
* count cache-hit ratios, or feed structured logs into your APM.
*
* The hook is best-effort: throws from the hook are suppressed and never
* escape the middleware. The hook runs synchronously; any returned promise
* is ignored.
*/
onComplete?: PixelServeOnComplete;
};
type UserData = {
src: string;
quality?: number | string;
format?: ImageFormat;
folder?: "public" | "private";
type?: ImageType;
userId?: string;
width?: number | string;
height?: number | string;
};
declare const buildFilename: (rawSrc: string | undefined, outputFormat: string) => {
asciiFilename: string;
encodedFilename: string;
};
/**
* Returns a stable source identifier for the deterministic ETag.
*
* - Local files contribute `mtimeMs:size`, so any edit to the underlying
* file invalidates the cache key.
* - Remote URLs contribute the resolved URL string. The framework cannot
* cheaply re-fetch HEAD per request, so the URL is the strongest
* identifier available without paying for the body.
* - Anything else (missing file, fallback paths) returns `null` so the
* caller falls back to the post-Sharp buffer hash.
*/
declare const buildSourceIdentifier: (src: string | undefined, baseDir: string) => Promise<string | null>;
/**
* Builds the deterministic SHA-256 ETag from the resolved user data + source
* identifier. The result is wrapped in double-quotes per RFC 7232.
*
* SHA-256 is used over SHA-1 because the input contains user-controlled fields
* (post-`idHandler` userId, src). SHA-1's collision weakness flagged by CodeQL
* `js/weak-cryptographic-algorithm` does not affect ETag correctness in
* practice, but a modern hash keeps static analysis green and removes any
* theoretical concern about a third party forging a matching ETag.
*/
declare const buildDeterministicEtag: (fields: {
src: string | undefined;
width: number | undefined;
height: number | undefined;
format: string;
quality: number;
type: ImageType;
folder: "public" | "private";
parsedUserId: string | undefined;
}, sourceIdentifier: string) => string;
/**
* Verifies that `candidate` is contained within `rootDir`. Used to enforce
* `getUserFolderRootDir` containment so a buggy or malicious `getUserFolder`
* implementation cannot expand the framework's filesystem surface area
* beyond an opt-in root.
*
* Both the **root** and the **candidate** are normalized via `fs.realpath`
* before the containment check. This catches symlink escapes (a path that
* lexically lives inside the root but whose final segment is a symlink
* pointing outward) at the containment layer rather than waiting for the
* downstream `isValidPath` read. When `fs.realpath` fails — typically
* because the candidate is a lazy per-user directory that doesn't exist
* yet — the function falls back to the lexical `path.resolve` value so
* containment can still be evaluated; the descendant `isValidPath()`
* check then realpaths the actual file before reading.
*
* The optional `preResolvedRoot` parameter lets the middleware factory
* cache the resolved root path once at startup and skip the per-request
* `fs.realpath` syscall on the root side. When supplied, the function
* treats it as the already-resolved value.
*
* Returns `true` when the candidate is inside the root (or equal to it).
* Returns `false` for empty inputs and any escape detected lexically or
* via realpath.
*/
declare const isInsideRoot: (rootDir: string, candidate: string, preResolvedRoot?: string) => Promise<boolean>;
/**
* Resolves a configured `getUserFolderRootDir` to its canonical realpath
* once at middleware-factory time. Returns the lexically-resolved path
* when the directory does not yet exist or `fs.realpath` fails so a lazy
* containment root can still be evaluated against future requests.
*
* Exported so consumers can pre-resolve their own roots for unit tests.
*/
declare const resolveRootDir: (rootDir: string) => Promise<string>;
/**
* Detects whether a buffer is an SVG by inspecting its leading bytes for
* common SVG / XML markers. Tolerates UTF-8 BOM, UTF-16 BE/LE BOMs, leading
* ASCII whitespace (incl. whitespace BEFORE a BOM), `<?xml` prologs, and
* `<!--` comments preceding the `<svg` root element. Reads up to 4 KiB so
* pathologically large XML prologs cannot push `<svg` out of the inspection
* window.
*
* The detector is intentionally conservative — any buffer that looks even
* vaguely SVG-shaped is rejected when `allowSvgInput` is false. This guards
* against billion-laughs / nested-use SVG bombs that libvips/librsvg parses.
*/
declare const looksLikeSvg: (buf: Buffer) => boolean;
/**
* @function registerServe
* @description A function to register the serveImage function as middleware for Express.
* @param {PixelServeOptions} options - The options object for image processing.
* @returns {function(Request, Response, NextFunction): Promise<void>} The middleware function.
*
* The factory eagerly validates `options` via `optionsSchema.parse` exactly
* **once** at registration time (Task 4) so the request hot path does not
* re-run the Zod schema, refine() callbacks, regex matches, or the
* `allowedNetworkList` trim/lowercase transform on every request. Operator
* misconfiguration is surfaced synchronously: the eagerly-captured
* `options.onError` hook (if any) receives `{ phase: "schema" }` and the
* factory re-throws so the failure is loud at startup rather than silent
* fallback noise per-request.
*
* The factory also eagerly resolves `options.getUserFolderRootDir` via
* `fs.realpath` once and caches the result. Every subsequent request reuses
* the cached realpath inside `isInsideRoot`, so the per-request containment
* check costs zero extra filesystem syscalls on the root side. When the
* configured root does not yet exist on disk, the factory falls back to a
* lexical `path.resolve` so the containment check still works for lazily-
* created trees.
*/
declare const registerServe: (options: PixelServeOptions) => ((req: Request, res: Response, next: NextFunction) => Promise<void>);
declare const userDataSchema: z.ZodObject<{
src: z.ZodOptional<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>;
format: z.ZodOptional<z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<"jpeg" | "jpg" | "png" | "webp" | "gif" | "tiff" | "avif" | undefined, string | undefined>>>;
width: z.ZodPipe<z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>, z.ZodTransform<number | undefined, string | number | undefined>>, z.ZodOptional<z.ZodNumber>>;
height: z.ZodPipe<z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>, z.ZodTransform<number | undefined, string | number | undefined>>, z.ZodOptional<z.ZodNumber>>;
quality: z.ZodPipe<z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>, z.ZodTransform<number | undefined, string | number | undefined>>, z.ZodDefault<z.ZodNumber>>;
folder: z.ZodDefault<z.ZodEnum<{
public: "public";
private: "private";
}>>;
type: z.ZodDefault<z.ZodEnum<{
avatar: "avatar";
normal: "normal";
}>>;
userId: z.ZodPipe<z.ZodPipe<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>, z.ZodTransform<string | undefined, string | number | undefined>>, z.ZodOptional<z.ZodString>>;
}, z.core.$strict>;
declare const optionsSchema: z.ZodObject<{
baseDir: z.ZodString;
idHandler: z.ZodOptional<z.ZodCustom<(id: string) => string | Promise<string>, (id: string) => string | Promise<string>>>;
getUserFolder: z.ZodOptional<z.ZodCustom<(req: unknown, id?: string) => Promise<string> | string, (req: unknown, id?: string) => Promise<string> | string>>;
getUserFolderRootDir: z.ZodOptional<z.ZodString>;
websiteURL: z.ZodOptional<z.ZodUnion<readonly [z.ZodURL, z.ZodString]>>;
apiRegex: z.ZodDefault<z.ZodCustom<RegExp, RegExp>>;
apiPrefix: z.ZodOptional<z.ZodString>;
allowedNetworkList: z.ZodDefault<z.ZodPipe<z.ZodArray<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodString>>, z.ZodTransform<string[], string[]>>>;
cacheControl: z.ZodOptional<z.ZodString>;
etag: z.ZodDefault<z.ZodBoolean>;
minWidth: z.ZodDefault<z.ZodNumber>;
maxWidth: z.ZodDefault<z.ZodNumber>;
minHeight: z.ZodDefault<z.ZodNumber>;
maxHeight: z.ZodDefault<z.ZodNumber>;
defaultQuality: z.ZodDefault<z.ZodNumber>;
requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
idHandlerTimeoutMs: z.ZodOptional<z.ZodNumber>;
maxDownloadBytes: z.ZodDefault<z.ZodNumber>;
maxRedirects: z.ZodDefault<z.ZodNumber>;
maxInputPixels: z.ZodDefault<z.ZodNumber>;
allowSvgInput: z.ZodDefault<z.ZodBoolean>;
onError: z.ZodOptional<z.ZodCustom<PixelServeOnError, PixelServeOnError>>;
onComplete: z.ZodOptional<z.ZodCustom<PixelServeOnComplete, PixelServeOnComplete>>;
}, z.core.$strict>;
/**
* Checks if a specified path is valid within a base path.
*
* Performs shape validation first (no null bytes, no control characters
* including `DEL`/`\x7F`, no backslashes on any platform, no absolute paths,
* length cap), then resolves both `basePath` and the joined path via
* `fs.realpath`, then asserts containment via `path.relative` plus a
* prefix check.
*
* Cross-platform notes:
*
* - Backslashes are rejected on **all** platforms (not just Windows). On
* POSIX, literal `\\` is a valid filename byte; on Windows it is a
* directory separator. Allowing the divergence silently is a security
* smell, so this guard rejects backslash universally to keep behavior
* consistent.
* - UNC paths (`\\server\share\...`) are caught by both the backslash
* check and `path.isAbsolute` on Windows.
* - `\x7F` (DEL) is part of the control-character regex so request paths
* containing it are rejected (the `Content-Disposition` sanitizer in
* `pixel.ts` also strips `\x7F`; keeping the two consistent here matters).
*
* TOCTOU caveat: this function calls `fs.realpath` to validate containment,
* but `pixel.ts` later re-resolves the path and calls `fs.readFile`
* independently. Between those two calls the filesystem could change (for
* example, a symlink target could be swapped). For an image-serving pipeline
* the resulting worst case is a fallback image being returned; for higher
* security workloads, callers should mount images on a read-only filesystem
* or run the process with `fs.open` + atime-checked file handles. This is
* accepted risk for the default deployment model.
*
* @param {string} basePath - The base directory to resolve paths.
* @param {string} specifiedPath - The path to check.
* @returns {Promise<boolean>} True if the path is valid, false otherwise.
*/
declare const isValidPath: (basePath: string, specifiedPath: string) => Promise<boolean>;
/**
* Determines if an IP address (v4 or v6) is private, loopback, link-local,
* unique-local, multicast, broadcast, or otherwise unsafe to issue requests to.
*
* @param {string} ip - The IP address to check.
* @returns {boolean} True if the IP is considered private/internal.
*/
declare const isPrivateIp: (ip: string) => boolean;
/**
* Resolves a hostname via DNS and verifies every returned address is a public
* (non-private/loopback/link-local) IP. If the hostname is already an IP
* literal, validates that directly without a DNS lookup.
*
* @param {string} hostname - The hostname to validate.
* @returns {Promise<boolean>} True if the hostname only resolves to public IPs.
*/
declare const isPublicHost: (hostname: string) => Promise<boolean>;
/**
* Resolves a hostname once, validates every returned address is public, and
* returns a `{ address, family }` pair that can be pinned to an
* `http.Agent`/`https.Agent`'s `lookup` function. The same address is then
* guaranteed to be the one the TCP socket connects to, closing the DNS-
* rebinding window between the validation lookup and axios' subsequent
* resolve. For IP literals the input is returned verbatim (still subject to
* `isPrivateIp`) and no DNS lookup is performed.
*
* Returns `null` when the host is empty, the host resolves to no addresses,
* the host resolves to (or is) a private/loopback/link-local IP, or DNS
* resolution fails. Callers fall back to the regular failure path on `null`.
*/
declare const resolvePinnedAddress: (hostname: string) => Promise<{
address: string;
family: 4 | 6;
} | null>;
/**
* Builds `httpAgent` and `httpsAgent` instances whose internal `lookup`
* function is pinned to a single `{ address, family }` pair. Passed to axios
* via the per-request config so the kernel resolver is never consulted again
* after our `isPublicHost` validation. Mitigates the classic DNS-rebinding
* exploit where an attacker-controlled authoritative server answers the
* validation lookup with a public IP and the subsequent connect-time lookup
* with `127.0.0.1`/`169.254.169.254`/etc.
*
* Each call returns a new pair of agents (one per request); the agents are
* not reused across requests so the pinning lifetime matches the redirect
* loop hop that validated the IP. Agents are not explicitly `destroy()`-ed
* because Node garbage-collects unused agents once their sockets close.
*/
declare const buildPinnedAgents: (address: string, family: 4 | 6) => {
httpAgent: http.Agent;
httpsAgent: https.Agent;
};
/**
* Strips a leading prefix from `pathname` using either a literal-string
* `apiPrefix` (preferred, ReDoS-free) or a user-supplied `apiRegex`. When
* both are provided, `apiPrefix` wins — the regex is not evaluated at all,
* so a vulnerable pattern in `apiRegex` cannot reach this code path.
*
* Exported for unit-testability of the precedence + prefix matching logic.
*/
declare const stripApiPrefix: (pathname: string, apiRegex: RegExp, apiPrefix: string | undefined) => string;
export { type ImageFormat, type ImageType, type PixelServeCompletionContext, type PixelServeErrorContext, type PixelServeErrorPhase, type PixelServeOnComplete, type PixelServeOnError, type PixelServeOptions, type UserData, buildDeterministicEtag, buildFilename, buildPinnedAgents, buildSourceIdentifier, isInsideRoot, isPrivateIp, isPublicHost, isValidPath, looksLikeSvg, optionsSchema, registerServe, resolvePinnedAddress, resolveRootDir, stripApiPrefix, userDataSchema };