mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 01:16:00 -06:00
Compare commits
3 Commits
49a269df14
...
d95a3e7574
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d95a3e7574 | ||
|
|
13fc05226d | ||
|
|
71ddaadaa0 |
52
.github/renovate.json
vendored
52
.github/renovate.json
vendored
@@ -7,7 +7,7 @@
|
||||
":automergeDigest",
|
||||
":automergeMinor"
|
||||
],
|
||||
"schedule": ["after 10pm every weekday", "before 5am every weekday", "every weekend"],
|
||||
"schedule": ["before 5am on monday"],
|
||||
"timezone": "America/Chicago",
|
||||
"prConcurrentLimit": 3,
|
||||
"prCreation": "not-pending",
|
||||
@@ -20,61 +20,23 @@
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Automerge dev dependencies",
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"description": "Group all non-major dependency updates together",
|
||||
"groupName": "all non-major dependencies",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest"],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"minimumReleaseAge": "3 days"
|
||||
},
|
||||
{
|
||||
"description": "Automerge TypeScript type packages",
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"matchPackageNames": ["/^@types//"]
|
||||
},
|
||||
{
|
||||
"description": "Group ESLint packages together",
|
||||
"groupName": "eslint packages",
|
||||
"automerge": true,
|
||||
"matchPackageNames": ["/^eslint/", "/^@typescript-eslint//"]
|
||||
},
|
||||
{
|
||||
"description": "Group testing packages together",
|
||||
"groupName": "testing packages",
|
||||
"automerge": true,
|
||||
"matchPackageNames": ["/^vitest/", "/^@vitest//", "/^@testing-library//"]
|
||||
},
|
||||
{
|
||||
"description": "Group Next.js related packages",
|
||||
"matchPackageNames": ["next", "eslint-config-next"],
|
||||
"groupName": "Next.js packages",
|
||||
"minimumReleaseAge": "7 days"
|
||||
},
|
||||
{
|
||||
"description": "Group React packages",
|
||||
"matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"],
|
||||
"groupName": "React packages",
|
||||
"description": "Major updates get individual PRs for review",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"automerge": false,
|
||||
"minimumReleaseAge": "7 days"
|
||||
},
|
||||
{
|
||||
"description": "Pin Node.js major versions",
|
||||
"matchPackageNames": ["node"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Group Tailwind CSS packages",
|
||||
"groupName": "Tailwind CSS packages",
|
||||
"matchPackageNames": [
|
||||
"/^tailwindcss/",
|
||||
"/^@tailwindcss//",
|
||||
"/prettier-plugin-tailwindcss/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Group font packages",
|
||||
"groupName": "font packages",
|
||||
"automerge": true,
|
||||
"matchPackageNames": ["/^@fontsource/"]
|
||||
}
|
||||
],
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
|
||||
@@ -70,5 +70,5 @@
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.2.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.9"
|
||||
"packageManager": "pnpm@10.20.0"
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
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 = () => {
|
||||
<LookupInput
|
||||
isLoading={isLoading}
|
||||
detectedType={currentType}
|
||||
tldValidation={tldValidation}
|
||||
shareableUrl={
|
||||
response.isJust && target && typeof window !== "undefined"
|
||||
? buildShareableUrl(window.location.origin, target, manualType)
|
||||
|
||||
@@ -4,11 +4,18 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { onPromise, preventDefault } from "@/lib/misc";
|
||||
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
|
||||
import { TargetTypeEnum } from "@/rdap/schemas";
|
||||
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
|
||||
import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes";
|
||||
import {
|
||||
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 { placeholders } from "@/rdap/constants";
|
||||
import ShareButton from "@/components/ShareButton";
|
||||
import type { TldValidationResult } from "@/rdap/services/tld-validation";
|
||||
|
||||
/**
|
||||
* Props for the LookupInput component.
|
||||
@@ -38,6 +45,10 @@ type LookupInputProps = {
|
||||
* Optional shareable URL to display in the share button. Only shown when provided.
|
||||
*/
|
||||
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> = ({
|
||||
@@ -46,6 +57,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
onChange,
|
||||
detectedType,
|
||||
shareableUrl,
|
||||
tldValidation,
|
||||
}: LookupInputProps) => {
|
||||
const { register, handleSubmit, getValues } = useForm<SubmitProps>({
|
||||
defaultValues: {
|
||||
@@ -222,8 +234,42 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
)}
|
||||
</IconButton>
|
||||
</TextField.Slot>
|
||||
{shareableUrl && (
|
||||
{tldValidation && tldValidation.type !== "valid" && (
|
||||
<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} />
|
||||
</TextField.Slot>
|
||||
)}
|
||||
|
||||
@@ -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<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.
|
||||
const repeatableRef = useRef<string>("");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<T>(
|
||||
|
||||
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:
|
||||
|
||||
@@ -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<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 });
|
||||
}
|
||||
case "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);
|
||||
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<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 });
|
||||
}
|
||||
case "autnum": {
|
||||
await loadBootstrap("autnum");
|
||||
const url = getRegistryURL(targetType, target, queryParams);
|
||||
const url = await getRegistryURL(targetType, target, queryParams);
|
||||
const result = await getAndParse<AutonomousNumber>(
|
||||
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<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 });
|
||||
}
|
||||
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<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 });
|
||||
}
|
||||
case "registrar":
|
||||
|
||||
@@ -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<string> {
|
||||
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("/");
|
||||
|
||||
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