feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy";
|
||||
|
||||
type HeaderSetter = Pick<Headers, "set">;
|
||||
|
||||
export const AUTH_HANDLERS: Record<
|
||||
string,
|
||||
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
|
||||
> = {
|
||||
bearer: (headers, _url, apiKey) => {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
},
|
||||
apiKeyHeader: (headers, _url, apiKey, headerName) => {
|
||||
headers.set(headerName ?? "X-API-Key", apiKey);
|
||||
},
|
||||
apiKeyQuery: (_headers, url, apiKey, headerName) => {
|
||||
url.searchParams.set(headerName ?? "api_key", apiKey);
|
||||
},
|
||||
};
|
||||
|
||||
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const secret of secrets) {
|
||||
map[secret.kind] = secret.value;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function applyAuth(
|
||||
headers: HeaderSetter,
|
||||
url: URL,
|
||||
authType: string,
|
||||
secrets: Array<{ kind: string; value: string }>,
|
||||
headerName?: string | null,
|
||||
): void {
|
||||
const secretMap = buildSecretMap(secrets);
|
||||
|
||||
if (authType === "basic" && secretMap.username && secretMap.password) {
|
||||
const credentials = Buffer.from(`${secretMap.username}:${secretMap.password}`).toString("base64");
|
||||
headers.set("Authorization", `Basic ${credentials}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = AUTH_HANDLERS[authType];
|
||||
if (handler && secretMap.apiKey) {
|
||||
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyBodyAuth(body: string | undefined, auth: CustomWidgetHttpRequest["auth"]): string | undefined {
|
||||
if (!auth?.body) return body;
|
||||
if (!body) throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Authenticated JSON body is required" });
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Authenticated JSON body is invalid" });
|
||||
}
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value))
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Authenticated JSON body must be an object" });
|
||||
const record = value as Record<string, unknown>;
|
||||
if (auth.body.type === "jsonField") {
|
||||
record[auth.body.name] = auth.body.value;
|
||||
} else {
|
||||
const current = record[auth.body.name];
|
||||
if (current !== undefined && !Array.isArray(current))
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Authenticated JSON parameter must be an array",
|
||||
});
|
||||
record[auth.body.name] = [auth.body.value, ...(current ?? [])];
|
||||
}
|
||||
return JSON.stringify(record);
|
||||
}
|
||||
|
||||
export async function performAuthenticatedRequest(
|
||||
input: CustomWidgetHttpRequest,
|
||||
signal: AbortSignal,
|
||||
perform: (input: CustomWidgetHttpRequest, signal: AbortSignal) => Promise<CustomWidgetHttpResponse>,
|
||||
): Promise<CustomWidgetHttpResponse> {
|
||||
const target = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(target, input.pathPrefix);
|
||||
if (input.resolveConnectionAsync) {
|
||||
const connection = await withinDeadline(input.resolveConnectionAsync, signal);
|
||||
resolveSameOriginTarget(connection.baseUrl, target);
|
||||
input = { ...input, ...connection, targetUrl: target };
|
||||
}
|
||||
return perform(input, signal);
|
||||
}
|
||||
|
||||
async function withinDeadline<T>(callback: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
let abort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([callback(), aborted]);
|
||||
} finally {
|
||||
if (abort) signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type CustomWidgetDomainErrorCode =
|
||||
| "BAD_GATEWAY"
|
||||
| "BAD_REQUEST"
|
||||
| "CONFLICT"
|
||||
| "FORBIDDEN"
|
||||
| "INTERNAL_SERVER_ERROR"
|
||||
| "NOT_FOUND"
|
||||
| "PAYLOAD_TOO_LARGE"
|
||||
| "TOO_MANY_REQUESTS";
|
||||
|
||||
export class CustomWidgetDomainError extends Error {
|
||||
public readonly code: CustomWidgetDomainErrorCode;
|
||||
public readonly reason?: "timeout";
|
||||
public readonly retryAfterMs?: number;
|
||||
|
||||
public constructor(input: {
|
||||
code: CustomWidgetDomainErrorCode;
|
||||
message: string;
|
||||
reason?: "timeout";
|
||||
retryAfterMs?: number;
|
||||
}) {
|
||||
super(input.message);
|
||||
this.name = "CustomWidgetDomainError";
|
||||
this.code = input.code;
|
||||
this.reason = input.reason;
|
||||
this.retryAfterMs = input.retryAfterMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export { CustomWidgetDomainError } from "./errors";
|
||||
export type { CustomWidgetDomainErrorCode } from "./errors";
|
||||
export {
|
||||
assertJsonBudget,
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest,
|
||||
invalidateCustomWidgetResponseCache,
|
||||
MAX_REQUEST_DURATION_MS,
|
||||
MAX_REQUEST_BODY_BYTES,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "./request-executor";
|
||||
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
|
||||
export {
|
||||
CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
|
||||
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
|
||||
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
|
||||
CustomWidgetRequestLimiter,
|
||||
} from "./request-limits";
|
||||
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
|
||||
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
|
||||
export type {
|
||||
CreatePreviewSessionInput,
|
||||
CustomWidgetPreviewJournalEntry,
|
||||
CustomWidgetPreviewSession,
|
||||
PreviewSessionServiceOptions,
|
||||
PreviewSessionStore,
|
||||
} from "./preview-sessions";
|
||||
export {
|
||||
hashRuntimeParams,
|
||||
renderBoundValue,
|
||||
renderRequestBody,
|
||||
renderRequestTarget,
|
||||
resolveCustomWidgetRequestValues,
|
||||
} from "./request-manifest";
|
||||
export type { CustomJsxRuntimeParams } from "./request-manifest";
|
||||
|
||||
export { redactResponseSecrets } from "./response";
|
||||
|
||||
export { assertCustomWidgetPathScope } from "./network-policy";
|
||||
@@ -0,0 +1,255 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { Agent } from "undici";
|
||||
|
||||
import type { CustomJsxNetworkScope } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import { MAX_RESPONSE_BODY_BYTES } from "./response";
|
||||
|
||||
const RESERVED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
]);
|
||||
|
||||
const blocked = new BlockList();
|
||||
for (const [address, prefix] of [
|
||||
["0.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["169.254.0.0", 16],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
] as const)
|
||||
blocked.addSubnet(address, prefix, "ipv4");
|
||||
blocked.addAddress("::", "ipv6");
|
||||
for (const [address, prefix] of [
|
||||
["64:ff9b::", 96],
|
||||
["100::", 64],
|
||||
["2001:db8::", 32],
|
||||
["fe80::", 10],
|
||||
["ff00::", 8],
|
||||
] as const) {
|
||||
blocked.addSubnet(address, prefix, "ipv6");
|
||||
}
|
||||
blocked.addAddress("fd00:ec2::254", "ipv6");
|
||||
|
||||
const privateAddresses = new BlockList();
|
||||
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
||||
const loopbackAddresses = new BlockList();
|
||||
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
loopbackAddresses.addAddress("::1", "ipv6");
|
||||
|
||||
type AddressFamily = 4 | 6;
|
||||
export type ResolvedAddress = { address: string; family: AddressFamily };
|
||||
export type HostResolver = (hostname: string) => Promise<ResolvedAddress[]>;
|
||||
export interface ResolveHostOptions {
|
||||
signal?: AbortSignal;
|
||||
resolver?: HostResolver;
|
||||
}
|
||||
type AddressClass = "public" | "private" | "loopback" | "blocked";
|
||||
const normalizeHostname = (value: string) =>
|
||||
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
|
||||
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
|
||||
|
||||
export function classifyAddress(address: string): AddressClass {
|
||||
const normalized = normalizeHostname(address).toLowerCase();
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
if (!family || normalized.includes("::ffff:") || blocked.check(normalized, familyName(family))) return "blocked";
|
||||
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
|
||||
if (privateAddresses.check(normalized, familyName(family))) return "private";
|
||||
return "public";
|
||||
}
|
||||
|
||||
export async function resolveAndValidateHost(
|
||||
hostname: string,
|
||||
scope: CustomJsxNetworkScope,
|
||||
options: ResolveHostOptions = {},
|
||||
): Promise<ResolvedAddress[]> {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized) as AddressFamily | 0;
|
||||
throwIfAborted(options.signal);
|
||||
const addresses = family
|
||||
? [{ address: normalized, family }]
|
||||
: await abortable(
|
||||
options.resolver?.(normalized) ??
|
||||
(lookup(normalized, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>),
|
||||
options.signal,
|
||||
);
|
||||
throwIfAborted(options.signal);
|
||||
if (!addresses.length)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
|
||||
for (const address of addresses) {
|
||||
const classification = classifyAddress(address.address);
|
||||
const allowed =
|
||||
classification === "public" ||
|
||||
(classification === "private" && scope !== "public") ||
|
||||
(classification === "loopback" && scope === "loopback");
|
||||
if (!allowed)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Target address is not allowed by the ${scope} network scope`,
|
||||
});
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw signal.reason ?? createAbortError();
|
||||
}
|
||||
|
||||
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return operation;
|
||||
if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError());
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(signal.reason ?? createAbortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
export function validateCustomWidgetUrl(value: string | URL): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
|
||||
if (url.username || url.password)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
|
||||
if (url.hash) throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
|
||||
return url;
|
||||
}
|
||||
|
||||
export function resolveSameOriginTarget(baseValue: string, targetValue?: string | URL): URL {
|
||||
const base = validateCustomWidgetUrl(baseValue);
|
||||
const target = validateCustomWidgetUrl(targetValue ?? base);
|
||||
if (target.origin !== base.origin)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Named requests must stay on the widget's origin",
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
|
||||
for (const name of Object.keys(headers ?? {})) {
|
||||
const value = name.trim().toLowerCase();
|
||||
if (
|
||||
RESERVED_HEADERS.has(value) ||
|
||||
value.startsWith("proxy-") ||
|
||||
value.startsWith("sec-") ||
|
||||
value.startsWith("x-forwarded-")
|
||||
) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPinnedAgent(
|
||||
addresses: ResolvedAddress[],
|
||||
timeoutMs: number,
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
|
||||
) {
|
||||
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
||||
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
|
||||
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
|
||||
const selected = candidates[0];
|
||||
if (!selected) {
|
||||
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
|
||||
error.code = "ENOTFOUND";
|
||||
callback(error, "", 0);
|
||||
} else if (options.all) callback(null, candidates);
|
||||
else callback(null, selected.address, selected.family);
|
||||
};
|
||||
return new Agent({
|
||||
connect: { ...tls, lookup: customLookup },
|
||||
connectTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
bodyTimeout: timeoutMs,
|
||||
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertCustomWidgetPathScope(url: URL, pathPrefix: string) {
|
||||
const decodedPrefix = decodePath(pathPrefix);
|
||||
let prefixEnd = decodedPrefix.length;
|
||||
while (prefixEnd > 0 && decodedPrefix.charAt(prefixEnd - 1) === "/") {
|
||||
prefixEnd -= 1;
|
||||
}
|
||||
const prefix = decodedPrefix.slice(0, prefixEnd);
|
||||
const path = decodePath(url.pathname);
|
||||
if (
|
||||
path.includes("\\") ||
|
||||
path.split("/").some((segment) => segment === "." || segment === "..") ||
|
||||
(prefix && path !== prefix && !path.startsWith(`${prefix}/`))
|
||||
) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Request must remain within the selected integration URL",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function decodePath(value: string) {
|
||||
let path = value;
|
||||
for (let pass = 0; pass < 4; pass += 1) {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(path);
|
||||
} catch {
|
||||
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Request path contains invalid encoding" });
|
||||
}
|
||||
if (decoded === path) return path;
|
||||
path = decoded;
|
||||
}
|
||||
if (/%[0-9a-f]{2}/iu.test(path))
|
||||
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Request path is excessively encoded" });
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const previewJournalEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
requestId: z.string(),
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
path: z.string(),
|
||||
status: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
simulated: z.boolean(),
|
||||
sessionRevision: z.number().int().nonnegative(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
export type CustomWidgetPreviewJournalEntry = z.infer<typeof previewJournalEntrySchema>;
|
||||
|
||||
export const isVerifiedPreviewEvidence = (entry: CustomWidgetPreviewJournalEntry) => {
|
||||
if (entry.kind === "query") return entry.status !== null && entry.status >= 200 && entry.status < 300;
|
||||
return entry.simulated || (entry.status !== null && entry.status >= 200 && entry.status < 300);
|
||||
};
|
||||
|
||||
export const getPreviewEvidenceKey = (entry: CustomWidgetPreviewJournalEntry) =>
|
||||
`${entry.sessionRevision}:${entry.kind}:${entry.requestId}`;
|
||||
|
||||
export const parsePreviewJournalEntries = (entries: readonly unknown[]) =>
|
||||
entries.flatMap((entry) => {
|
||||
const parsed = previewJournalEntrySchema.safeParse(parsePreviewStoredValue(entry));
|
||||
return parsed.success ? [parsed.data] : [];
|
||||
});
|
||||
|
||||
export const parsePreviewStoredValue = (value: unknown): unknown => {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { hasSameCustomWidgetSourceAuthentication } from "../core";
|
||||
import { customWidgetPreviewDefinitionSchema } from "../core";
|
||||
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
interface PreviewTemplateContext {
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
sources: Record<string, CustomWidgetSource>;
|
||||
requests: Record<string, CustomJsxRequest>;
|
||||
optionDefinitions: CustomWidgetOptions;
|
||||
template: string;
|
||||
}
|
||||
|
||||
export function validatePreviewTemplateRevision(current: PreviewTemplateContext, template: string) {
|
||||
const parsed = customWidgetPreviewDefinitionSchema.safeParse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: current.name,
|
||||
description: current.description,
|
||||
iconUrl: current.iconUrl,
|
||||
sources: current.sources,
|
||||
requests: current.requests,
|
||||
options: current.optionDefinitions,
|
||||
template,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
const path = issue?.path.length ? `${issue.path.join(".")}: ` : "";
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Revised preview template is invalid: ${path}${issue?.message ?? "Unknown validation error"}`,
|
||||
});
|
||||
}
|
||||
if (parsed.data.template === current.template) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Revised preview template is unchanged" });
|
||||
}
|
||||
return parsed.data.template;
|
||||
}
|
||||
|
||||
export function canConfigurePreviewSource(current: CustomWidgetSource, source: CustomWidgetSource) {
|
||||
if (current.type === "integration" && source.type === "integration")
|
||||
return current.integrationKind === source.integrationKind;
|
||||
return hasSameCustomWidgetSourceAuthentication(current, source);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { canConfigurePreviewSource } from "./preview-session-template";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
customWidgetOptionsSchema,
|
||||
customWidgetRequestsSchema,
|
||||
customWidgetSecretKinds,
|
||||
customWidgetSourcesSchema,
|
||||
} from "../core";
|
||||
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import {
|
||||
getPreviewEvidenceKey,
|
||||
isVerifiedPreviewEvidence,
|
||||
parsePreviewJournalEntries,
|
||||
parsePreviewStoredValue,
|
||||
previewJournalEntrySchema,
|
||||
} from "./preview-session-evidence";
|
||||
import type { CustomWidgetPreviewJournalEntry } from "./preview-session-evidence";
|
||||
import { validatePreviewTemplateRevision } from "./preview-session-template";
|
||||
|
||||
const SESSION_TTL_MS = 10 * 60_000;
|
||||
const MAX_JOURNAL_ENTRIES = 50;
|
||||
const MAX_UPDATE_ATTEMPTS = 8;
|
||||
|
||||
const encryptedSecretSchema = z.object({
|
||||
sourceId: z.string(),
|
||||
kind: z.enum(customWidgetSecretKinds),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const sessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
revision: z.number().int().nonnegative(),
|
||||
expiresAt: z.number(),
|
||||
sources: customWidgetSourcesSchema,
|
||||
secrets: z.array(encryptedSecretSchema),
|
||||
requests: customWidgetRequestsSchema,
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
template: z.string(),
|
||||
optionDefinitions: customWidgetOptionsSchema,
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
definitionId: z.string().optional(),
|
||||
definitionStateFingerprint: z.string().optional(),
|
||||
liveActions: z.boolean(),
|
||||
});
|
||||
export type CustomWidgetPreviewSession = z.infer<typeof sessionSchema>;
|
||||
|
||||
export type { CustomWidgetPreviewJournalEntry } from "./preview-session-evidence";
|
||||
|
||||
export interface CreatePreviewSessionInput {
|
||||
userId: string;
|
||||
sources: Record<string, CustomWidgetSource>;
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
requests: Record<string, CustomJsxRequest>;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
template: string;
|
||||
optionDefinitions: CustomWidgetOptions;
|
||||
options: Record<string, unknown>;
|
||||
definitionId?: string;
|
||||
definitionStateFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface PreviewSessionStore {
|
||||
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
|
||||
getSession(id: string): Promise<unknown>;
|
||||
deleteSession(id: string): Promise<void>;
|
||||
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
|
||||
getJournal(id: string, maxEntries: number): Promise<unknown[]>;
|
||||
saveEvidence(id: string, key: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
getEvidence(id: string): Promise<unknown[]>;
|
||||
}
|
||||
|
||||
export interface PreviewSessionServiceOptions {
|
||||
createId(): string;
|
||||
encrypt(value: string): string;
|
||||
decrypt(value: string): string;
|
||||
now?: () => number;
|
||||
store?: PreviewSessionStore;
|
||||
}
|
||||
|
||||
export class CustomWidgetPreviewSessionService {
|
||||
private readonly sessions = new Map<string, CustomWidgetPreviewSession>();
|
||||
private readonly journals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
|
||||
private readonly evidence = new Map<string, Map<string, CustomWidgetPreviewJournalEntry>>();
|
||||
private readonly now: () => number;
|
||||
|
||||
public constructor(private readonly options: PreviewSessionServiceOptions) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
public async create(input: CreatePreviewSessionInput) {
|
||||
const sourceIds = new Set(Object.keys(input.sources));
|
||||
if (input.secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A preview secret references an unknown source",
|
||||
});
|
||||
}
|
||||
const session: CustomWidgetPreviewSession = {
|
||||
id: this.options.createId(),
|
||||
userId: input.userId,
|
||||
revision: 0,
|
||||
expiresAt: this.now() + SESSION_TTL_MS,
|
||||
sources: input.sources,
|
||||
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
requests: input.requests,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
template: input.template,
|
||||
optionDefinitions: input.optionDefinitions,
|
||||
options: input.options,
|
||||
definitionId: input.definitionId,
|
||||
definitionStateFingerprint: input.definitionStateFingerprint,
|
||||
liveActions: false,
|
||||
};
|
||||
await this.save(session);
|
||||
return { id: session.id, revision: session.revision, expiresAt: session.expiresAt, liveActions: false as const };
|
||||
}
|
||||
|
||||
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
|
||||
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
|
||||
const parsed = sessionSchema.safeParse(parsePreviewStoredValue(candidate));
|
||||
if (!parsed.success || parsed.data.expiresAt <= this.now()) {
|
||||
if (this.options.store) await this.options.store.deleteSession(id);
|
||||
else {
|
||||
this.sessions.delete(id);
|
||||
this.journals.delete(id);
|
||||
this.evidence.delete(id);
|
||||
}
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
if (parsed.data.userId !== userId) {
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
public async setLiveActions(id: string, userId: string, enabled: boolean) {
|
||||
const session = await this.update(id, userId, (current) => ({ ...current, liveActions: enabled }));
|
||||
return { id, expiresAt: session.expiresAt, liveActions: enabled };
|
||||
}
|
||||
|
||||
public async reviseTemplate(id: string, userId: string, template: string, expectedRevision?: number) {
|
||||
const session = await this.update(
|
||||
id,
|
||||
userId,
|
||||
(current) => ({ ...current, template: validatePreviewTemplateRevision(current, template) }),
|
||||
expectedRevision,
|
||||
);
|
||||
return {
|
||||
id: session.id,
|
||||
revision: session.revision,
|
||||
expiresAt: session.expiresAt,
|
||||
liveActions: session.liveActions,
|
||||
evidenceReset: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
public async appendJournal(
|
||||
session: CustomWidgetPreviewSession,
|
||||
input: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
||||
): Promise<void> {
|
||||
const value = previewJournalEntrySchema.parse({ ...input, id: this.options.createId(), timestamp: this.now() });
|
||||
const ttlMs = Math.max(1, session.expiresAt - this.now());
|
||||
if (this.options.store) {
|
||||
const writes = [this.options.store.appendJournal(session.id, value, MAX_JOURNAL_ENTRIES, ttlMs)];
|
||||
if (isVerifiedPreviewEvidence(value)) {
|
||||
writes.push(this.options.store.saveEvidence(session.id, getPreviewEvidenceKey(value), value, ttlMs));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
return;
|
||||
}
|
||||
|
||||
this.journals.set(session.id, [value, ...(this.journals.get(session.id) ?? [])].slice(0, MAX_JOURNAL_ENTRIES));
|
||||
if (!isVerifiedPreviewEvidence(value)) return;
|
||||
const evidence = this.evidence.get(session.id) ?? new Map<string, CustomWidgetPreviewJournalEntry>();
|
||||
evidence.set(getPreviewEvidenceKey(value), value);
|
||||
this.evidence.set(session.id, evidence);
|
||||
}
|
||||
|
||||
public async getJournal(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
|
||||
await this.get(id, userId);
|
||||
const entries = this.options.store
|
||||
? await this.options.store.getJournal(id, MAX_JOURNAL_ENTRIES)
|
||||
: (this.journals.get(id) ?? []);
|
||||
return parsePreviewJournalEntries(entries);
|
||||
}
|
||||
|
||||
public async getEvidence(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
|
||||
await this.get(id, userId);
|
||||
const entries = this.options.store
|
||||
? await this.options.store.getEvidence(id)
|
||||
: [...(this.evidence.get(id)?.values() ?? [])];
|
||||
return parsePreviewJournalEntries(entries);
|
||||
}
|
||||
|
||||
public getSecrets(session: CustomWidgetPreviewSession, sourceId: string) {
|
||||
return session.secrets
|
||||
.filter((secret) => secret.sourceId === sourceId)
|
||||
.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
|
||||
}
|
||||
|
||||
public async setSecrets(
|
||||
id: string,
|
||||
userId: string,
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
||||
) {
|
||||
const next = await this.update(id, userId, (session) => {
|
||||
const sourceIds = new Set(Object.keys(session.sources));
|
||||
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A preview secret references an unknown source",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
...session,
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
});
|
||||
return { id, expiresAt: next.expiresAt };
|
||||
}
|
||||
|
||||
public async configureSource(
|
||||
id: string,
|
||||
userId: string,
|
||||
sourceId: string,
|
||||
source: CustomWidgetSource,
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
|
||||
) {
|
||||
const next = await this.update(id, userId, (session) => {
|
||||
const currentSource = session.sources[sourceId];
|
||||
if (!currentSource) {
|
||||
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
|
||||
}
|
||||
if (!canConfigurePreviewSource(currentSource, source)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Preview source authentication changed; create a new configuration request",
|
||||
});
|
||||
}
|
||||
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
...session,
|
||||
sources: {
|
||||
...session.sources,
|
||||
[sourceId]: source,
|
||||
},
|
||||
secrets: [
|
||||
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
|
||||
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
],
|
||||
};
|
||||
});
|
||||
return { id, expiresAt: next.expiresAt };
|
||||
}
|
||||
|
||||
private async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
mutate: (session: CustomWidgetPreviewSession) => CustomWidgetPreviewSession,
|
||||
expectedRevision?: number,
|
||||
) {
|
||||
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS; attempt += 1) {
|
||||
const current = await this.get(id, userId);
|
||||
if (expectedRevision !== undefined && current.revision !== expectedRevision) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "CONFLICT",
|
||||
message: `Preview session revision changed from ${expectedRevision} to ${current.revision}`,
|
||||
});
|
||||
}
|
||||
const next = { ...mutate(current), revision: current.revision + 1 };
|
||||
const ttlMs = Math.max(0, next.expiresAt - this.now());
|
||||
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
if (this.options.store) {
|
||||
if (await this.options.store.compareAndSwapSession(id, current.revision, next, ttlMs)) return next;
|
||||
} else if (this.sessions.get(id)?.revision === current.revision) {
|
||||
this.sessions.set(id, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
throw new CustomWidgetDomainError({ code: "CONFLICT", message: "Preview session changed too many times; retry" });
|
||||
}
|
||||
|
||||
private async save(session: CustomWidgetPreviewSession): Promise<void> {
|
||||
const ttlMs = Math.max(0, session.expiresAt - this.now());
|
||||
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
|
||||
if (this.options.store) await this.options.store.saveSession(session.id, session, ttlMs);
|
||||
else this.sessions.set(session.id, session);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { createPinnedAgent } from "./network-policy";
|
||||
|
||||
export const DISPATCHER_CLOSE_GRACE_MS = 1_000;
|
||||
|
||||
type CustomWidgetDispatcher = ReturnType<typeof createPinnedAgent>;
|
||||
|
||||
export async function closeDispatcher(
|
||||
dispatcher: CustomWidgetDispatcher,
|
||||
deadlineSignal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const cleanupController = new AbortController();
|
||||
const timeout = setTimeout(() => cleanupController.abort(), DISPATCHER_CLOSE_GRACE_MS);
|
||||
try {
|
||||
await abortable(dispatcher.close(), AbortSignal.any([deadlineSignal, cleanupController.signal]));
|
||||
return true;
|
||||
} catch (error) {
|
||||
detachDispatcherDestroy(dispatcher);
|
||||
if (cleanupController.signal.aborted && !deadlineSignal.aborted) return false;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function detachDispatcherDestroy(dispatcher: CustomWidgetDispatcher): void {
|
||||
try {
|
||||
void dispatcher.destroy().catch(() => undefined);
|
||||
} catch {
|
||||
// Cleanup is best effort after its bounded grace period.
|
||||
}
|
||||
}
|
||||
|
||||
function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
void operation.catch(() => undefined);
|
||||
return Promise.reject(signal.reason ?? createAbortError());
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(signal.reason ?? createAbortError());
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { STATUS_CODES } from "node:http";
|
||||
import { Headers, Response } from "undici";
|
||||
|
||||
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
|
||||
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
|
||||
import { applyAuth, applyBodyAuth, performAuthenticatedRequest } from "./auth";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import {
|
||||
assertSafeStaticHeaders,
|
||||
assertCustomWidgetPathScope,
|
||||
createPinnedAgent,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "./network-policy";
|
||||
import { closeDispatcher } from "./request-dispatcher-lifecycle";
|
||||
import { decodeResponseBody, parseResponseBody, redactResponseSecrets } from "./response";
|
||||
|
||||
export {
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
} from "./network-policy";
|
||||
export {
|
||||
assertJsonBudget,
|
||||
MAX_RESPONSE_BODY_BYTES,
|
||||
MAX_RESPONSE_JSON_DEPTH,
|
||||
MAX_RESPONSE_JSON_NODES,
|
||||
} from "./response";
|
||||
|
||||
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const MAX_QUERY_REDIRECTS = 3;
|
||||
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
|
||||
export const MAX_REQUEST_DURATION_MS = 45_000;
|
||||
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
||||
const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE";
|
||||
|
||||
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
|
||||
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
|
||||
let cacheEpoch = 0;
|
||||
|
||||
type RequestHopResult =
|
||||
| { kind: "response"; response: CustomWidgetHttpResponse }
|
||||
| { kind: "redirect"; location: string | null; statusCode: number };
|
||||
|
||||
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
Math.min(input.timeoutMs ?? MAX_REQUEST_DURATION_MS, MAX_REQUEST_DURATION_MS),
|
||||
);
|
||||
try {
|
||||
assertRequest(input);
|
||||
return await performAuthenticatedRequest(input, controller.signal, performRequestWithinDeadline);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "External request exceeded the total time limit",
|
||||
reason: "timeout",
|
||||
});
|
||||
}
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
input.logError?.({
|
||||
origin: URL.canParse(input.baseUrl) ? new URL(input.baseUrl).origin : "invalid",
|
||||
method: input.method,
|
||||
errorName: "TransportError",
|
||||
});
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: "External request failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function performRequestWithinDeadline(
|
||||
input: CustomWidgetHttpRequest,
|
||||
deadlineSignal: AbortSignal,
|
||||
): Promise<CustomWidgetHttpResponse> {
|
||||
assertRequest(input);
|
||||
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
|
||||
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
|
||||
let currentMethod = input.method;
|
||||
let currentBody = input.body;
|
||||
const authenticated =
|
||||
input.auth !== undefined &&
|
||||
input.auth.type !== "none" &&
|
||||
(input.auth.type !== "integration" ||
|
||||
Object.keys(input.auth.headers ?? {}).length > 0 ||
|
||||
Object.keys(input.auth.query ?? {}).length > 0 ||
|
||||
input.auth.body !== undefined);
|
||||
const maxRedirects = input.kind === "query" && !authenticated ? MAX_QUERY_REDIRECTS : 0;
|
||||
for (let redirects = 0; ; redirects += 1) {
|
||||
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(currentUrl, input.pathPrefix);
|
||||
const requestBody = applyBodyAuth(currentBody, input.auth);
|
||||
if (requestBody !== undefined && Buffer.byteLength(requestBody, "utf8") > MAX_REQUEST_BODY_BYTES)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
message: "Request body exceeds the 10 KiB limit",
|
||||
});
|
||||
const dispatcher = createPinnedAgent(
|
||||
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
input.tls,
|
||||
);
|
||||
const headers = buildHeaders(input, currentUrl, requestBody);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
let result: RequestHopResult | undefined;
|
||||
let lifecycleFailure: { error: unknown } | undefined;
|
||||
try {
|
||||
const responseData = await dispatcher.request({
|
||||
origin: currentUrl.origin,
|
||||
path: `${currentUrl.pathname}${currentUrl.search}`,
|
||||
method: currentMethod,
|
||||
headers,
|
||||
body: requestBody,
|
||||
signal: AbortSignal.any([deadlineSignal, controller.signal]),
|
||||
});
|
||||
if (![301, 302, 303, 307, 308].includes(responseData.statusCode)) {
|
||||
let body: ArrayBuffer | Buffer = await responseData.body.arrayBuffer();
|
||||
const responseHeaders = normalizeResponseHeaders(responseData.headers);
|
||||
if (currentMethod !== "HEAD" && responseHeaders.has("content-encoding")) {
|
||||
body = decodeResponseBody(body, responseHeaders.get("content-encoding"));
|
||||
responseHeaders.delete("content-encoding");
|
||||
responseHeaders.delete("content-length");
|
||||
}
|
||||
const response = new Response(body.byteLength > 0 ? body : null, {
|
||||
status: responseData.statusCode,
|
||||
statusText: STATUS_CODES[responseData.statusCode] ?? "",
|
||||
headers: responseHeaders,
|
||||
});
|
||||
let data: unknown = null;
|
||||
if (currentMethod !== "HEAD") data = await parseResponseBody(response, input.textFallback);
|
||||
const parsed = {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data,
|
||||
};
|
||||
parsed.data = redactResponseSecrets(parsed.data, input.redactSecrets ?? []);
|
||||
result = { kind: "response", response: parsed };
|
||||
} else {
|
||||
await responseData.body.dump();
|
||||
result = {
|
||||
kind: "redirect",
|
||||
location: normalizeResponseHeaders(responseData.headers).get("location"),
|
||||
statusCode: responseData.statusCode,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
lifecycleFailure = { error };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const closed = await closeDispatcher(dispatcher, deadlineSignal);
|
||||
if (!closed)
|
||||
input.logError?.({
|
||||
origin: currentUrl.origin,
|
||||
method: currentMethod,
|
||||
errorName: "DispatcherCloseTimeout",
|
||||
});
|
||||
} catch {
|
||||
input.logError?.({
|
||||
origin: currentUrl.origin,
|
||||
method: currentMethod,
|
||||
errorName: "DispatcherCloseError",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lifecycleFailure) {
|
||||
const { error } = lifecycleFailure;
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
if (error instanceof Error && "code" in error && error.code === RESPONSE_TOO_LARGE_ERROR_CODE) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
message: "Response exceeds the 1 MiB limit",
|
||||
});
|
||||
}
|
||||
const timedOut = isCustomWidgetRequestTimeoutError(error, deadlineSignal, controller.signal);
|
||||
input.logError?.({
|
||||
origin: currentUrl.origin,
|
||||
method: currentMethod,
|
||||
errorName: timedOut ? "RequestTimeout" : "TransportError",
|
||||
...(timedOut ? { reason: "timeout" as const } : {}),
|
||||
});
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_GATEWAY",
|
||||
message: timedOut ? "External request timed out" : "External request failed",
|
||||
...(timedOut ? { reason: "timeout" as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!result)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "External request did not complete",
|
||||
});
|
||||
if (result.kind === "response") return result.response;
|
||||
if (redirects >= maxRedirects)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
|
||||
if (!result.location)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
|
||||
const redirected = validateCustomWidgetUrl(new URL(result.location, currentUrl));
|
||||
if (redirected.origin !== baseUrl.origin)
|
||||
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
|
||||
if (result.statusCode === 303 || ([301, 302].includes(result.statusCode) && currentMethod === "POST")) {
|
||||
currentMethod = "GET";
|
||||
currentBody = undefined;
|
||||
}
|
||||
currentUrl = redirected;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean {
|
||||
if (signals.some((signal) => signal.aborted)) return true;
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.name.includes("Timeout")) return true;
|
||||
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
|
||||
return code !== undefined && TIMEOUT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
function normalizeResponseHeaders(values: Record<string, string | string[] | undefined>) {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry));
|
||||
else if (value !== undefined) headers.set(name, value);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function assertRequest(input: CustomWidgetHttpRequest): void {
|
||||
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES)
|
||||
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
|
||||
assertSafeStaticHeaders(input.staticHeaders);
|
||||
}
|
||||
|
||||
function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | undefined): Headers {
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
|
||||
if (body !== undefined && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
if (body === undefined) headers.delete("Content-Type");
|
||||
if (input.auth) {
|
||||
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
|
||||
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
|
||||
for (const [name, value] of Object.entries(input.auth.headers ?? {})) headers.set(name, value);
|
||||
for (const [name, value] of Object.entries(input.auth.query ?? {})) url.searchParams.set(name, value);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
|
||||
const key = input.kind === "query" ? input.cacheKey : undefined;
|
||||
if (!key) return performRequest(input);
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.response;
|
||||
if (cached) cache.delete(key);
|
||||
const pending = inFlight.get(key);
|
||||
if (pending) return pending;
|
||||
const requestEpoch = cacheEpoch;
|
||||
let request: Promise<CustomWidgetHttpResponse>;
|
||||
request = performRequest(input)
|
||||
.then((response) => {
|
||||
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0 && requestEpoch === cacheEpoch) {
|
||||
pruneCache();
|
||||
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response });
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.get(key) === request) inFlight.delete(key);
|
||||
});
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function invalidateCustomWidgetResponseCache(prefixes: readonly string[]): void {
|
||||
if (prefixes.length === 0) return;
|
||||
cacheEpoch += 1;
|
||||
for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) cache.delete(key);
|
||||
for (const key of inFlight.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) inFlight.delete(key);
|
||||
}
|
||||
|
||||
function pruneCache(): void {
|
||||
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key);
|
||||
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
|
||||
const key = cache.keys().next().value as string | undefined;
|
||||
if (!key) return;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
// A query may legally follow three redirects with a ten-second timeout per hop.
|
||||
// Keep the lease above that total and identify every acquisition so a late
|
||||
// release can never remove capacity owned by a newer request.
|
||||
export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000;
|
||||
export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4;
|
||||
export const CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT = 8;
|
||||
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3, metadata: 60 } as const;
|
||||
const DEFINITION_CATEGORY_LIMITS = { query: 240, action: 40, delete: 12, metadata: 240 } as const;
|
||||
type RequestCategory = keyof typeof CATEGORY_LIMITS;
|
||||
|
||||
export interface RequestLimitInput {
|
||||
category: RequestCategory;
|
||||
userId?: string;
|
||||
anonymousId?: string;
|
||||
itemId: string;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
export interface RequestLimitStore {
|
||||
incrementRate(key: string, windowMs: number): Promise<{ count: number; retryAfterMs: number }>;
|
||||
acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number): Promise<boolean>;
|
||||
releaseConcurrency(key: string, ownerId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RequestLimiterOptions {
|
||||
store?: RequestLimitStore;
|
||||
onStoreError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class CustomWidgetRequestLimiter {
|
||||
private readonly rateBuckets = new Map<string, number[]>();
|
||||
private readonly concurrency = new Map<string, number>();
|
||||
|
||||
public constructor(private readonly options: RequestLimiterOptions = {}) {}
|
||||
|
||||
public async acquire(input: RequestLimitInput): Promise<() => Promise<void>> {
|
||||
const identity = input.userId ? `user:${input.userId}` : `anonymous:${input.anonymousId ?? "unknown"}`;
|
||||
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
|
||||
const definitionRateKey = `custom-widget:rate:v2:definition:${input.category}:${hashKey(input.definitionId)}`;
|
||||
const userKey = `custom-widget:concurrency:v2:user-item:${hashKey(identity, input.itemId)}`;
|
||||
const definitionKey = `custom-widget:concurrency:v2:definition:${hashKey(input.definitionId)}`;
|
||||
if (!this.options.store)
|
||||
return this.acquireLocal(rateKey, definitionRateKey, userKey, definitionKey, input.category);
|
||||
try {
|
||||
return await this.acquireStored(
|
||||
rateKey,
|
||||
definitionRateKey,
|
||||
userKey,
|
||||
definitionKey,
|
||||
input.category,
|
||||
this.options.store,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
this.options.onStoreError?.(error);
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Request limiter is unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private acquireLocal(
|
||||
rateKey: string,
|
||||
definitionRateKey: string,
|
||||
userKey: string,
|
||||
definitionKey: string,
|
||||
category: RequestCategory,
|
||||
) {
|
||||
const now = Date.now();
|
||||
this.acquireLocalRate(rateKey, CATEGORY_LIMITS[category], now);
|
||||
this.acquireLocalRate(definitionRateKey, DEFINITION_CATEGORY_LIMITS[category], now);
|
||||
this.acquireLocalConcurrency(userKey, CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT);
|
||||
try {
|
||||
this.acquireLocalConcurrency(definitionKey, CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT);
|
||||
} catch (error) {
|
||||
this.releaseLocalConcurrency(userKey);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
this.releaseLocalConcurrency(userKey);
|
||||
this.releaseLocalConcurrency(definitionKey);
|
||||
};
|
||||
}
|
||||
|
||||
private async acquireStored(
|
||||
rateKey: string,
|
||||
definitionRateKey: string,
|
||||
userKey: string,
|
||||
definitionKey: string,
|
||||
category: RequestCategory,
|
||||
store: RequestLimitStore,
|
||||
) {
|
||||
const ownerId = randomUUID();
|
||||
const rate = await store.incrementRate(rateKey, WINDOW_MS);
|
||||
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
|
||||
const definitionRate = await store.incrementRate(definitionRateKey, WINDOW_MS);
|
||||
if (definitionRate.count > DEFINITION_CATEGORY_LIMITS[category]) throwLimit(definitionRate.retryAfterMs);
|
||||
if (
|
||||
!(await store.acquireConcurrency(
|
||||
userKey,
|
||||
ownerId,
|
||||
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
|
||||
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
|
||||
))
|
||||
) {
|
||||
throwLimit(1_000);
|
||||
}
|
||||
try {
|
||||
if (
|
||||
!(await store.acquireConcurrency(
|
||||
definitionKey,
|
||||
ownerId,
|
||||
CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
|
||||
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
|
||||
))
|
||||
) {
|
||||
throwLimit(1_000);
|
||||
}
|
||||
} catch (error) {
|
||||
await store.releaseConcurrency(userKey, ownerId);
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
await Promise.all([store.releaseConcurrency(userKey, ownerId), store.releaseConcurrency(definitionKey, ownerId)]);
|
||||
};
|
||||
}
|
||||
|
||||
private acquireLocalRate(key: string, limit: number, now: number): void {
|
||||
const recent = (this.rateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
|
||||
if (recent.length >= limit) throwLimit(WINDOW_MS - (now - (recent[0] ?? now)));
|
||||
recent.push(now);
|
||||
this.rateBuckets.set(key, recent);
|
||||
}
|
||||
|
||||
private acquireLocalConcurrency(key: string, limit: number): void {
|
||||
const count = this.concurrency.get(key) ?? 0;
|
||||
if (count >= limit) throwLimit(1_000);
|
||||
this.concurrency.set(key, count + 1);
|
||||
}
|
||||
|
||||
private releaseLocalConcurrency(key: string): void {
|
||||
const count = this.concurrency.get(key) ?? 0;
|
||||
if (count <= 1) this.concurrency.delete(key);
|
||||
else this.concurrency.set(key, count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
|
||||
const throwLimit = (retryAfterMs: number): never => {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Custom widget request limit exceeded",
|
||||
retryAfterMs: Math.max(1, retryAfterMs),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { CustomJsxRequest } from "../core";
|
||||
import { collectCustomWidgetRequestReferences } from "../core";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
import { resolveSameOriginTarget } from "./request-executor";
|
||||
|
||||
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
|
||||
type CustomJsxResolvedValues = Record<string, unknown>;
|
||||
|
||||
export function resolveCustomWidgetRequestValues(
|
||||
request: CustomJsxRequest,
|
||||
options: Record<string, unknown>,
|
||||
suppliedParams: CustomJsxRuntimeParams = {},
|
||||
): CustomJsxResolvedValues {
|
||||
const references = collectCustomWidgetRequestReferences(request);
|
||||
const suppliedNames = Object.keys(suppliedParams).toSorted();
|
||||
const expectedNames = [...references.params].toSorted();
|
||||
if (
|
||||
suppliedNames.length !== expectedNames.length ||
|
||||
suppliedNames.some((name, index) => name !== expectedNames[index])
|
||||
) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Request parameters do not match the request references",
|
||||
});
|
||||
}
|
||||
const values: CustomJsxResolvedValues = { ...suppliedParams };
|
||||
for (const name of references.options) {
|
||||
const value = options[name];
|
||||
if (value === undefined) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Widget option '${name}' is missing`,
|
||||
});
|
||||
}
|
||||
values[`option:${name}`] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export const renderBoundValue = (template: unknown, values: CustomJsxResolvedValues): unknown => {
|
||||
const substitute = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(substitute);
|
||||
if (value !== null && typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
if (
|
||||
entries.length === 1 &&
|
||||
["$param", "$option"].includes(entries[0]?.[0] ?? "") &&
|
||||
typeof entries[0]?.[1] === "string"
|
||||
) {
|
||||
const prefix = entries[0][0] === "$option" ? "option:" : "";
|
||||
const resolved = values[`${prefix}${entries[0][1]}`];
|
||||
if (resolved === undefined)
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown request value '${entries[0][1]}'`,
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return substitute(template);
|
||||
};
|
||||
|
||||
export const renderRequestBody = (request: CustomJsxRequest, values: CustomJsxResolvedValues): string | undefined =>
|
||||
request.body === undefined ? undefined : JSON.stringify(renderBoundValue(request.body, values));
|
||||
|
||||
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, values: CustomJsxResolvedValues) => {
|
||||
const path = request.path.replaceAll(
|
||||
/\{(option|param):([A-Za-z][A-Za-z0-9_-]*)\}/gu,
|
||||
(_match, kind: string, name: string) => {
|
||||
const value = values[kind === "option" ? `option:${name}` : name];
|
||||
if (value === undefined)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path value '${name}'` });
|
||||
if (!["string", "number", "boolean"].includes(typeof value))
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Path value '${name}' must resolve to text, number, or boolean`,
|
||||
});
|
||||
return encodeURIComponent(String(value));
|
||||
},
|
||||
);
|
||||
if (/[{}]/u.test(path))
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request path contains an invalid placeholder" });
|
||||
const base = new URL(baseUrl);
|
||||
base.pathname = `${base.pathname.replace(/\/$/u, "")}${path}` || "/";
|
||||
const target = resolveSameOriginTarget(baseUrl, base);
|
||||
for (const [name, template] of Object.entries(request.query ?? {})) {
|
||||
const value = renderBoundValue(template, values);
|
||||
if (!["string", "number", "boolean"].includes(typeof value)) {
|
||||
throw new CustomWidgetDomainError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Query value '${name}' must resolve to text, number, or boolean`,
|
||||
});
|
||||
}
|
||||
target.searchParams.set(name, String(value));
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
export function hashRuntimeParams(params: CustomJsxResolvedValues) {
|
||||
const stable = Object.fromEntries(Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)));
|
||||
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
|
||||
import type { IntegrationHttpBodyAuth } from "@homarr/definitions";
|
||||
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
|
||||
|
||||
export interface CustomWidgetAuthConfig {
|
||||
type: string;
|
||||
secrets: Array<{ kind: string; value: string }>;
|
||||
headerName?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
body?: IntegrationHttpBodyAuth;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
resolveConnectionAsync?(): Promise<
|
||||
Pick<CustomWidgetHttpRequest, "baseUrl" | "auth" | "tls" | "pathPrefix" | "redactSecrets">
|
||||
>;
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
|
||||
pathPrefix?: string;
|
||||
redactSecrets?: CustomWidgetAuthConfig["secrets"];
|
||||
baseUrl: string;
|
||||
targetUrl?: string | URL;
|
||||
method: CustomWidgetMethod | "HEAD" | "OPTIONS";
|
||||
body?: string;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth?: CustomWidgetAuthConfig;
|
||||
networkScope: CustomJsxNetworkScope;
|
||||
kind: "query" | "action";
|
||||
timeoutMs?: number;
|
||||
textFallback?: boolean;
|
||||
cacheKey?: string;
|
||||
cacheTtlSeconds?: number;
|
||||
logError?: (event: {
|
||||
origin: string;
|
||||
method: CustomWidgetMethod | "HEAD" | "OPTIONS";
|
||||
errorName: string;
|
||||
reason?: "timeout";
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
data: unknown;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
||||
import type { Response } from "undici";
|
||||
import { CustomWidgetDomainError } from "./errors";
|
||||
|
||||
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
||||
export const MAX_RESPONSE_JSON_DEPTH = 32;
|
||||
export const MAX_RESPONSE_JSON_NODES = 50_000;
|
||||
|
||||
export function assertJsonBudget(value: unknown): void {
|
||||
let nodes = 0;
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
if (++nodes > MAX_RESPONSE_JSON_NODES)
|
||||
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
|
||||
if (depth > MAX_RESPONSE_JSON_DEPTH)
|
||||
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
|
||||
if (Array.isArray(current)) current.forEach((entry) => visit(entry, depth + 1));
|
||||
else if (current !== null && typeof current === "object")
|
||||
Object.values(current).forEach((entry) => visit(entry, depth + 1));
|
||||
};
|
||||
visit(value, 0);
|
||||
}
|
||||
|
||||
async function readLimitedBody(response: Response): Promise<string> {
|
||||
const length = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(length) && length > MAX_RESPONSE_BODY_BYTES) throw tooLarge();
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
size += chunk.value.byteLength;
|
||||
if (size > MAX_RESPONSE_BODY_BYTES) {
|
||||
await reader.cancel();
|
||||
throw tooLarge();
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
const bytes = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
/** Bound every decoding stage as well as the on-wire response body. */
|
||||
export function decodeResponseBody(body: ArrayBuffer, contentEncoding: string | null): ArrayBuffer | Buffer {
|
||||
if (!contentEncoding || body.byteLength === 0) return body;
|
||||
const encodings = contentEncoding
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.map((value) => value.trim());
|
||||
if (encodings.length > 3)
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Too many upstream content encodings" });
|
||||
let decoded: ArrayBuffer | Buffer = body;
|
||||
for (const encoding of encodings.toReversed()) {
|
||||
try {
|
||||
const options = { maxOutputLength: MAX_RESPONSE_BODY_BYTES };
|
||||
switch (encoding) {
|
||||
case "identity":
|
||||
break;
|
||||
case "gzip":
|
||||
decoded = gunzipSync(decoded, options);
|
||||
break;
|
||||
case "deflate":
|
||||
decoded = inflateSync(decoded, options);
|
||||
break;
|
||||
case "br":
|
||||
decoded = brotliDecompressSync(decoded, options);
|
||||
break;
|
||||
default:
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Unsupported upstream content encoding" });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
if (error instanceof Error && "code" in error && error.code === "ERR_BUFFER_TOO_LARGE") throw tooLarge();
|
||||
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Invalid compressed upstream response" });
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
|
||||
const text = await readLimitedBody(response);
|
||||
if (!text) return null;
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
try {
|
||||
if (contentType.includes("ndjson")) {
|
||||
const json = text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as unknown);
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
}
|
||||
const json = JSON.parse(text) as unknown;
|
||||
assertJsonBudget(json);
|
||||
return json;
|
||||
} catch (error) {
|
||||
if (error instanceof CustomWidgetDomainError) throw error;
|
||||
if (!textFallback && contentType.includes("json")) {
|
||||
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
const tooLarge = () =>
|
||||
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
|
||||
|
||||
export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown {
|
||||
if (secrets.length === 0) return data;
|
||||
const values = secrets.filter(({ value }) => value.length > 0);
|
||||
const username = secrets.find(({ kind }) => kind === "username")?.value;
|
||||
const password = secrets.find(({ kind }) => kind === "password")?.value;
|
||||
if (username && password) values.push({ kind: "basic", value: `${username}:${password}` });
|
||||
const sensitive = new Set<string>();
|
||||
const embedded = new Set<string>();
|
||||
for (const { kind, value } of values) {
|
||||
for (const encoded of [
|
||||
value,
|
||||
JSON.stringify(value).slice(1, -1),
|
||||
encodeURIComponent(value),
|
||||
Buffer.from(value).toString("base64"),
|
||||
Buffer.from(value).toString("base64").replace(/=+$/u, ""),
|
||||
Buffer.from(value).toString("base64url"),
|
||||
]) {
|
||||
sensitive.add(encoded);
|
||||
// Usernames and short credentials can be ordinary words or characters in response data.
|
||||
if (kind === "authenticationQuery" || (kind !== "username" && value.length >= 12)) embedded.add(encoded);
|
||||
}
|
||||
}
|
||||
const embeddedValues = [...embedded].toSorted((a, b) => b.length - a.length);
|
||||
const redactEmbedded = (value: string) => {
|
||||
for (const secret of embeddedValues) value = value.replaceAll(secret, "[REDACTED]");
|
||||
return value;
|
||||
};
|
||||
const visit = (value: unknown): unknown => {
|
||||
if (typeof value === "string") {
|
||||
if (sensitive.has(value)) return "[REDACTED]";
|
||||
return redactEmbedded(value);
|
||||
}
|
||||
if ((typeof value === "number" || typeof value === "boolean" || value === null) && sensitive.has(String(value)))
|
||||
return "[REDACTED]";
|
||||
if (Array.isArray(value)) return value.map(visit);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redactEmbedded(key), visit(child)]));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
return visit(data);
|
||||
}
|
||||
Reference in new issue
Block a user