Files
rdap/src/hooks/useLookup.tsx
Xevion 5fb095a498 test: add comprehensive testing infrastructure with critical bug fixes
- 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
2025-10-22 01:23:15 -05:00

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;