pixel-serve-server
Version:
A robust Node.js utility for handling and processing images. This package provides features like resizing, format conversion and etc.
575 lines (569 loc) • 31 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), on either 304 short-
* circuit (an `If-None-Match` match against the pre-Sharp deterministic
* ETag, skipping Sharp entirely, OR against the post-Sharp buffer-hash
* ETag used when no deterministic source identifier is available), and on
* the hard-fallback path (a 200 response serving the bundled placeholder
* verbatim after the outer pipeline catch).
*
* - `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.
* - `fallback` is `true` when the served bytes are a bundled placeholder
* image rather than a genuinely resolved-and-encoded source — this
* covers both a "soft" fallback (e.g. a missing file or blocked host,
* still re-encoded through Sharp on a 200) and a "hard" fallback (the
* outer pipeline catch serving the bundled asset verbatim on a 200).
* `false` for a genuinely resolved-and-encoded image. 304 short-circuits
* always report `false` — no bytes are sent, so there is nothing to
* characterize as fallback-or-not for that response.
*
* 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;
fallback: boolean;
};
/**
* Observability hook fired after the response has been flushed on the
* happy path (200 + image bytes), on the 304 cached short-circuit, and on
* the hard-fallback path (a 200 serving the bundled placeholder verbatim
* after the outer pipeline catch) — every response that resolves to a 200
* or a 304 fires this hook exactly once. 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; use the `fallback` field on its context to
* distinguish a genuinely-served image from a bundled placeholder.
*/
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;
/**
* Hosts allowed as remote image sources. Each entry is a hostname
* (`images.unsplash.com`) matched exactly, OR a wildcard of the form
* `*.example.com` which matches the apex (`example.com`) AND any subdomain
* (`cdn.example.com`, `a.b.example.com`). Use a wildcard for services that
* redirect to a CDN subdomain (e.g. `picsum.photos` → `fastly.picsum.photos`).
*
* A wildcard must have at least two labels after `*.` (so `*.com` is
* rejected at `registerServe()`), and public-suffix families (`*.co.uk`)
* are not special-cased. The wildcard relaxes only the hostname allowlist;
* every redirect hop is still re-validated against the DNS public-IP guard,
* so a wildcard can never reach a private/loopback/link-local address.
* Defaults to `[]` (no remote fetching).
*/
allowedNetworkList?: string[];
cacheControl?: string;
etag?: boolean;
/**
* Operator dimension bounds. Requested `width`/`height` are first validated
* against the framework's hard `[1, 4000]` window (out-of-window requests
* return a fallback image) and then clamped to these bounds. `minWidth`/
* `minHeight` may be set anywhere from 1 upward to serve small images
* (avatars, icons, favicons); `maxWidth`/`maxHeight` must not exceed 4000.
* Defaults: min 50, max 4000.
*/
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), after the 304 cached
* short-circuit, and after the hard-fallback path (a 200 serving the
* bundled placeholder verbatim following the outer pipeline catch). Use
* this hook to ship per-request latency metrics, count cache-hit ratios,
* or feed structured logs into your APM; the context's `fallback` field
* distinguishes a genuinely-served image from a bundled placeholder.
*
* 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. The local-file branch is gated behind
* `isValidPath` so a traversal / out-of-tree `src` is rejected BEFORE
* `fs.stat` ever runs — without this gate, a traversal `src` that happens
* to reference an existing file outside `baseDir` would still `fs.stat`
* successfully, turning the ETag into an oracle for that file's
* mtime/size and decoupling it from the fallback bytes `readLocalImage`
* actually serves for the same rejected path.
* - An `http(s)` src whose host matches the configured `options.websiteURL`
* (the "internal host" case) is resolved to its on-disk path via
* `resolveInternalLocalPath` — the same helper `fetchImage` uses to
* decide whether to read locally instead of over the network — and falls
* through to the SAME local-file branch below, so its ETag tracks the
* underlying file's `mtime:size` rather than the immutable URL string.
* Without this, overwriting the on-disk file behind an internal-host URL
* never changes its ETag, so a client can be served a stale `304`
* forever.
* - Any other `http(s)` URL contributes 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.
* - A local file (direct path OR resolved from an internal-host URL) whose
* size exceeds the optional `options.maxBytes` returns `null` instead of
* a `file:` identifier: `readLocalImage` refuses to serve a file that
* large and returns the bundled fallback buffer instead, so keying the
* ETag on the oversized file's stat would decouple the ETag from the
* bytes actually served. Mirrors the size guard in `readLocalImage`.
* - Anything else (missing file, out-of-tree/traversal path, fallback
* paths) returns `null` so the caller falls back to the post-Sharp
* buffer hash — which always matches the bytes actually sent.
*/
declare const buildSourceIdentifier: (src: string | undefined, baseDir: string, options?: {
websiteURL?: string;
apiRegex?: RegExp;
apiPrefix?: string;
maxBytes?: number;
}) => 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. A `<!DOCTYPE …>` prolog is recognized the same way — and when the
* prolog names `svg` as the DOCTYPE's root element (`<!doctype svg`), that
* alone is treated as conclusive, even if an oversized internal subset (a
* billion-laughs entity-bomb DTD) pushes the `<svg` root past the 4 KiB
* window. This DOCTYPE-root signal fires whether the buffer opens directly
* with `<!doctype` OR the DOCTYPE sits behind an `<?xml …?>` declaration or
* an XML comment (see `DOCTYPE_SVG_ROOT`), so the entity-bomb defense is not
* limited to the bare-`<!doctype`-first shape.
*
* 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.ZodOptional<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>;
/** A validated, connectable address pinned into an agent's `lookup`. */
type PinnedAddress = {
address: string;
family: 4 | 6;
};
/**
* Resolves a hostname once, validates that EVERY returned address is public,
* and returns the full list of `{ address, family }` pairs. The list is then
* pinned into an `http.Agent`/`https.Agent`'s `lookup` function so the TCP
* socket can only connect to an address we validated — closing the DNS-
* rebinding window between the validation lookup and axios' subsequent
* resolve. Returning ALL validated addresses (rather than just the first)
* lets Node's Happy-Eyeballs (`autoSelectFamily`, on by default since Node
* 20) fail over across families/addresses, so a host whose first-returned
* address is an unreachable IPv6 no longer times out the whole fetch. 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, resolves to no addresses, resolves
* to (or is) a private/loopback/link-local IP (conservatively: if ANY
* resolved address is private the whole host is rejected), or DNS resolution
* fails. Callers fall back to the regular failure path on `null`.
*/
declare const resolvePinnedAddresses: (hostname: string) => Promise<PinnedAddress[] | null>;
/**
* Backward-compatible single-address resolver: returns the FIRST validated
* `{ address, family }` pair (or `null`). Retained because it is part of the
* package's exported surface; prefer `resolvePinnedAddresses` internally so
* Happy-Eyeballs can fail over across every validated address.
*/
declare const resolvePinnedAddress: (hostname: string) => Promise<PinnedAddress | null>;
/**
* Builds `httpAgent` and `httpsAgent` instances whose internal `lookup`
* function is pinned to a pre-validated address list. Passed to axios via the
* per-request config so the kernel resolver is never consulted again after
* our public-IP 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 IPs. Agents are not explicitly `destroy()`-ed
* because Node garbage-collects unused agents once their sockets close.
*
* The agents deliberately do NOT set `autoSelectFamily: false` or pin a single
* `family`: leaving Node's default Happy-Eyeballs enabled lets the socket
* connect fail over across the validated addresses (e.g. try IPv4 when the
* first-returned IPv6 is unreachable) instead of hanging until timeout. This
* does not weaken the SSRF boundary — the socket can still only connect to an
* address the pinned `lookup` returns, and every one of those was validated
* public by `resolvePinnedAddresses`.
*
* Accepts either the legacy `(address, family)` pair (backward-compatible with
* external callers) or a pre-validated `PinnedAddress[]` list.
*/
declare function buildPinnedAgents(address: string, family: 4 | 6): {
httpAgent: http.Agent;
httpsAgent: https.Agent;
};
declare function buildPinnedAgents(addresses: PinnedAddress[]): {
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;
/**
* Resolves an `http(s)` URL `src` to an API-prefix-stripped local pathname
* when its host matches the configured `websiteURL` (the "internal host"
* case — the app's own image endpoint referencing itself by absolute URL).
* Returns `null` when `src` does not parse as a URL, `websiteURL` is not
* configured (`normalizeWebsiteHost` returns `null`), or the URL's host
* matches neither the bare nor `www.`-prefixed configured hostname/host.
*
* Single source of truth for "is this src actually a local file reachable
* through our own internal host" — shared by `fetchImage` (decides whether
* to read locally or fetch over the network) and `buildSourceIdentifier`
* (`pixel.ts`; keys the deterministic ETag on the underlying file's
* `mtime:size` rather than the immutable URL string) so the two internal-
* host detection rules cannot drift apart.
*/
declare const resolveInternalLocalPath: (src: string, websiteURL: string | undefined, apiRegex: RegExp, apiPrefix: string | undefined) => string | null;
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, resolveInternalLocalPath, resolvePinnedAddress, resolvePinnedAddresses, resolveRootDir, stripApiPrefix, userDataSchema };