diff --git a/src/__tests__/rdap/tld-validation.test.ts b/src/__tests__/rdap/tld-validation.test.ts new file mode 100644 index 0000000..3f5c1f8 --- /dev/null +++ b/src/__tests__/rdap/tld-validation.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { extractTld, validateDomainTld } from "@/rdap/services/tld-validation"; +import type { Register } from "@/rdap/schemas"; + +// Mock the registry module +vi.mock("@/rdap/services/registry", () => ({ + getRegistry: vi.fn(), + getCachedRegistry: vi.fn(), +})); + +describe("extractTld", () => { + it("should extract TLD from standard domain", () => { + expect(extractTld("example.com")).toBe("com"); + }); + + it("should extract TLD from subdomain", () => { + expect(extractTld("www.example.com")).toBe("com"); + }); + + it("should extract TLD from multiple subdomain levels", () => { + expect(extractTld("api.staging.example.com")).toBe("com"); + }); + + it("should return null for single label domain", () => { + expect(extractTld("localhost")).toBeNull(); + }); + + it("should return null for empty string", () => { + expect(extractTld("")).toBeNull(); + }); + + it("should handle uppercase TLDs", () => { + expect(extractTld("example.COM")).toBe("com"); + }); + + it("should handle mixed case domains", () => { + expect(extractTld("Example.CoM")).toBe("com"); + }); +}); + +describe("validateDomainTld", () => { + beforeEach(() => { + // Reset mocks before each test + vi.resetModules(); + }); + + describe("with mocked IANA TLD list", () => { + beforeEach(async () => { + // Mock fetch for IANA TLD list + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + `# IANA TLD list +com +net +org +test +localhost +example +invalid` + ), + }); + + // Mock the registry module to return test data + const { getRegistry } = await import("@/rdap/services/registry"); + vi.mocked(getRegistry).mockResolvedValue({ + description: "Test DNS registry", + publication: "2024-01-01", + version: "1.0", + services: [ + [["com", "net", "org"], ["https://rdap.example.com/"]], + // test, localhost, example, invalid are NOT in RDAP registry + ], + } as Register); + }); + + it("should return valid for TLD with RDAP support", async () => { + const result = await validateDomainTld("example.com"); + expect(result).toEqual({ type: "valid" }); + }); + + it("should return no-rdap for valid TLD without RDAP support", async () => { + const result = await validateDomainTld("example.test"); + expect(result).toEqual({ type: "no-rdap", tld: "test" }); + }); + + it("should return invalid for non-existent TLD", async () => { + const result = await validateDomainTld("example.notreal"); + expect(result).toEqual({ type: "invalid", tld: "notreal" }); + }); + + it("should handle subdomains correctly", async () => { + const result = await validateDomainTld("www.example.com"); + expect(result).toEqual({ type: "valid" }); + }); + + it("should return invalid for single-label domain", async () => { + const result = await validateDomainTld("localhost"); + expect(result).toEqual({ type: "invalid", tld: "localhost" }); + }); + + it("should handle case-insensitive TLDs", async () => { + const result = await validateDomainTld("example.COM"); + expect(result).toEqual({ type: "valid" }); + }); + }); + + describe("with fetch failures", () => { + beforeEach(async () => { + // Mock fetch to fail for IANA TLD list + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + + // Mock the registry module + const { getRegistry } = await import("@/rdap/services/registry"); + vi.mocked(getRegistry).mockResolvedValue({ + description: "Test DNS registry", + publication: "2024-01-01", + version: "1.0", + services: [[["com"], ["https://rdap.example.com/"]]], + } as Register); + }); + + it("should gracefully handle IANA list fetch failure", async () => { + const result = await validateDomainTld("example.com"); + // Should assume valid when IANA list can't be loaded + expect(result).toEqual({ type: "valid" }); + }); + }); +}); + +describe("getRegistryURL error messages", () => { + beforeEach(() => { + vi.resetModules(); + }); + + describe("domain TLD errors", () => { + beforeEach(async () => { + // Mock fetch for IANA TLD list + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + `# IANA TLD list +com +net +test` + ), + }); + + // Mock the registry module + const { getCachedRegistry, getRegistry } = await import("@/rdap/services/registry"); + + vi.mocked(getCachedRegistry).mockReturnValue({ + description: "Test DNS registry", + publication: "2024-01-01", + version: "1.0", + services: [ + [["com", "net"], ["https://rdap.example.com/"]], + // test is NOT in RDAP registry + ], + } as Register); + + vi.mocked(getRegistry).mockResolvedValue({ + description: "Test DNS registry", + publication: "2024-01-01", + version: "1.0", + services: [ + [["com", "net"], ["https://rdap.example.com/"]], + // test is NOT in RDAP registry + ], + } as Register); + }); + + it("should throw enhanced error for invalid TLD", async () => { + const { getRegistryURL } = await import("@/rdap/services/resolver"); + + await expect(getRegistryURL("domain", "example.invalidtld")).rejects.toThrow( + 'The TLD ".invalidtld" is not recognized as a valid top-level domain' + ); + }); + + it("should throw enhanced error for valid TLD without RDAP", async () => { + const { getRegistryURL } = await import("@/rdap/services/resolver"); + + await expect(getRegistryURL("domain", "example.test")).rejects.toThrow( + 'The TLD ".test" exists but is not available in the IANA RDAP registry' + ); + }); + + it("should throw error for malformed domain", async () => { + const { getRegistryURL } = await import("@/rdap/services/resolver"); + + await expect(getRegistryURL("domain", "noextension")).rejects.toThrow( + 'Invalid domain format: "noextension"' + ); + }); + + it("should not throw error for valid domain with RDAP", async () => { + const { getRegistryURL } = await import("@/rdap/services/resolver"); + + await expect(getRegistryURL("domain", "example.com")).resolves.toContain( + "https://rdap.example.com/domain/example.com" + ); + }); + }); +}); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index dc9aacb..8a5c159 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -30,10 +30,16 @@ const Index: NextPage = () => { [router] ); - const { error, target, setTarget, setTargetType, submit, currentType, manualType } = useLookup( - undefined, - handleUrlUpdate - ); + const { + error, + target, + setTarget, + setTargetType, + submit, + currentType, + manualType, + tldValidation, + } = useLookup(undefined, handleUrlUpdate); // Parse URL parameters on mount and auto-execute query if present useEffect(() => { @@ -143,6 +149,7 @@ const Index: NextPage = () => { = ({ @@ -46,6 +57,7 @@ const LookupInput: FunctionComponent = ({ onChange, detectedType, shareableUrl, + tldValidation, }: LookupInputProps) => { const { register, handleSubmit, getValues } = useForm({ defaultValues: { @@ -222,8 +234,42 @@ const LookupInput: FunctionComponent = ({ )} - {shareableUrl && ( + {tldValidation && tldValidation.type !== "valid" && ( + + The TLD .{tldValidation.tld} exists + but is not available in the IANA RDAP registry. The + query will not return results. + + ) : ( + + The TLD .{tldValidation.tld} is not + recognized as a valid top-level domain. + + ) + } + > + {tldValidation.type === "no-rdap" ? ( + + ) : ( + + )} + + + )} + {shareableUrl && ( + )} diff --git a/src/rdap/hooks/useLookup.tsx b/src/rdap/hooks/useLookup.tsx index 3c61d1a..0280d2b 100644 --- a/src/rdap/hooks/useLookup.tsx +++ b/src/rdap/hooks/useLookup.tsx @@ -13,6 +13,7 @@ import { } from "@/rdap/services/type-detection"; import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query"; import { useTelemetry } from "@/contexts/TelemetryContext"; +import { validateDomainTld, type TldValidationResult } from "@/rdap/services/tld-validation"; export type WarningHandler = (warning: { message: string }) => void; export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => void; @@ -31,6 +32,9 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate // Used by a callback on LookupInput to forcibly set the type of the lookup. const [currentType, setTargetType] = useState(null); + // TLD validation state for real-time warnings + const [tldValidation, setTldValidation] = useState(null); + // Used to allow repeatable lookups when weird errors happen. const repeatableRef = useRef(""); @@ -55,6 +59,45 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate detectType().catch(console.error); }, [debouncedTarget, currentType, getTypeEasy]); + // Validate TLD in real-time for domain inputs + useEffect(() => { + const validateTld = async () => { + // Clear validation when input is empty + if (debouncedTarget.length === 0) { + setTldValidation(null); + return; + } + + // Only validate domains + const isDomain = + currentType === "domain" || + (currentType === null && uriType.mapOr(false, (t) => t === "domain")); + + if (!isDomain) { + setTldValidation(null); + return; + } + + // Perform validation + const result = await validateDomainTld(debouncedTarget); + setTldValidation(result); + + // Track telemetry for warnings/errors + if (result.type !== "valid") { + track({ + name: "user_interaction", + properties: { + action: "tld_warning_shown", + component: "LookupInput", + value: result.type, + }, + }); + } + }; + + validateTld().catch(console.error); + }, [debouncedTarget, currentType, uriType, track]); + useEffect(() => { const preload = async () => { if (uriType.isNothing) return; @@ -229,6 +272,7 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate currentType: uriType, manualType: currentType, getType: getTypeEasy, + tldValidation, }; }; diff --git a/src/rdap/services/api.ts b/src/rdap/services/api.ts index 4ba58fb..89a5fc9 100644 --- a/src/rdap/services/api.ts +++ b/src/rdap/services/api.ts @@ -1,6 +1,19 @@ import type { ZodSchema } from "zod"; import { Result } from "true-myth"; +/** + * Custom error for 404 Not Found responses that includes the URL for context. + */ +export class NotFoundError extends Error { + constructor( + message: string, + public url: string + ) { + super(message); + this.name = "NotFoundError"; + } +} + /** * Fetch and parse RDAP data from a URL * @param url - The URL to fetch @@ -63,8 +76,9 @@ export async function getAndParse( case 404: return Result.err( - new Error( - "The registry indicated that the resource requested could not be found; the resource either does not exist, or is something that the registry does not track (i.e. this software queried incorrectly, which is unlikely)." + new NotFoundError( + "The registry indicated that the resource requested could not be found.", + url ) ); case 500: diff --git a/src/rdap/services/query.ts b/src/rdap/services/query.ts index 46b40ad..ba5d321 100644 --- a/src/rdap/services/query.ts +++ b/src/rdap/services/query.ts @@ -8,7 +8,7 @@ import { import { Result } from "true-myth"; import { loadBootstrap } from "@/rdap/services/registry"; import { getRegistryURL } from "@/rdap/services/resolver"; -import { getAndParse } from "@/rdap/services/api"; +import { getAndParse, NotFoundError } from "@/rdap/services/api"; import type { ParsedGeneric } from "@/rdap/components/RdapObjectRouter"; // An array of schemas to try and parse unknown JSON data with. @@ -74,21 +74,35 @@ export async function executeRdapQuery( // Block scoped case to allow url const reuse case "ip4": { await loadBootstrap("ip4"); - const url = getRegistryURL(targetType, target, queryParams); + const url = await getRegistryURL(targetType, target, queryParams); const result = await getAndParse(url, IpNetworkSchema, followReferral); - if (result.isErr) return Result.err(result.error); + if (result.isErr) { + if (result.error instanceof NotFoundError) { + return Result.err( + new Error(`The IP address "${target}" was not found in the registry.`) + ); + } + return Result.err(result.error); + } return Result.ok({ data: result.value, url }); } case "ip6": { await loadBootstrap("ip6"); - const url = getRegistryURL(targetType, target, queryParams); + const url = await getRegistryURL(targetType, target, queryParams); const result = await getAndParse(url, IpNetworkSchema, followReferral); - if (result.isErr) return Result.err(result.error); + if (result.isErr) { + if (result.error instanceof NotFoundError) { + return Result.err( + new Error(`The IP address "${target}" was not found in the registry.`) + ); + } + return Result.err(result.error); + } return Result.ok({ data: result.value, url }); } case "domain": { await loadBootstrap("domain"); - const url = getRegistryURL(targetType, target, queryParams); + const url = await getRegistryURL(targetType, target, queryParams); // HTTP if (url.startsWith("http://") && url != repeatableUrl) { @@ -102,19 +116,37 @@ export async function executeRdapQuery( ); } const result = await getAndParse(url, DomainSchema, followReferral); - if (result.isErr) return Result.err(result.error); + if (result.isErr) { + if (result.error instanceof NotFoundError) { + return Result.err( + new Error( + `The domain "${target}" was not found in the registry. It may not be registered or may have expired.` + ) + ); + } + return Result.err(result.error); + } return Result.ok({ data: result.value, url }); } case "autnum": { await loadBootstrap("autnum"); - const url = getRegistryURL(targetType, target, queryParams); + const url = await getRegistryURL(targetType, target, queryParams); const result = await getAndParse( url, AutonomousNumberSchema, followReferral ); - if (result.isErr) return Result.err(result.error); + if (result.isErr) { + if (result.error instanceof NotFoundError) { + return Result.err( + new Error( + `The autonomous system number "${target}" was not found in the registry.` + ) + ); + } + return Result.err(result.error); + } return Result.ok({ data: result.value, url }); } case "tld": { @@ -127,7 +159,14 @@ export async function executeRdapQuery( const baseUrl = `https://root.rdap.org/domain/${value}`; const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; const result = await getAndParse(url, DomainSchema, followReferral); - if (result.isErr) return Result.err(result.error); + if (result.isErr) { + if (result.error instanceof NotFoundError) { + return Result.err( + new Error(`The TLD "${target}" was not found in the root zone.`) + ); + } + return Result.err(result.error); + } return Result.ok({ data: result.value, url }); } case "url": { @@ -164,9 +203,16 @@ export async function executeRdapQuery( } case "entity": { await loadBootstrap("entity"); - const url = getRegistryURL(targetType, target, queryParams); + const url = await getRegistryURL(targetType, target, queryParams); const result = await getAndParse(url, EntitySchema, followReferral); - if (result.isErr) return Result.err(result.error); + if (result.isErr) { + if (result.error instanceof NotFoundError) { + return Result.err( + new Error(`The entity "${target}" was not found in the registry.`) + ); + } + return Result.err(result.error); + } return Result.ok({ data: result.value, url }); } case "registrar": diff --git a/src/rdap/services/resolver.ts b/src/rdap/services/resolver.ts index fe29c76..3a64cdc 100644 --- a/src/rdap/services/resolver.ts +++ b/src/rdap/services/resolver.ts @@ -2,6 +2,7 @@ import type { RootRegistryType } from "@/rdap/schemas"; import { getCachedRegistry } from "@/rdap/services/registry"; import { domainMatchPredicate, getBestURL } from "@/rdap/utils"; import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network"; +import { extractTld, validateDomainTld } from "@/rdap/services/tld-validation"; export interface URLQueryParams { jsContact?: boolean; @@ -11,11 +12,11 @@ export interface URLQueryParams { /** * Resolve the RDAP URL for a given registry type and lookup target */ -export function getRegistryURL( +export async function getRegistryURL( type: RootRegistryType, lookupTarget: string, queryParams?: URLQueryParams -): string { +): Promise { const bootstrap = getCachedRegistry(type); if (bootstrap == null) throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`); @@ -23,7 +24,7 @@ export function getRegistryURL( let url: string | null = null; typeSwitch: switch (type) { - case "domain": + case "domain": { for (const bootstrapItem of bootstrap.services) { if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) { // min length of 1 is validated in zod schema @@ -31,7 +32,31 @@ export function getRegistryURL( break typeSwitch; } } - throw new Error(`No matching domain found.`); + + // Domain not found in RDAP registry - provide detailed error + const tld = extractTld(lookupTarget); + if (!tld) { + throw new Error( + `Invalid domain format: "${lookupTarget}". Expected format: example.com` + ); + } + + // Validate TLD to provide context-specific error + const validation = await validateDomainTld(lookupTarget); + + if (validation.type === "invalid") { + throw new Error( + `The TLD ".${validation.tld}" is not recognized as a valid top-level domain. Please verify the domain name is correct.` + ); + } else if (validation.type === "no-rdap") { + throw new Error( + `The TLD ".${validation.tld}" exists but is not available in the IANA RDAP registry. This TLD does not support RDAP lookups.` + ); + } + + // Fallback (should not reach here given validation logic) + throw new Error(`No RDAP server found for domain "${lookupTarget}".`); + } case "ip4": { // Extract the IP address without CIDR suffix for matching const [ipAddress] = lookupTarget.split("/"); diff --git a/src/rdap/services/tld-validation.ts b/src/rdap/services/tld-validation.ts new file mode 100644 index 0000000..548f8d8 --- /dev/null +++ b/src/rdap/services/tld-validation.ts @@ -0,0 +1,122 @@ +/** + * TLD validation service + * Validates domain TLDs against: + * 1. Complete IANA TLD list (all valid TLDs) + * 2. RDAP DNS registry (TLDs with RDAP support) + */ + +export type TldValidationResult = + | { type: "valid" } + | { type: "no-rdap"; tld: string } + | { type: "invalid"; tld: string }; + +// Cache for IANA TLD list +let tldListCache: Set | null = null; +let tldListCacheExpiry: number | null = null; + +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours +const IANA_TLD_LIST_URL = "https://data.iana.org/TLD/tlds-alpha-by-domain.txt"; + +/** + * Load the complete IANA TLD list from official source + */ +async function loadIanaTldList(): Promise> { + // Check in-memory cache first + const now = Date.now(); + if (tldListCache && tldListCacheExpiry && now < tldListCacheExpiry) { + return tldListCache; + } + + try { + const response = await fetch(IANA_TLD_LIST_URL); + if (!response.ok) { + throw new Error(`Failed to fetch IANA TLD list: ${response.statusText}`); + } + + const text = await response.text(); + + // Parse file - format is one TLD per line, comments start with # + const tlds = text + .split("\n") + .map((line) => line.trim().toLowerCase()) + .filter((line) => line && !line.startsWith("#")); + + tldListCache = new Set(tlds); + tldListCacheExpiry = now + CACHE_TTL; + + return tldListCache; + } catch (error) { + // If fetch fails, return empty set to fail gracefully + console.error("Failed to load IANA TLD list:", error); + return new Set(); + } +} + +/** + * Extract TLD from domain (rightmost label after final dot) + */ +export function extractTld(domain: string): string | null { + const parts = domain.split("."); + if (parts.length < 2) { + return null; + } + const tld = parts[parts.length - 1]; + return tld ? tld.toLowerCase() : null; +} + +/** + * Check if TLD exists in official IANA TLD list + */ +async function isValidTld(tld: string): Promise { + const tldList = await loadIanaTldList(); + + // If list couldn't be loaded, assume valid to avoid false positives + if (tldList.size === 0) { + return true; + } + + return tldList.has(tld.toLowerCase()); +} + +/** + * Check if TLD has RDAP support in IANA DNS registry + */ +async function isRdapAvailable(tld: string): Promise { + try { + const { getRegistry } = await import("./registry"); + const registry = await getRegistry("domain"); + + // Check if TLD appears in any registry service + return registry.services.some((service) => + service[0].some((registryTld) => registryTld.toLowerCase() === tld.toLowerCase()) + ); + } catch (error) { + // If registry can't be loaded, assume no RDAP available + console.error("Failed to check RDAP availability:", error); + return false; + } +} + +/** + * Validate domain TLD against IANA list and RDAP registry + * Returns validation result indicating if TLD is valid, has RDAP, or is invalid + */ +export async function validateDomainTld(domain: string): Promise { + const tld = extractTld(domain); + + if (!tld) { + return { type: "invalid", tld: domain }; + } + + const [isValid, hasRdap] = await Promise.all([isValidTld(tld), isRdapAvailable(tld)]); + + if (!isValid) { + return { type: "invalid", tld }; + } + + if (!hasRdap) { + return { type: "no-rdap", tld }; + } + + return { type: "valid" }; +}