fix: address code quality issues and critical bugs

- Fix getBestURL HTTPS prioritization bug (critical security issue)
- Standardize equality operators to === for status code checks
- Add JSON parsing error handling with try-catch
- Improve IP address validation with explicit error messages
- Fix ARIA label accessibility (add id="search" to TextField)
- Remove unused callback hook in useLookup
- Add type detection debouncing (150ms) using @mantine/hooks
- Remove duplicate LookupInput component from src/components/form/
This commit is contained in:
2025-10-22 16:44:12 -05:00
parent 3ff347b81f
commit 99b65363b4
9 changed files with 50 additions and 282 deletions

View File

@@ -21,6 +21,7 @@
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@mantine/hooks": "^8.3.5",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/themes": "^3.2.1",
"@swc/helpers": "^0.5.11",

12
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@fontsource/ibm-plex-mono':
specifier: ^5.2.7
version: 5.2.7
'@mantine/hooks':
specifier: ^8.3.5
version: 8.3.5(react@19.2.0)
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.2.0)
@@ -602,6 +605,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mantine/hooks@8.3.5':
resolution: {integrity: sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==}
peerDependencies:
react: ^18.x || ^19.x
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -4349,6 +4357,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mantine/hooks@8.3.5(react@19.2.0)':
dependencies:
react: 19.2.0
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.6.0

View File

