Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,285 @@
|
||||
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<number> {
|
||||
try {
|
||||
const response = await fetchWebhookWithRedirects(resolveWebhookEndpoint(endpoint, request), init);
|
||||
return response.status;
|
||||
} catch {
|
||||
return BLOCKED_WEBHOOK_STATUS;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: Parameters<typeof fetch>[0], init: RequestInit): Promise<Response> {
|
||||
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<Response> {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user