mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-05 23:15:58 -06:00
feat: add TLD validation with real-time warnings
Implement comprehensive TLD validation that checks domain inputs against the IANA TLD list and RDAP registry to provide context-aware error messages and real-time UI warnings. Key changes: - Add tld-validation service that validates TLDs against IANA list and RDAP registry availability - Show warning icons in LookupInput for invalid TLDs (error) and valid TLDs without RDAP support (warning) with explanatory tooltips - Enhance error messages in getRegistryURL to differentiate between invalid TLDs, valid TLDs without RDAP, and malformed domains - Add NotFoundError class for better 404 handling with context-specific messages per target type (domain, IP, ASN, entity, TLD) - Make getRegistryURL async to support TLD validation in error path - Add comprehensive test coverage for TLD validation and error messages - Track TLD warning displays in telemetry This improves UX by catching common mistakes (typos, non-existent TLDs, reserved TLDs) before query execution and providing clear guidance.
This commit is contained in:
207
src/__tests__/rdap/tld-validation.test.ts
Normal file
207
src/__tests__/rdap/tld-validation.test.ts
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,10 +30,16 @@ const Index: NextPage = () => {
|
|||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { error, target, setTarget, setTargetType, submit, currentType, manualType } = useLookup(
|
const {
|
||||||
undefined,
|
error,
|
||||||
handleUrlUpdate
|
target,
|
||||||
);
|
setTarget,
|
||||||
|
setTargetType,
|
||||||
|
submit,
|
||||||
|
currentType,
|
||||||
|
manualType,
|
||||||
|
tldValidation,
|
||||||
|
} = useLookup(undefined, handleUrlUpdate);
|
||||||
|
|
||||||
// Parse URL parameters on mount and auto-execute query if present
|
// Parse URL parameters on mount and auto-execute query if present
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,6 +149,7 @@ const Index: NextPage = () => {
|
|||||||
<LookupInput
|
<LookupInput
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
detectedType={currentType}
|
detectedType={currentType}
|
||||||
|
tldValidation={tldValidation}
|
||||||
shareableUrl={
|
shareableUrl={
|
||||||
response.isJust && target && typeof window !== "undefined"
|
response.isJust && target && typeof window !== "undefined"
|
||||||
? buildShareableUrl(window.location.origin, target, manualType)
|
? buildShareableUrl(window.location.origin, target, manualType)
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { onPromise, preventDefault } from "@/lib/misc";
|
import { onPromise, preventDefault } from "@/lib/misc";
|
||||||
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
|
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
|
||||||
import { TargetTypeEnum } from "@/rdap/schemas";
|
import { TargetTypeEnum } from "@/rdap/schemas";
|
||||||
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
|
import {
|
||||||
import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes";
|
MagnifyingGlassIcon,
|
||||||
|
ReloadIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
CrossCircledIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
|
import { TextField, Select, Flex, IconButton, Badge, Tooltip, Text } from "@radix-ui/themes";
|
||||||
import type { Maybe } from "true-myth";
|
import type { Maybe } from "true-myth";
|
||||||
import { placeholders } from "@/rdap/constants";
|
import { placeholders } from "@/rdap/constants";
|
||||||
import ShareButton from "@/components/ShareButton";
|
import ShareButton from "@/components/ShareButton";
|
||||||
|
import type { TldValidationResult } from "@/rdap/services/tld-validation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the LookupInput component.
|
* Props for the LookupInput component.
|
||||||
@@ -38,6 +45,10 @@ type LookupInputProps = {
|
|||||||
* Optional shareable URL to display in the share button. Only shown when provided.
|
* Optional shareable URL to display in the share button. Only shown when provided.
|
||||||
*/
|
*/
|
||||||
shareableUrl?: string;
|
shareableUrl?: string;
|
||||||
|
/**
|
||||||
|
* TLD validation result for domain inputs. Shows warning/error icon when TLD is invalid or unavailable.
|
||||||
|
*/
|
||||||
|
tldValidation?: TldValidationResult | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LookupInput: FunctionComponent<LookupInputProps> = ({
|
const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||||
@@ -46,6 +57,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
detectedType,
|
detectedType,
|
||||||
shareableUrl,
|
shareableUrl,
|
||||||
|
tldValidation,
|
||||||
}: LookupInputProps) => {
|
}: LookupInputProps) => {
|
||||||
const { register, handleSubmit, getValues } = useForm<SubmitProps>({
|
const { register, handleSubmit, getValues } = useForm<SubmitProps>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -222,8 +234,42 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
|||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TextField.Slot>
|
</TextField.Slot>
|
||||||
{shareableUrl && (
|
{tldValidation && tldValidation.type !== "valid" && (
|
||||||
<TextField.Slot side="right">
|
<TextField.Slot side="right">
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
tldValidation.type === "no-rdap" ? (
|
||||||
|
<Text size="2">
|
||||||
|
The TLD <strong>.{tldValidation.tld}</strong> exists
|
||||||
|
but is not available in the IANA RDAP registry. The
|
||||||
|
query will not return results.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="2">
|
||||||
|
The TLD <strong>.{tldValidation.tld}</strong> is not
|
||||||
|
recognized as a valid top-level domain.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tldValidation.type === "no-rdap" ? (
|
||||||
|
<ExclamationTriangleIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
style={{ color: "var(--orange-9)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CrossCircledIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
style={{ color: "var(--red-9)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TextField.Slot>
|
||||||
|
)}
|
||||||
|
{shareableUrl && (
|
||||||
|
<TextField.Slot side="right" pr="3">
|
||||||
<ShareButton url={shareableUrl} />
|
<ShareButton url={shareableUrl} />
|
||||||
</TextField.Slot>
|
</TextField.Slot>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/rdap/services/type-detection";
|
} from "@/rdap/services/type-detection";
|
||||||
import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query";
|
import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query";
|
||||||
import { useTelemetry } from "@/contexts/TelemetryContext";
|
import { useTelemetry } from "@/contexts/TelemetryContext";
|
||||||
|
import { validateDomainTld, type TldValidationResult } from "@/rdap/services/tld-validation";
|
||||||
|
|
||||||
export type WarningHandler = (warning: { message: string }) => void;
|
export type WarningHandler = (warning: { message: string }) => void;
|
||||||
export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => 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.
|
// Used by a callback on LookupInput to forcibly set the type of the lookup.
|
||||||
const [currentType, setTargetType] = useState<TargetType | null>(null);
|
const [currentType, setTargetType] = useState<TargetType | null>(null);
|
||||||
|
|
||||||
|
// TLD validation state for real-time warnings
|
||||||
|
const [tldValidation, setTldValidation] = useState<TldValidationResult | null>(null);
|
||||||
|
|
||||||
// Used to allow repeatable lookups when weird errors happen.
|
// Used to allow repeatable lookups when weird errors happen.
|
||||||
const repeatableRef = useRef<string>("");
|
const repeatableRef = useRef<string>("");
|
||||||
|
|
||||||
@@ -55,6 +59,45 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
|
|||||||
detectType().catch(console.error);
|
detectType().catch(console.error);
|
||||||
}, [debouncedTarget, currentType, getTypeEasy]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const preload = async () => {
|
const preload = async () => {
|
||||||
if (uriType.isNothing) return;
|
if (uriType.isNothing) return;
|
||||||
@@ -229,6 +272,7 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
|
|||||||
currentType: uriType,
|
currentType: uriType,
|
||||||
manualType: currentType,
|
manualType: currentType,
|
||||||
getType: getTypeEasy,
|
getType: getTypeEasy,
|
||||||
|
tldValidation,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import type { ZodSchema } from "zod";
|
import type { ZodSchema } from "zod";
|
||||||
import { Result } from "true-myth";
|
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
|
* Fetch and parse RDAP data from a URL
|
||||||
* @param url - The URL to fetch
|
* @param url - The URL to fetch
|
||||||
@@ -63,8 +76,9 @@ export async function getAndParse<T>(
|
|||||||
|
|
||||||
case 404:
|
case 404:
|
||||||
return Result.err(
|
return Result.err(
|
||||||
new Error(
|
new NotFoundError(
|
||||||
"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)."
|
"The registry indicated that the resource requested could not be found.",
|
||||||
|
url
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
case 500:
|
case 500:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { Result } from "true-myth";
|
import { Result } from "true-myth";
|
||||||
import { loadBootstrap } from "@/rdap/services/registry";
|
import { loadBootstrap } from "@/rdap/services/registry";
|
||||||
import { getRegistryURL } from "@/rdap/services/resolver";
|
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";
|
import type { ParsedGeneric } from "@/rdap/components/RdapObjectRouter";
|
||||||
|
|
||||||
// An array of schemas to try and parse unknown JSON data with.
|
// 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
|
// Block scoped case to allow url const reuse
|
||||||
case "ip4": {
|
case "ip4": {
|
||||||
await loadBootstrap("ip4");
|
await loadBootstrap("ip4");
|
||||||
const url = getRegistryURL(targetType, target, queryParams);
|
const url = await getRegistryURL(targetType, target, queryParams);
|
||||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
|
const result = await getAndParse<IpNetwork>(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 });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "ip6": {
|
case "ip6": {
|
||||||
await loadBootstrap("ip6");
|
await loadBootstrap("ip6");
|
||||||
const url = getRegistryURL(targetType, target, queryParams);
|
const url = await getRegistryURL(targetType, target, queryParams);
|
||||||
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
|
const result = await getAndParse<IpNetwork>(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 });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "domain": {
|
case "domain": {
|
||||||
await loadBootstrap("domain");
|
await loadBootstrap("domain");
|
||||||
const url = getRegistryURL(targetType, target, queryParams);
|
const url = await getRegistryURL(targetType, target, queryParams);
|
||||||
|
|
||||||
// HTTP
|
// HTTP
|
||||||
if (url.startsWith("http://") && url != repeatableUrl) {
|
if (url.startsWith("http://") && url != repeatableUrl) {
|
||||||
@@ -102,19 +116,37 @@ export async function executeRdapQuery(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
|
const result = await getAndParse<Domain>(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 });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "autnum": {
|
case "autnum": {
|
||||||
await loadBootstrap("autnum");
|
await loadBootstrap("autnum");
|
||||||
const url = getRegistryURL(targetType, target, queryParams);
|
const url = await getRegistryURL(targetType, target, queryParams);
|
||||||
const result = await getAndParse<AutonomousNumber>(
|
const result = await getAndParse<AutonomousNumber>(
|
||||||
url,
|
url,
|
||||||
AutonomousNumberSchema,
|
AutonomousNumberSchema,
|
||||||
followReferral
|
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 });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "tld": {
|
case "tld": {
|
||||||
@@ -127,7 +159,14 @@ export async function executeRdapQuery(
|
|||||||
const baseUrl = `https://root.rdap.org/domain/${value}`;
|
const baseUrl = `https://root.rdap.org/domain/${value}`;
|
||||||
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||||
const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
|
const result = await getAndParse<Domain>(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 });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "url": {
|
case "url": {
|
||||||
@@ -164,9 +203,16 @@ export async function executeRdapQuery(
|
|||||||
}
|
}
|
||||||
case "entity": {
|
case "entity": {
|
||||||
await loadBootstrap("entity");
|
await loadBootstrap("entity");
|
||||||
const url = getRegistryURL(targetType, target, queryParams);
|
const url = await getRegistryURL(targetType, target, queryParams);
|
||||||
const result = await getAndParse<Entity>(url, EntitySchema, followReferral);
|
const result = await getAndParse<Entity>(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 });
|
return Result.ok({ data: result.value, url });
|
||||||
}
|
}
|
||||||
case "registrar":
|
case "registrar":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RootRegistryType } from "@/rdap/schemas";
|
|||||||
import { getCachedRegistry } from "@/rdap/services/registry";
|
import { getCachedRegistry } from "@/rdap/services/registry";
|
||||||
import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
|
import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
|
||||||
import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network";
|
import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network";
|
||||||
|
import { extractTld, validateDomainTld } from "@/rdap/services/tld-validation";
|
||||||
|
|
||||||
export interface URLQueryParams {
|
export interface URLQueryParams {
|
||||||
jsContact?: boolean;
|
jsContact?: boolean;
|
||||||
@@ -11,11 +12,11 @@ export interface URLQueryParams {
|
|||||||
/**
|
/**
|
||||||
* Resolve the RDAP URL for a given registry type and lookup target
|
* Resolve the RDAP URL for a given registry type and lookup target
|
||||||
*/
|
*/
|
||||||
export function getRegistryURL(
|
export async function getRegistryURL(
|
||||||
type: RootRegistryType,
|
type: RootRegistryType,
|
||||||
lookupTarget: string,
|
lookupTarget: string,
|
||||||
queryParams?: URLQueryParams
|
queryParams?: URLQueryParams
|
||||||
): string {
|
): Promise<string> {
|
||||||
const bootstrap = getCachedRegistry(type);
|
const bootstrap = getCachedRegistry(type);
|
||||||
if (bootstrap == null)
|
if (bootstrap == null)
|
||||||
throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`);
|
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;
|
let url: string | null = null;
|
||||||
|
|
||||||
typeSwitch: switch (type) {
|
typeSwitch: switch (type) {
|
||||||
case "domain":
|
case "domain": {
|
||||||
for (const bootstrapItem of bootstrap.services) {
|
for (const bootstrapItem of bootstrap.services) {
|
||||||
if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) {
|
if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) {
|
||||||
// min length of 1 is validated in zod schema
|
// min length of 1 is validated in zod schema
|
||||||
@@ -31,7 +32,31 @@ export function getRegistryURL(
|
|||||||
break typeSwitch;
|
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": {
|
case "ip4": {
|
||||||
// Extract the IP address without CIDR suffix for matching
|
// Extract the IP address without CIDR suffix for matching
|
||||||
const [ipAddress] = lookupTarget.split("/");
|
const [ipAddress] = lookupTarget.split("/");
|
||||||
|
|||||||
122
src/rdap/services/tld-validation.ts
Normal file
122
src/rdap/services/tld-validation.ts
Normal file
@@ -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<string> | 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<Set<string>> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<TldValidationResult> {
|
||||||
|
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" };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user