fix: resolve TODO.md security/perf/test-coverage backlog

Fixes the 13-item codebase health scan backlog: wraps meal-plan
generation in a transaction, adds missing userId/GIN indexes, fixes
an IPv6-parsing gap in the webhook SSRF guard (and an identical
duplicated bug in the AI URL-import path, now consolidated onto one
implementation), paginates the collections list, dedupes the AI
recipe Zod schemas, wires up Stripe tier sync, rate-limits AI key
rotation, gets `pnpm typecheck` actually working, and adds test
coverage for the previously-untested admin/webhooks routes.

Two flagged issues (collection removeRecipeId IDOR, tier-limit race)
turned out to already be fixed/non-issues on inspection — noted in
TODO.md rather than silently dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-02 12:12:42 +02:00
parent 2154512e54
commit d2faf98ac1
38 changed files with 7598 additions and 315 deletions
+17 -34
View File
@@ -4,6 +4,7 @@ import { TierLimitError } from "../tiers";
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
insert: vi.fn(),
execute: vi.fn(),
}));
vi.mock("@epicure/db", () => ({
@@ -22,7 +23,7 @@ vi.mock("@epicure/db", () => ({
}));
// Import after mock
const { checkTierLimit, incrementUsage } = await import("../tiers");
const { checkAndIncrementTierLimit, incrementUsage } = await import("../tiers");
function makeChain(finalValue: unknown) {
const chain = {
@@ -54,7 +55,7 @@ describe("TierLimitError", () => {
});
});
describe("checkTierLimit", () => {
describe("checkAndIncrementTierLimit", () => {
const tierDef = {
tier: "free",
maxRecipes: 10,
@@ -63,50 +64,32 @@ describe("checkTierLimit", () => {
maxPublicRecipes: 3,
};
it("does not throw when usage is under limit", async () => {
mockDb.select
.mockReturnValueOnce(makeChain([tierDef]))
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }]));
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("throws TierLimitError when aiCall limit reached", async () => {
mockDb.select
.mockReturnValueOnce(makeChain([tierDef]))
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }]));
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
});
it("throws TierLimitError when recipe limit reached", async () => {
mockDb.select
.mockReturnValueOnce(makeChain([tierDef]))
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }]));
it("throws TierLimitError for recipe key when limit reached", async () => {
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
});
it("does not throw when no usage row exists (treats as zero)", async () => {
mockDb.select
.mockReturnValueOnce(makeChain([tierDef]))
.mockReturnValueOnce(makeChain([]));
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
});
it("does not throw when tier definition does not exist", async () => {
mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("does not throw for storage key (no limit enforced)", async () => {
mockDb.select
.mockReturnValueOnce(makeChain([tierDef]))
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }]));
await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined();
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
expect(mockDb.execute).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { isPrivateAddress } from "../validate-webhook-url";
describe("isPrivateAddress", () => {
it("flags IPv4 private/reserved ranges", () => {
expect(isPrivateAddress("127.0.0.1")).toBe(true);
expect(isPrivateAddress("10.1.2.3")).toBe(true);
expect(isPrivateAddress("172.16.0.1")).toBe(true);
expect(isPrivateAddress("192.168.1.1")).toBe(true);
expect(isPrivateAddress("169.254.1.1")).toBe(true);
expect(isPrivateAddress("224.0.0.1")).toBe(true);
});
it("allows public IPv4 addresses", () => {
expect(isPrivateAddress("8.8.8.8")).toBe(false);
expect(isPrivateAddress("1.1.1.1")).toBe(false);
});
it("fails closed on malformed IPv4 octets", () => {
expect(isPrivateAddress("999.999.999.999")).toBe(true);
});
it("flags IPv6 loopback and unspecified addresses in any compression form", () => {
expect(isPrivateAddress("::1")).toBe(true);
expect(isPrivateAddress("::")).toBe(true);
expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
});
it("flags IPv6 unique-local (fc00::/7) and link-local (fe80::/10) ranges", () => {
expect(isPrivateAddress("fc00::1")).toBe(true);
expect(isPrivateAddress("fd12:3456::1")).toBe(true);
expect(isPrivateAddress("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")).toBe(true);
expect(isPrivateAddress("fe80::1")).toBe(true);
expect(isPrivateAddress("fe00::1")).toBe(false);
});
it("recurses into IPv4-mapped IPv6 addresses, including non-compressed forms", () => {
expect(isPrivateAddress("::ffff:127.0.0.1")).toBe(true);
expect(isPrivateAddress("::ffff:10.0.0.5")).toBe(true);
expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false);
});
it("fails closed on a malformed IPv4-mapped address (out-of-range octets)", () => {
expect(isPrivateAddress("::ffff:999.999.999.999")).toBe(true);
});
it("allows public IPv6 addresses", () => {
expect(isPrivateAddress("2001:4860:4860::8888")).toBe(false);
});
it("fails closed on unparseable input", () => {
expect(isPrivateAddress("not-an-ip")).toBe(true);
});
});
+2 -2
View File
@@ -22,7 +22,7 @@ vi.mock("@epicure/db", () => ({
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const body = (init?.body as string) ?? "";
fetchCalls.push({
url: url as string,
@@ -38,7 +38,7 @@ beforeEach(() => {
fetchCalls = [];
vi.clearAllMocks();
mockInsert.mockReturnValue({ values: mockInsertValues });
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const body = (init?.body as string) ?? "";
fetchCalls.push({
url: url as string,
@@ -92,7 +92,7 @@ describe("withUserKey", () => {
vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready
// withUserKey uses findFirst via userAiKeys
const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey });
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await withUserKey("user1", { provider: "openai" });
expect(config.apiKey).toBe("sk-user-key");
@@ -100,7 +100,7 @@ describe("withUserKey", () => {
it("returns config unchanged when no user key for provider", async () => {
const mockFindFirst = vi.fn().mockResolvedValue(null);
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await withUserKey("user1", { provider: "anthropic" });
expect(config.apiKey).toBeUndefined();
@@ -118,7 +118,7 @@ describe("getModelConfigForUseCase", () => {
mealPlanModel: null,
});
const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await getModelConfigForUseCase("user1", "text");
expect(config.provider).toBe("anthropic");
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const AdaptedRecipeSchema = z.object({
title: z.string(),
@@ -9,25 +10,9 @@ const AdaptedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.number().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema,
ingredients: z.array(ingredientSchema(z.number())),
steps: z.array(stepSchema),
adaptationNotes: z.string(),
});
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const RecipeOutputSchema = z.object({
title: z.string(),
@@ -9,25 +10,9 @@ const RecipeOutputSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.number().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema,
ingredients: z.array(ingredientSchema(z.number())),
steps: z.array(stepSchema),
});
export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({
title: z.string(),
@@ -9,25 +10,9 @@ const ImportedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema.optional(),
ingredients: z.array(ingredientSchema(z.string())),
steps: z.array(stepSchema),
});
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
+6 -77
View File
@@ -1,63 +1,8 @@
import { generateObject } from "ai";
import dns from "node:dns/promises";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
/**
* Resolves the hostname in `rawUrl` and returns an error string if the
* URL targets a private/reserved address range (SSRF guard), or null if safe.
*/
async function validateImportUrl(rawUrl: string): Promise<string | null> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return "Invalid URL";
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
return "URL must use http or https";
}
let addresses: string[];
try {
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
addresses = results.map((r) => r.address);
} catch {
return "Unable to resolve hostname";
}
for (const addr of addresses) {
if (isPrivateAddress(addr)) {
return "URL must not point to a private or reserved address";
}
}
return null;
}
function isPrivateAddress(ip: string): boolean {
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
if (a === 127) return true;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a >= 224) return true;
return false;
}
const lower = ip.toLowerCase();
if (lower === "::1") return true;
if (lower === "::") return true;
if (lower.startsWith("fe80:")) return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
return false;
}
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({
title: z.string(),
@@ -66,31 +11,15 @@ const ImportedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema.optional(),
ingredients: z.array(ingredientSchema(z.string())),
steps: z.array(stepSchema),
});
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
const ssrfError = await validateImportUrl(url);
const ssrfError = await validateWebhookUrl(url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(url, {
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod";
export const dietaryTagsSchema = z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
});
export const stepSchema = z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
});
export function ingredientSchema<Q extends z.ZodTypeAny>(quantity: Q) {
return z.object({
rawName: z.string(),
quantity: quantity.optional(),
unit: z.string().optional(),
note: z.string().optional(),
});
}
+10 -1
View File
@@ -107,6 +107,15 @@ export function generateOpenApiSpec(): object {
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }),
});
const PaginatedCollections = z.object({
data: z.array(CollectionRef),
total: z.number(),
limit: z.number(),
offset: z.number(),
});
const LimitOffset = z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) });
const idParam = z.object({ id: z.string() });
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20), visibility: z.enum(["private", "unlisted", "public"]).optional(), q: z.string().optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -121,7 +130,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: Pagination }, responses: { 200: { description: "Collections", content: { "application/json": { schema: z.array(CollectionRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
+1 -34
View File
@@ -1,6 +1,6 @@
import { db } from "@epicure/db";
import { tierDefinitions, userUsage } from "@epicure/db";
import { eq, and, sql } from "@epicure/db";
import { eq, sql } from "@epicure/db";
function currentMonth() {
const now = new Date();
@@ -69,39 +69,6 @@ export async function checkAndIncrementTierLimit(
}
}
/** @deprecated Use checkAndIncrementTierLimit for recipe/aiCall keys to avoid TOCTOU races. */
export async function checkTierLimit(
userId: string,
userTier: "free" | "pro",
key: LimitKey
): Promise<void> {
const [tierDef] = await db
.select()
.from(tierDefinitions)
.where(eq(tierDefinitions.tier, userTier));
if (!tierDef) return;
const month = currentMonth();
const [usage] = await db
.select()
.from(userUsage)
.where(and(eq(userUsage.userId, userId), eq(userUsage.month, month)));
const current = usage ?? {
aiCallsUsed: 0,
recipeCount: 0,
storageUsedMb: 0,
};
if (key === "recipe" && current.recipeCount >= tierDef.maxRecipes) {
throw new TierLimitError("recipe", userTier);
}
if (key === "aiCall" && current.aiCallsUsed >= tierDef.aiCallsPerMonth) {
throw new TierLimitError("aiCall", userTier);
}
}
export async function incrementUsage(
userId: string,
key: LimitKey,
+84 -17
View File
@@ -1,24 +1,91 @@
import dns from "node:dns/promises";
import net from "node:net";
function isPrivateAddress(ip: string): boolean {
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
if (a === 127) return true;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a >= 224) return true;
return false;
function isPrivateV4(a: number, b: number): boolean {
if (a === 127) return true;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a >= 224) return true;
return false;
}
/** Expands a valid IPv6 address (any compression form) into 8 16-bit groups as a BigInt. */
function ipv6ToBigInt(ip: string): bigint | null {
if (net.isIPv6(ip) !== true) return null;
const [head, tail] = ip.split("::");
const headParts = head ? head.split(":") : [];
const tailParts = tail ? tail.split(":") : [];
// An embedded IPv4 tail (e.g. "::ffff:127.0.0.1") occupies the last two hextets.
const expand = (parts: string[]): string[] => {
const last = parts[parts.length - 1];
if (last && last.includes(".")) {
const octets = last.split(".").map(Number);
if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
return [];
}
const hex1 = ((octets[0]! << 8) | octets[1]!).toString(16);
const hex2 = ((octets[2]! << 8) | octets[3]!).toString(16);
return [...parts.slice(0, -1), hex1, hex2];
}
return parts;
};
const expandedHead = expand(headParts);
const expandedTail = expand(tailParts);
let groups: string[];
if (ip.includes("::")) {
const missing = 8 - (expandedHead.length + expandedTail.length);
if (missing < 0) return null;
groups = [...expandedHead, ...Array(missing).fill("0"), ...expandedTail];
} else {
groups = expandedHead;
}
if (groups.length !== 8) return null;
let result = BigInt(0);
for (const g of groups) {
const val = parseInt(g || "0", 16);
if (Number.isNaN(val)) return null;
result = (result << BigInt(16)) | BigInt(val);
}
return result;
}
export function isPrivateAddress(ip: string): boolean {
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const octets = v4.slice(1, 5).map(Number);
if (octets.some((o) => o > 255)) return true; // malformed, fail closed
const [a, b] = octets as [number, number];
return isPrivateV4(a, b);
}
const addr = ipv6ToBigInt(ip);
if (addr === null) return true; // unparseable, fail closed
if (addr === BigInt(0) || addr === BigInt(1)) return true; // :: and ::1
const fc00 = BigInt(0xfc00) << BigInt(112);
const fe80 = BigInt(0xfe80) << BigInt(112);
const mask7 = BigInt(0xfe00) << BigInt(112); // /7 mask for fc00::/7 (top 7 bits of the address)
const mask10 = BigInt(0xffc0) << BigInt(112); // /10 mask for fe80::/10 (top 10 bits of the address)
if ((addr & mask7) === (fc00 & mask7)) return true; // unique local fc00::/7
if ((addr & mask10) === (fe80 & mask10)) return true; // link-local fe80::/10
// IPv4-mapped ::ffff:0:0/96
if (addr >> BigInt(32) === BigInt(0xffff)) {
const embedded = addr & BigInt(0xffffffff);
const a = Number((embedded >> BigInt(24)) & BigInt(0xff));
const b = Number((embedded >> BigInt(16)) & BigInt(0xff));
return isPrivateV4(a, b);
}
const lower = ip.toLowerCase();
if (lower === "::1" || lower === "::") return true;
if (lower.startsWith("fe80:")) return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
return false;
}