Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/ipAddress/services/IpAddressService.server.ts
T

198 lines
5.5 KiB
TypeScript

import { getTrustedClientIpOrNull } from "~/utils/server/IpUtils";
import { IpAddressDto } from "../dtos/IpAddressDto";
import { cachified, clearCacheKey } from "~/utils/cache.server";
import { addBlacklistAttempt, addToBlacklist, findInBlacklist } from "~/utils/db/blacklist.db.server";
import { createScopedLogger } from "~/utils/logger.server";
import type { IpAddress } from "@hivegrid/db/prisma-browser";
import { appendIpAddressLogCapability, findIpAddressCapability, upsertIpAddressCapability } from "@hivegrid/db/pretenant-security.server";
import { hashIp, lookupCountry } from "./geoip.server";
const ipAddressLogger = createScopedLogger("ipAddress");
const LOCAL_REQUEST_IP = "127.0.0.1";
type IpAddressLogErrorCode = "blacklisted" | "unauthorized";
async function log(
request: Request,
{
action,
description,
metadata,
block,
}: {
action: string;
description: string;
metadata?: { [key: string]: any };
block?: IpAddressLogErrorCode;
}
) {
const ip = getTrustedClientIpOrNull(request) || LOCAL_REQUEST_IP;
// AD-22: Persist only the pseudonymous hash, never the plain-text IP address.
const storedIp = hashIp(ip);
const ipAddress = await getOrCreateIpAddressLookup(ip);
if (!ipAddress) {
return;
}
const validationError = getIpAddressValidationError(ipAddress);
// Match the runtime security list against the raw IP without persisting it in logs.
let blacklistedIp = await findInBlacklist("ip", ip);
if (!blacklistedIp && block) {
blacklistedIp = await addToBlacklist({ type: "ip", value: ip });
}
const error = blacklistedIp ? "blacklisted" : (validationError ?? block ?? null);
if (error) {
if (blacklistedIp) {
await addBlacklistAttempt(blacklistedIp);
}
await appendIpAddressLogCapability({
ipAddressId: ipAddress.id,
ip: storedIp,
url: new URL(request.url).pathname,
action,
description,
metadata: null,
error,
success: false,
});
throw Error(error);
} else {
await appendIpAddressLogCapability({
ipAddressId: ipAddress.id || null,
ip: storedIp,
url: new URL(request.url).pathname,
action,
description,
metadata: metadata ? JSON.stringify(metadata, null, 2) : null,
error: null,
success: true,
});
}
}
async function getOrCreateIpAddressLookup(ip: string): Promise<IpAddressDto | null> {
// AD-22: Use the pseudonymous hash as key and resolve the country locally with DB-IP Lite.
const ipHash = hashIp(ip);
const cacheKey = `ipAddress:${ipHash}`;
const item = await cachified({
key: cacheKey,
disabled: isLocalRequestIp(ip),
getFreshValue: () => findIpAddressCapability(ipHash),
});
if (item) {
return toIpAddressDto(item);
}
const geo = await lookupCountry(ip);
const createdIp = await upsertIpAddressCapability({
ip: ipHash,
provider: geo ? "local-db" : "",
type: "",
countryCode: geo?.countryCode ?? "",
countryName: geo?.countryName ?? "",
regionCode: "",
regionName: "",
city: "",
zipCode: "",
latitude: null,
longitude: null,
metadata: JSON.stringify(null),
});
clearCacheKey(cacheKey);
return toIpAddressDto(createdIp);
}
function isLocalRequestIp(ip: string): boolean {
return ip === LOCAL_REQUEST_IP || ip === "::1";
}
function toIpAddressDto(item: IpAddress): IpAddressDto | null {
try {
return {
id: item.id,
ip: item.ip,
provider: item.provider,
type: item.type,
countryCode: item.countryCode,
countryName: item.countryName,
regionCode: item.regionCode,
regionName: item.regionName,
city: item.city,
zipCode: item.zipCode,
latitude: item.latitude ? Number(item.latitude) : null,
longitude: item.longitude ? Number(item.longitude) : null,
metadata: JSON.parse(item.metadata),
};
} catch (error: any) {
ipAddressLogger.error({ err: error }, "[getOrCreateIpAddressLookup] Error parsing metadata");
return null;
}
}
function getIpAddressValidationError(ipAddress: IpAddressDto): IpAddressLogErrorCode | null {
if (!ipAddress.metadata) {
return null;
}
if (ipAddress.provider === "ipapi.is") {
let data: IpApiIsDto = ipAddress.metadata as IpApiIsDto;
ipAddressLogger.debug(
{
is_bogon: data.is_bogon,
is_mobile: data.is_mobile,
is_crawler: data.is_crawler,
is_datacenter: data.is_datacenter,
is_tor: data.is_tor,
is_proxy: data.is_proxy,
is_vpn: data.is_vpn,
is_abuser: data.is_abuser,
},
"[validateIp]"
);
if (data.is_crawler) {
return "unauthorized";
}
if (data.is_abuser) {
return "unauthorized";
}
if (data.location) {
const blockCountryCodes: string[] = [];
if (blockCountryCodes.includes(data.location.country_code)) {
return "unauthorized";
}
}
}
return null;
}
/**
* Threat-flag shape from the former ipapi.is provider. It remains the contract for legacy records;
* new local country lookups no longer persist provider metadata (AD-22).
*/
type IpApiIsDto = {
ip: string;
rir: string;
is_bogon: boolean;
is_mobile: boolean;
is_crawler: boolean;
is_datacenter: boolean;
is_tor: boolean;
is_proxy: boolean;
is_vpn: boolean;
is_abuser: boolean;
location?: {
country: string;
country_code: string;
city: string;
latitude: number;
longitude: number;
zip: string;
};
};
export default {
log,
getOrCreateIpAddressLookup,
};