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

231 lines
6.2 KiB
TypeScript

import { getClientIPAddress } from "~/utils/server/IpUtils";
import { IpAddressDto } from "../dtos/IpAddressDto";
import { cachified, clearCacheKey } from "~/utils/cache.server";
import { db } from "~/utils/db.server";
import { addBlacklistAttempt, addToBlacklist, findInBlacklist } from "~/utils/db/blacklist.db.server";
import { createScopedLogger } from "~/utils/logger.server";
import type { IpAddress } from "@toolctrl/db/prisma-browser";
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 = getClientIPAddress(request)?.toString() || LOCAL_REQUEST_IP;
// AD-22: nur der Pseudonym-Hash wird persistiert, nie die Klartext-IP.
const storedIp = hashIp(ip);
const ipAddress = await getOrCreateIpAddressLookup(ip);
if (!ipAddress) {
return;
}
const validationError = getIpAddressValidationError(ipAddress);
// Blacklist-Abgleich auf der Roh-IP — reine Laufzeit-Security-Liste, wird nicht in den Logs persistiert.
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 db.ipAddressLog.create({
data: {
ipAddressId: ipAddress.id,
ip: storedIp,
url: new URL(request.url).pathname,
action: action,
description,
metadata: null,
error,
success: false,
},
});
throw Error(error);
} else {
await db.ipAddressLog.create({
data: {
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: Schlüssel und gespeicherter Wert ist der Pseudonym-Hash; die Land-Auflösung passiert
// lokal (DB-IP Lite) über die Roh-IP, die unsere Infra nie verlässt.
const ipHash = hashIp(ip);
const cacheKey = `ipAddress:${ipHash}`;
const item = await cachified({
key: cacheKey,
disabled: isLocalRequestIp(ip),
getFreshValue: () =>
db.ipAddress.findUnique({
where: { ip: ipHash },
}),
});
if (item) {
return toIpAddressDto(item);
}
const geo = await lookupCountry(ip);
const dto: IpAddressDto = {
id: "",
ip: ipHash,
provider: geo ? "local-db" : "",
type: "",
countryCode: geo?.countryCode ?? "",
countryName: geo?.countryName ?? "",
regionCode: "",
regionName: "",
city: "",
zipCode: "",
latitude: null,
longitude: null,
metadata: null,
};
const createdIp = await db.ipAddress
.upsert({
where: { ip: ipHash },
update: {},
create: {
ip: ipHash,
provider: dto.provider,
type: dto.type,
countryCode: dto.countryCode,
countryName: dto.countryName,
regionCode: dto.regionCode,
regionName: dto.regionName,
city: dto.city,
zipCode: dto.zipCode,
latitude: dto.latitude,
longitude: dto.longitude,
metadata: JSON.stringify(dto.metadata, null, 2),
},
})
.then((created) => {
clearCacheKey(cacheKey);
return created;
});
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-Flags-Form des früheren ipapi.is-Providers. Bleibt als Vertrag für `getIpAddressValidationError`
* bestehen (greift nur, wenn ein Datensatz noch ipapi.is-Metadaten trägt); neue Lookups setzen keine
* Metadaten mehr (AD-22, lokale Land-Auflösung).
*/
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,
};