import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server"; import { getBaseURL } from "~/utils/url.server"; import ApiHelper from "~/utils/helpers/ApiHelper"; const LEGACY_WEBHOOK_FETCH_TIMEOUT_MS = 10_000; const BLOCKED_WEBHOOK_STATUS = 0; const LEGACY_WEBHOOK_REDIRECT_LIMIT = 5; const INTERNAL_WEBHOOK_HOST_SUFFIXES = [".localhost", ".local", ".internal", ".lan", ".home", ".corp", ".intranet", ".svc"] as const; type ApiRowLogEntity = { id: string; [key: string]: unknown; }; type ApiRowLogItem = { id: string; [key: string]: unknown; } | null; export async function createApiRowLog( request: Request, data: { tenantId: string | null; createdByUserId?: string | null; createdByApiKey?: string | null; action: string; entity: ApiRowLogEntity; item: ApiRowLogItem; commentId?: string | null; details?: string; }, ) { const log = await createLegacyRowLog({ tenantId: data.tenantId, userId: data.createdByUserId ?? null, apiKeyId: data.createdByApiKey ?? null, url: new URL(request.url).pathname, rowId: data.item?.id ?? null, action: data.action, details: data.details ?? null, commentId: data.commentId ?? null, }); const apiFormat = ApiHelper.getApiFormat(data.entity as never, data.item as never); await callApiEntityWebhooks({ logId: log.id, entityId: data.entity.id, action: data.action, body: apiFormat, request, }); return log; } async function callApiEntityWebhooks({ logId, entityId, action, body, request, }: { logId: string; entityId: string; action: string; body: string; request: Request; }) { const webhooks = await getLegacyEntityWebhooksByAction(entityId, action); return Promise.all( webhooks.map(async (webhook) => { if (!webhook.endpoint) { return undefined; } const status = await fetchLegacyWebhookStatus(webhook.endpoint, request, { method: webhook.method, body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }); return createLegacyEntityWebhookLog({ webhookId: webhook.id, logId, status, }); }), ); } function resolveWebhookEndpoint(endpoint: string, request: Request): string { const resolvedEndpoint = endpoint.startsWith("/") ? getBaseURL(request) + endpoint : endpoint; return validateOutboundWebhookUrl(resolvedEndpoint); } async function fetchLegacyWebhookStatus(endpoint: string, request: Request, init: RequestInit): Promise { try { const response = await fetchWebhookWithRedirects(resolveWebhookEndpoint(endpoint, request), init); return response.status; } catch { return BLOCKED_WEBHOOK_STATUS; } } async function fetchWithTimeout(input: Parameters[0], init: RequestInit): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), LEGACY_WEBHOOK_FETCH_TIMEOUT_MS); try { return await fetch(input, { ...init, redirect: "manual", signal: controller.signal }); } finally { clearTimeout(timeout); } } async function fetchWebhookWithRedirects(input: string, init: RequestInit): Promise { let currentUrl = input; let currentInit = init; for (let redirects = 0; redirects <= LEGACY_WEBHOOK_REDIRECT_LIMIT; redirects += 1) { const response = await fetchWithTimeout(currentUrl, currentInit); if (!isRedirectResponse(response.status)) { return response; } const location = response.headers.get("location"); if (!location) { return response; } if (redirects === LEGACY_WEBHOOK_REDIRECT_LIMIT) { throw new Error("Webhook redirect limit exceeded."); } currentUrl = resolveOutboundWebhookRedirect(currentUrl, location); currentInit = buildRedirectRequestInit(currentInit, response.status); } throw new Error("Webhook redirect limit exceeded."); } function resolveOutboundWebhookRedirect(currentUrl: string, location: string): string { let redirectUrl: URL; try { redirectUrl = new URL(location, currentUrl); } catch { throw new Error("Webhook redirect location must be a valid URL."); } return validateOutboundWebhookUrl(redirectUrl.toString()); } function isRedirectResponse(status: number): boolean { return status >= 300 && status < 400; } function buildRedirectRequestInit(init: RequestInit, status: number): RequestInit { const method = typeof init.method === "string" ? init.method.toUpperCase() : undefined; if (status === 303 || ((status === 301 || status === 302) && method === "POST")) { const headers = new Headers(init.headers); headers.delete("content-type"); headers.delete("content-length"); return { ...init, method: "GET", body: undefined, headers }; } return init; } function validateOutboundWebhookUrl(endpoint: string): string { let url: URL; try { url = new URL(endpoint); } catch { throw new Error("Webhook endpoint must be an absolute URL."); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("Webhook endpoint must use http or https."); } if (isBlockedWebhookHostname(url.hostname)) { throw new Error("Webhook endpoint host is not allowed."); } return url.toString(); } function isBlockedWebhookHostname(hostname: string): boolean { const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); if (!normalizedHostname) { return true; } const ipv4 = parseIpv4Address(normalizedHostname); if (ipv4) { return isBlockedIpv4Address(ipv4); } const ipv6 = parseIpv6Address(normalizedHostname); if (ipv6) { return isBlockedIpv6Address(ipv6); } return ( normalizedHostname === "localhost" || !normalizedHostname.includes(".") || INTERNAL_WEBHOOK_HOST_SUFFIXES.some((suffix) => normalizedHostname.endsWith(suffix)) ); } function parseIpv4Address(hostname: string): [number, number, number, number] | undefined { const parts = hostname.split("."); if (parts.length !== 4) { return undefined; } const octets = parts.map((part) => Number(part)); if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { return undefined; } return octets as [number, number, number, number]; } function isBlockedIpv4Address([first, second]: [number, number, number, number]): boolean { return ( first === 0 || first === 10 || first === 127 || first >= 224 || (first === 100 && second >= 64 && second <= 127) || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 198 && (second === 18 || second === 19)) ); } function parseIpv6Address(hostname: string): number[] | undefined { if (!hostname.includes(":")) { return undefined; } const [head = "", tail = "", extra] = hostname.split("::"); if (extra !== undefined) { return undefined; } const headParts = head ? head.split(":") : []; const tailParts = tail ? tail.split(":") : []; const missingPartCount = 8 - headParts.length - tailParts.length; if (missingPartCount < 0 || (!hostname.includes("::") && missingPartCount !== 0)) { return undefined; } const parts = [...headParts, ...Array(missingPartCount).fill("0"), ...tailParts]; if (parts.length !== 8) { return undefined; } const hextets = parts.map((part) => Number.parseInt(part, 16)); if (hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) { return undefined; } return hextets; } function isBlockedIpv6Address(hextets: number[]): boolean { const first = hextets[0] ?? 0; const isUnspecified = hextets.every((part) => part === 0); const isLoopback = hextets.slice(0, 7).every((part) => part === 0) && hextets[7] === 1; const isUniqueLocal = (first & 0xfe00) === 0xfc00; const isLinkLocal = (first & 0xffc0) === 0xfe80; const isIpv4Mapped = hextets.slice(0, 5).every((part) => part === 0) && hextets[5] === 0xffff; const isIpv4Compatible = hextets.slice(0, 6).every((part) => part === 0); if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal) { return true; } if (isIpv4Mapped || isIpv4Compatible) { const ipv4: [number, number, number, number] = [hextets[6] >> 8, hextets[6] & 0xff, hextets[7] >> 8, hextets[7] & 0xff]; return isBlockedIpv4Address(ipv4); } return false; }