@@ -1,246 +0,0 @@
import { useForm, Controller } from "react-hook-form";
import type { FunctionComponent } from "react";
import { useState } from "react";
import { onPromise, preventDefault } from "@/lib/utils";
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, Checkbox, Text, IconButton } from "@radix-ui/themes";
import type { Maybe } from "true-myth";
import { placeholders } from "@/rdap/constants";
/**
* Props for the LookupInput component.
*/
type LookupInputProps = {
isLoading?: boolean;
/**
* Callback function called when a type of registry is detected when a user changes their input.
* @param type - The detected type of registry.
* @returns A promise.
*/
onRegistry?: (type: TargetType) => Promise<void>;
/**
* Callback function called when a user hits submit.
* @param props - The submit props.
* @returns A promise.
*/
onSubmit?: (props: SubmitProps) => Promise<void>;
/**
* Callback function called when a user changes their input (text search) or explicitly changes the type of search.
* @param target - The target object containing the search target and target type.
* @returns Nothing.
*/
onChange?: (target: { target: string; targetType: TargetType | null }) => Promise<void>;
detectedType: Maybe<TargetType>;
};
const LookupInput: FunctionComponent<LookupInputProps> = ({
isLoading,
onSubmit,
onChange,
detectedType,
}: LookupInputProps) => {
const { register, handleSubmit, getValues, control } = useForm<SubmitProps>({
defaultValues: {
target: "",
// Not used at this time.
followReferral: false,
requestJSContact: false,
},
});
/**
* A mapping of available (simple) target types to their long-form human-readable names.
*/
const objectNames: Record<SimplifiedTargetType | "auto", string> = {
auto: "Autodetect",
domain: "Domain",
ip: "IP/CIDR", // IPv4/IPv6 are combined into this option
tld: "TLD",
autnum: "AS Number",
entity: "Entity Handle",
registrar: "Registrar",
url: "URL",
json: "JSON",
};
/**
* Mapping of precise target types to their simplified short-form names.
*/
const targetShortNames: Record<TargetType, string> = {
domain: "Domain",
tld: "TLD",
ip4: "IPv4",
ip6: "IPv6",
autnum: "ASN",
entity: "Entity",
registrar: "Registrar",
url: "URL",
json: "JSON",
};
/**
* Represents the selected value in the LookupInput component.
*/
const [selected, setSelected] = useState<SimplifiedTargetType | "auto">("auto");
/**
* Retrieves the target type based on the provided value.
* @param value - The value to retrieve the target type for.
* @returns The target type as ObjectType or null.
*/
function retrieveTargetType(value?: string | null): TargetType | null {
// If the value is null and the selected value is null, return null.
if (value == null) value = selected;
// 'auto' means 'do whatever' so we return null.
if (value == "auto") return null;
// Validate the value is a valid TargetType
const result = TargetTypeEnum.safeParse(value);
return result.success ? result.data : null;
}
return (
<form
className="pb-2.5"
onSubmit={onSubmit != undefined ? onPromise(handleSubmit(onSubmit)) : preventDefault}
>
<Flex direction="column" gap="3">
<label htmlFor="search" className="sr-only">
Search
</label>
<Flex gap="0" style={{ position: "relative" }}>
<TextField.Root
size="3"
placeholder={placeholders[selected]}
disabled={isLoading}
{...register("target", {
required: true,
onChange: () => {
if (onChange != undefined)
void onChange({
target: getValues("target"),
targetType: retrieveTargetType(null),
});
},
})}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
border: "1px solid var(--gray-7)",
borderRight: "none",
boxShadow: "none",
flex: 1,
}}
>
<TextField.Slot side="left">
<IconButton
size="1"
variant="ghost"
type="submit"
disabled={isLoading}
tabIndex={-1}
style={{ cursor: isLoading ? "not-allowed" : "pointer" }}
>
{isLoading ? (
<ReloadIcon className="animate-spin" width="16" height="16" />
) : (
<MagnifyingGlassIcon width="16" height="16" />
)}
</IconButton>
</TextField.Slot>
</TextField.Root>
<Select.Root
value={selected}
onValueChange={(value) => {
setSelected(value as SimplifiedTargetType | "auto");
if (onChange != undefined)
void onChange({
target: getValues("target"),
targetType: retrieveTargetType(value),
});
}}
disabled={isLoading}
size="3"
>
<Select.Trigger
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
minWidth: "150px",
}}
>
{selected == "auto" ? (
detectedType.isJust ? (
<Text>Auto ({targetShortNames[detectedType.value]})</Text>
) : (
objectNames["auto"]
)
) : (
<Flex align="center" gap="2">
<LockClosedIcon width="14" height="14" />
{objectNames[selected]}
</Flex>
)}
</Select.Trigger>
<Select.Content position="popper">
{Object.entries(objectNames).map(([key, value]) => (
<Select.Item key={key} value={key}>
<Flex
align="center"
justify="between"
gap="2"
style={{ width: "100%" }}
>
{value}
</Flex>
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Flex>
<Flex pl="3" gapX="5" gapY="2" wrap="wrap">
<Flex asChild align="center" gap="2">
<Text as="label" size="2">
<Controller
name="requestJSContact"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
Request JSContact
</Text>
</Flex>
<Flex asChild align="center" gap="2">
<Text as="label" size="2">
<Controller
name="followReferral"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
Follow referral to registrar&apos;s RDAP record
</Text>
</Flex>
</Flex>
</Flex>
</form>
);
};
export default LookupInput;

View File

@@ -8,12 +8,10 @@ import LookupInput from "@/rdap/components/LookupInput";
import ErrorCard from "@/components/ErrorCard";
import { ThemeToggle } from "@/components/ThemeToggle";
import { Maybe } from "true-myth";
import type { TargetType } from "@/rdap/schemas";
import { Flex, Container, Section, Text, Link } from "@radix-ui/themes";
const Index: NextPage = () => {
const { error, setTarget, setTargetType, submit, getType } = useLookup();
const [detectedType, setDetectedType] = useState<Maybe<TargetType>>(Maybe.nothing());
const { error, setTarget, setTargetType, submit, currentType } = useLookup();
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(Maybe.nothing());
const [isLoading, setLoading] = useState<boolean>(false);
@@ -65,20 +63,10 @@ const Index: NextPage = () => {
<Section size="2">
<LookupInput
isLoading={isLoading}
detectedType={detectedType}
onChange={async ({ target, targetType }) => {
detectedType={currentType}
onChange={({ target, targetType }) => {
setTarget(target);
setTargetType(targetType);
// Only run autodetection when in autodetect mode (targetType is null)
if (targetType === null) {
const detectResult = await getType(target);
if (detectResult.isOk) {
setDetectedType(Maybe.just(detectResult.value));
} else {
setDetectedType(Maybe.nothing());
}
}
}}
onSubmit={async function (props) {
try {

View File

@@ -31,7 +31,7 @@ type LookupInputProps = {
* @param target - The target object containing the search target and target type.
* @returns Nothing.
*/
onChange?: (target: { target: string; targetType: TargetType | null }) => Promise<void>;
onChange?: (target: { target: string; targetType: TargetType | null }) => void | Promise<void>;
detectedType: Maybe<TargetType>;
};
@@ -113,6 +113,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
</label>
<Flex gap="0" style={{ position: "relative" }}>
<TextField.Root
id="search"
size="3"
placeholder={placeholders[selected]}
disabled={isLoading}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { getType, validateInputForType } from "@/rdap/utils";
import type { AutonomousNumber, Domain, IpNetwork, SubmitProps, TargetType } from "@/rdap/schemas";
import {
@@ -27,6 +28,7 @@ const schemas = [DomainSchema, AutonomousNumberSchema, IpNetworkSchema];
const useLookup = (warningHandler?: WarningHandler) => {
const [error, setError] = useState<string | null>(null);
const [target, setTarget] = useState<string>("");
const [debouncedTarget] = useDebouncedValue(target, 150);
const [uriType, setUriType] = useState<Maybe<TargetType>>(Maybe.nothing());
// Used by a callback on LookupInput to forcibly set the type of the lookup.
@@ -39,13 +41,20 @@ const useLookup = (warningHandler?: WarningHandler) => {
return getType(target, getRegistry);
}, []);
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]);
useEffect(() => {
const detectType = async () => {
if (currentType != null || debouncedTarget.length === 0) return;
const detectedType = await getTypeEasy(debouncedTarget);
if (detectedType.isOk) {
setUriType(Maybe.just(detectedType.value));
} else {
setUriType(Maybe.nothing());
}
};
detectType().catch(console.error);
}, [debouncedTarget, currentType, getTypeEasy]);
useEffect(() => {
const preload = async () => {
@@ -67,7 +76,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
};
preload().catch(console.error);
}, [target, uriType, warningHandler]);
}, [uriType, warningHandler]);
async function submitInternal(
target: string
@@ -160,7 +169,7 @@ const useLookup = (warningHandler?: WarningHandler) => {
case "url": {
const response = await fetch(target);
if (response.status != 200)
if (response.status !== 200)
return Result.err(
new Error(
`The URL provided returned a non-200 status code: ${response.status}.`
@@ -178,10 +187,14 @@ const useLookup = (warningHandler?: WarningHandler) => {
return Result.err(new Error("No schema was able to parse the response."));
}
case "json": {
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: "" });
try {
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: "" });
}
} catch (e) {
return Result.err(new Error("Invalid JSON format", { cause: e }));
}
}
case "registrar": {

View File

@@ -7,7 +7,7 @@ import { Result } from "true-myth";
export async function getAndParse<T>(url: string, schema: ZodSchema<T>): Promise<Result<T, Error>> {
const response = await fetch(url);
if (response.status == 200) {
if (response.status === 200) {
const result = schema.safeParse(await response.json());
if (result.success === false) {

View File

@@ -25,7 +25,8 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st
throw new Error(`No matching domain found.`);
case "ip4": {
// Extract the IP address without CIDR suffix for matching
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
const [ipAddress] = lookupTarget.split("/");
if (!ipAddress) throw new Error(`Invalid IPv4 format: ${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))) {
@@ -37,7 +38,8 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st
}
case "ip6": {
// Extract the IP address without CIDR suffix for matching
const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget;
const [ipAddress] = lookupTarget.split("/");
if (!ipAddress) throw new Error(`Invalid IPv6 format: ${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))) {

View File

@@ -11,10 +11,7 @@ export function domainMatch(tld: string, domain: string): boolean {
// return the first HTTPS url, or the first URL
export function getBestURL(urls: [string, ...string[]]): string {
urls.forEach((url) => {
if (url.startsWith("https://")) return url;
});
return urls[0];
return urls.find((url) => url.startsWith("https://")) ?? urls[0];
}
type ValidatorArgs = {