198 lines
5.5 KiB
TypeScript
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,
|
|
};
|