231 lines
6.2 KiB
TypeScript
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,
|
|
};
|