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 { // 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, };