mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-10 10:08:21 -06:00
- Add Vitest testing framework with 88 passing tests across 4 test files - Fix critical entity validator bug (service array index) - Implement validator architecture with 'matched but invalid' state support - Add strict IPv4/IPv6 validation with detailed error messages - Add case-insensitive domain and ASN matching - Add explicit validator priority ordering (url→json→tld→ip→domain) - Add integration tests with real IANA registry validation - Add AutnumCard component for AS number display - Update dependencies: prettier 2.8.1→2.8.8 Test coverage: - helpers.test.ts: IPv4/IPv6 CIDR matching (27 tests) - helpers.asn.test.ts: ASN range validation (22 tests) - rdap.test.ts: Type detection with edge cases (36 tests) - rdap.integration.test.ts: Real IANA registry tests (3 tests) Bug fixes: - Entity validator now correctly uses service[1] for tags (0=email, 1=tags, 2=urls) - IPv4 validation rejects octets >255 with specific error messages - IPv6 validation checks for invalid hex chars and multiple :: - Domain regex supports multi-label domains (a.b.c.d.example.net) - Type detection priority prevents URL/JSON false matches as domains
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { domainMatchPredicate, getBestURL, getType } from "@/rdap";
|
|
import type {
|
|
AutonomousNumber,
|
|
Domain,
|
|
IpNetwork,
|
|
Register,
|
|
RootRegistryType,
|
|
SubmitProps,
|
|
TargetType,
|
|
} from "@/types";
|
|
import { registryURLs } from "@/constants";
|
|
import {
|
|
AutonomousNumberSchema,
|
|
DomainSchema,
|
|
IpNetworkSchema,
|
|
RegisterSchema,
|
|
RootRegistryEnum,
|
|
} from "@/schema";
|
|
import { truncated, ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/helpers";
|
|
import type { ZodSchema } from "zod";
|
|
import type { ParsedGeneric } from "@/components/lookup/Generic";
|
|
import { Maybe, Result } from "true-myth";
|
|
|
|
export type WarningHandler = (warning: { message: string }) => void;
|
|
export type MetaParsedGeneric = {
|
|
data: ParsedGeneric;
|
|
url: string;
|
|
completeTime: Date;
|
|
};
|
|
|
|
// An array of schemas to try and parse unknown JSON data with.
|
|
const schemas = [DomainSchema, AutonomousNumberSchema, IpNetworkSchema];
|
|
|
|
const useLookup = (warningHandler?: WarningHandler) => {
|
|
/**
|
|
* A reference to the registry data, which is used to cache the registry data in memory.
|
|
* This uses TargetType as the key, meaning v4/v6 IP/CIDR lookups are differentiated.
|
|
*/
|
|
const registryDataRef = useRef<Record<RootRegistryType, Register | null>>(
|
|
{} as Record<RootRegistryType, Register>
|
|
);
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [target, setTarget] = useState<string>("");
|
|
const [uriType, setUriType] = useState<Maybe<TargetType>>(Maybe.nothing());
|
|
|
|
// Used by a callback on LookupInput to forcibly set the type of the lookup.
|
|
const [currentType, setTargetType] = useState<TargetType | null>(null);
|
|
|
|
// Used to allow repeatable lookups when weird errors happen.
|
|
const repeatableRef = useRef<string>("");
|
|
|
|
useCallback(async () => {
|
|
if (currentType != null) return Maybe.just(currentType);
|
|
const uri: Maybe<TargetType> = (await getTypeEasy(target)).mapOr(
|
|
Maybe.nothing(),
|
|
(type) => Maybe.just(type)
|
|
);
|
|
setUriType(uri);
|
|
}, [target, currentType, getTypeEasy]);
|
|
|
|
// Fetch & load a specific registry's data into memory.
|
|
async function loadBootstrap(type: RootRegistryType, force = false) {
|
|
// Early preload exit condition
|
|
if (registryDataRef.current[type] != null && !force) return;
|
|
|
|
// Fetch the bootstrapping file from the registry
|
|
const response = await fetch(registryURLs[type]);
|
|
if (response.status != 200)
|
|
throw new Error(`Error: ${response.statusText}`);
|
|
|
|
// Parse it, so we don't make any false assumptions during development & while maintaining the tool.
|
|
const parsedRegister = RegisterSchema.safeParse(await response.json());
|
|
if (!parsedRegister.success)
|
|
throw new Error(
|
|
`Could not parse IANA bootstrap response (type: ${type}).`
|
|
);
|
|
|
|
// Set it in state so we can use it.
|
|
registryDataRef.current = {
|
|
...registryDataRef.current,
|
|
[type]: parsedRegister.data,
|
|
};
|
|
}
|
|
|
|
async function getRegistry(type: RootRegistryType): Promise<Register> {
|
|
if (registryDataRef.current[type] == null) await loadBootstrap(type);
|
|
const registry = registryDataRef.current[type];
|
|
if (registry == null)
|
|
throw new Error(`Could not load bootstrap data for ${type} registry.`);
|
|
return registry;
|
|
}
|
|
|
|
async function getTypeEasy(
|
|
target: string
|
|
): Promise<Result<TargetType, Error>> {
|
|
return getType(target, getRegistry);
|
|
}
|
|
|
|
function getRegistryURL(
|
|
type: RootRegistryType,
|
|
lookupTarget: string
|
|
): string {
|
|
const bootstrap = registryDataRef.current[type];
|
|
if (bootstrap == null)
|
|
throw new Error(
|
|
`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`
|
|
);
|
|
|
|
let url: string | null = null;
|
|
|
|
typeSwitch: switch (type) {
|
|
case "domain":
|
|
for (const bootstrapItem of bootstrap.services) {
|
|
if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) {
|
|
// min length of 1 is validated in zod schema
|
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
break typeSwitch;
|
|
}
|
|
}
|
|
throw new Error(`No matching domain found.`);
|
|
case "ip4": {
|
|
// Extract the IP address without CIDR suffix for matching
|
|
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
|
for (const bootstrapItem of bootstrap.services) {
|
|
// bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"]
|
|
if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) {
|
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
break typeSwitch;
|
|
}
|
|
}
|
|
throw new Error(`No matching IPv4 registry found for ${lookupTarget}.`);
|
|
}
|
|
case "ip6": {
|
|
// Extract the IP address without CIDR suffix for matching
|
|
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
|
|
for (const bootstrapItem of bootstrap.services) {
|
|
// bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"]
|
|
if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) {
|
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
break typeSwitch;
|
|
}
|
|
}
|
|
throw new Error(`No matching IPv6 registry found for ${lookupTarget}.`);
|
|
}
|
|
case "autnum": {
|
|
// Extract ASN number from "AS12345" format
|
|
const asnMatch = lookupTarget.match(/^AS(\d+)$/i);
|
|
if (!asnMatch || !asnMatch[1]) {
|
|
throw new Error(`Invalid ASN format: ${lookupTarget}`);
|
|
}
|
|
|
|
const asnNumber = parseInt(asnMatch[1], 10);
|
|
if (isNaN(asnNumber)) {
|
|
throw new Error(`Invalid ASN number: ${lookupTarget}`);
|
|
}
|
|
|
|
for (const bootstrapItem of bootstrap.services) {
|
|
// bootstrapItem[0] contains ASN ranges like ["64512-65534", "13312-18431"]
|
|
if (bootstrapItem[0].some((range) => asnInRange(asnNumber, range))) {
|
|
url = getBestURL(bootstrapItem[1] as [string, ...string[]]);
|
|
break typeSwitch;
|
|
}
|
|
}
|
|
throw new Error(`No matching registry found for ${lookupTarget}.`);
|
|
}
|
|
case "entity":
|
|
throw new Error(`No matching entity found.`);
|
|
default:
|
|
throw new Error("Invalid lookup target provided.");
|
|
}
|
|
|
|
if (url == null) throw new Error("No lookup target was resolved.");
|
|
|
|
// Map internal types to RDAP endpoint paths
|
|
// ip4 and ip6 both use the 'ip' endpoint in RDAP
|
|
const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type;
|
|
|
|
return `${url}${rdapPath}/${lookupTarget}`;
|
|
}
|
|
|
|
useEffect(() => {
|
|
const preload = async () => {
|
|
if (uriType.isNothing) return;
|
|
|
|
const registryUri = RootRegistryEnum.safeParse(uriType.value);
|
|
if (!registryUri.success) return;
|
|
|
|
console.log({
|
|
uriType: uriType.value,
|
|
registryData: registryDataRef.current,
|
|
registryUri: registryUri.data,
|
|
});
|
|
if (registryDataRef.current[registryUri.data] != null) return;
|
|
|
|
try {
|
|
await loadBootstrap(registryUri.data);
|
|
} catch (e) {
|
|
if (warningHandler != undefined) {
|
|
const message =
|
|
e instanceof Error ? `(${truncated(e.message, 15)})` : ".";
|
|
warningHandler({
|
|
message: `Failed to preload registry${message}`,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
preload().catch(console.error);
|
|
}, [target, uriType, warningHandler]);
|
|
|
|
async function getAndParse<T>(
|
|
url: string,
|
|
schema: ZodSchema
|
|
): Promise<Result<T, Error>> {
|
|
const response = await fetch(url);
|
|
|
|
if (response.status == 200) {
|
|
const result = schema.safeParse(await response.json());
|
|
|
|
if (result.success === false) {
|
|
// flatten the errors to make them more readable and simple
|
|
const flatErrors = result.error.flatten(function (issue) {
|
|
const path = issue.path.map((value) => value.toString()).join(".");
|
|
return `${path}: ${issue.message}`;
|
|
});
|
|
|
|
console.log(flatErrors);
|
|
|
|
// combine them all, wrap them in a new error, and return it
|
|
return Result.err(
|
|
new Error(
|
|
[
|
|
"Could not parse the response from the registry.",
|
|
...flatErrors.formErrors,
|
|
...Object.values(flatErrors.fieldErrors).flat(),
|
|
].join("\n\t")
|
|
)
|
|
);
|
|
}
|
|
|
|
return Result.ok(result.data);
|
|
}
|
|
|
|
switch (response.status) {
|
|
case 302:
|
|
return Result.err(
|
|
new Error(
|
|
"The registry indicated that the resource requested is available at a different location."
|
|
)
|
|
);
|
|
case 400:
|
|
return Result.err(
|
|
new Error(
|
|
"The registry indicated that the request was malformed or could not be processed. Check that you typed in the correct information and try again."
|
|
)
|
|
);
|
|
case 403:
|
|
return Result.err(
|
|
new Error(
|
|
"The registry indicated that the request was forbidden. This could be due to rate limiting, abusive behavior, or other reasons. Try again later or contact the registry for more information."
|
|
)
|
|
);
|
|
|
|
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)."
|
|
)
|
|
);
|
|
case 500:
|
|
return Result.err(
|
|
new Error(
|
|
"The registry indicated that an internal server error occurred. This could be due to a misconfiguration, a bug, or other reasons. Try again later or contact the registry for more information."
|
|
)
|
|
);
|
|
default:
|
|
return Result.err(
|
|
new Error(
|
|
`The registry did not return an OK status code: ${response.status}.`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
async function submitInternal(
|
|
target: string
|
|
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
|
|
if (target == null || target.length == 0)
|
|
return Result.err(
|
|
new Error("A target must be given in order to execute a lookup.")
|
|
);
|
|
|
|
const targetType = await getTypeEasy(target);
|
|
|
|
if (targetType.isErr) {
|
|
return Result.err(
|
|
new Error("Unable to determine type, unable to send query", {
|
|
cause: targetType.error,
|
|
})
|
|
);
|
|
}
|
|
|
|
switch (targetType.value) {
|
|
// Block scoped case to allow url const reuse
|
|
case "ip4": {
|
|
await loadBootstrap("ip4");
|
|
const url = getRegistryURL(targetType.value, target);
|
|
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
|
if (result.isErr) return Result.err(result.error);
|
|
return Result.ok({ data: result.value, url });
|
|
}
|
|
case "ip6": {
|
|
await loadBootstrap("ip6");
|
|
const url = getRegistryURL(targetType.value, target);
|
|
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema);
|
|
if (result.isErr) return Result.err(result.error);
|
|
return Result.ok({ data: result.value, url });
|
|
}
|
|
case "domain": {
|
|
await loadBootstrap("domain");
|
|
const url = getRegistryURL(targetType.value, target);
|
|
|
|
// HTTP
|
|
if (url.startsWith("http://") && url != repeatableRef.current) {
|
|
repeatableRef.current = url;
|
|
return Result.err(
|
|
new Error(
|
|
"The registry this domain belongs to uses HTTP, which is not secure. " +
|
|
"In order to prevent a cryptic error from appearing due to mixed active content, " +
|
|
"or worse, a CORS error, this lookup has been blocked. Try again to force the lookup."
|
|
)
|
|
);
|
|
}
|
|
const result = await getAndParse<Domain>(url, DomainSchema);
|
|
if (result.isErr) return Result.err(result.error);
|
|
|
|
return Result.ok({ data: result.value, url });
|
|
}
|
|
case "autnum": {
|
|
await loadBootstrap("autnum");
|
|
const url = getRegistryURL(targetType.value, target);
|
|
const result = await getAndParse<AutonomousNumber>(
|
|
url,
|
|
AutonomousNumberSchema
|
|
);
|
|
if (result.isErr) return Result.err(result.error);
|
|
return Result.ok({ data: result.value, url });
|
|
}
|
|
case "tld": {
|
|
// remove the leading dot
|
|
const value = target.startsWith(".") ? target.slice(1) : target;
|
|
const url = `https://root.rdap.org/domain/${value}`;
|
|
const result = await getAndParse<Domain>(url, DomainSchema);
|
|
if (result.isErr) return Result.err(result.error);
|
|
return Result.ok({ data: result.value, url });
|
|
}
|
|
case "url": {
|
|
const response = await fetch(target);
|
|
|
|
if (response.status != 200)
|
|
return Result.err(
|
|
new Error(
|
|
`The URL provided returned a non-200 status code: ${response.status}.`
|
|
)
|
|
);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const data = await response.json();
|
|
|
|
// Try each schema until one works
|
|
for (const schema of schemas) {
|
|
const result = schema.safeParse(data);
|
|
if (result.success)
|
|
return Result.ok({ data: result.data, url: target });
|
|
}
|
|
|
|
return Result.err(
|
|
new Error("No schema was able to parse the response.")
|
|
);
|
|
}
|
|
case "json": {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const data = JSON.parse(target);
|
|
for (const schema of schemas) {
|
|
const result = schema.safeParse(data);
|
|
if (result.success) return Result.ok({ data: result.data, url: "" });
|
|
}
|
|
}
|
|
case "registrar": {
|
|
}
|
|
default:
|
|
return Result.err(
|
|
new Error("The type detected has not been implemented.")
|
|
);
|
|
}
|
|
}
|
|
|
|
async function submit({
|
|
target,
|
|
}: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
|
|
try {
|
|
// target is already set in state, but it's also provided by the form callback, so we'll use it.
|
|
const response = await submitInternal(target);
|
|
|
|
if (response.isErr) {
|
|
setError(response.error.message);
|
|
console.error(response.error);
|
|
} else setError(null);
|
|
|
|
return response.isOk
|
|
? Maybe.just({
|
|
data: response.value.data,
|
|
url: response.value.url,
|
|
completeTime: new Date(),
|
|
})
|
|
: Maybe.nothing();
|
|
} catch (e) {
|
|
if (!(e instanceof Error))
|
|
setError("An unknown, unprocessable error has occurred.");
|
|
else setError(e.message);
|
|
console.error(e);
|
|
return Maybe.nothing();
|
|
}
|
|
}
|
|
|
|
return {
|
|
error,
|
|
setTarget,
|
|
setTargetType,
|
|
submit,
|
|
currentType: uriType,
|
|
getType: getTypeEasy,
|
|
};
|
|
};
|
|
|
|
export default useLookup;
|