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 };
}
|