All files / lib/partners ip.ts

100% Statements 29/29
100% Branches 22/22
100% Functions 7/7
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96                                                        15x 15x       5x 15x                       20x 3x       8x 1x   17x 17x 57x 1x 14x                       27x 24x       24x 768x                 11x 11x   9x 9x       9x    
/**
 * Salted IP digests for farm detection (FR-F-6).
 *
 * WHAT IS STORED, AND WHY IT IS NOT AN IP. The requirement is "more than two
 * referrals from one partner sharing a common inbound IP raises a flag" — a
 * SAMENESS test. Sameness needs a fingerprint, not an address, so the address
 * is never written: two salted SHA-256 digests are, and neither is reversible
 * without the secret.
 *
 * TWO digests, because one cannot answer the question. A full-IP hash tells
 * you two referrals came from the identical address; it can say nothing about
 * a /24, because hashing destroys the structure the comparison needs. The
 * second digest is of the /24 itself, computed before hashing — so "same
 * building, different desk" is answerable without ever holding the range.
 *
 * NO SECRET, NO HASH. `hashIp` returns null rather than an unsalted digest.
 * The IPv4 space is 2^32 — small enough that a plain SHA-256 of an address is
 * a lookup table, not a pseudonym. Failing to record a fraud signal is
 * recoverable; writing a reversible one into a table we keep is not. Same
 * discipline as `apps/go/src/pages/api/submit.ts`.
 *
 * WHOSE IP. The PARTNER's, taken when they create a referral. Nothing here
 * records anything about a homeowner, who has not contacted us at the point
 * this runs and has agreed to nothing.
 */
 
/** Cloudflare sets this on every request that reaches the Worker. */
export function clientIp(headers: Headers): string | null {
	const cf = headers.get("cf-connecting-ip")?.trim();
	if (cf) return cf;
	// Behind Cloudflare this is Cloudflare's own list, so the FIRST entry is
	// the client. Only a fallback — if `cf-connecting-ip` is missing, something
	// is routing traffic in a way this file's assumptions do not cover.
	const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
	return forwarded || null;
}
 
/**
 * The /24 (IPv4) or /64 (IPv6) an address sits in, as text.
 *
 * Computed BEFORE hashing, because a digest has no structure left to group by.
 * /64 for IPv6 because that is the smallest block routinely assigned to one
 * subscriber — anything narrower groups nothing, since a single device
 * commonly rotates through addresses inside it.
 */
export function subnetOf(ip: string): string | null {
	if (ip.includes(":")) {
		const groups = ip.split(":");
		// Only an already-expanded address can be truncated safely. A `::`
		// elision means the leading groups are not where they appear to be, and
		// guessing produces a prefix that groups unrelated addresses together.
		if (groups.length !== 8 || groups.some((g) => g === "")) return null;
		return `${groups.slice(0, 4).join(":")}::/64`;
	}
	const octets = ip.split(".");
	if (octets.length !== 4) return null;
	if (!octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255))
		return null;
	return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
}
 
/**
 * SHA-256(value + secret) as lowercase hex, or null with no secret.
 *
 * The secret makes this a keyed digest rather than a lookup table.
 */
export async function hashIp(
	value: string,
	secret: string | undefined,
): Promise<string | null> {
	if (!secret) return null;
	const digest = await crypto.subtle.digest(
		"SHA-256",
		new TextEncoder().encode(value + secret),
	);
	return Array.from(new Uint8Array(digest), (byte) =>
		byte.toString(16).padStart(2, "0"),
	).join("");
}
 
/** Both digests for one request. Either may be null; neither ever throws. */
export async function ipFingerprint(
	headers: Headers,
	secret: string | undefined,
): Promise<{ ipHash: string | null; ipPrefixHash: string | null }> {
	const ip = clientIp(headers);
	if (!ip) return { ipHash: null, ipPrefixHash: null };
 
	const subnet = subnetOf(ip);
	const [ipHash, ipPrefixHash] = await Promise.all([
		hashIp(ip, secret),
		subnet ? hashIp(subnet, secret) : Promise.resolve(null),
	]);
	return { ipHash, ipPrefixHash };
}