diff --git a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx index 9348120..fc9338b 100644 --- a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx +++ b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx @@ -10,7 +10,7 @@ import { Modal } from "@/client/components/Modal"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { captureClientEvent } from "@/client/lib/posthog"; import type { RankTrackingConfig } from "@/types/schemas/rank-tracking"; -import { normalizeDomain } from "@/types/schemas/domain"; +import { domainField, normalizeDomain } from "@/types/schemas/domain"; import { depthToPages, pagesToDepth, @@ -54,11 +54,11 @@ export function RankTrackingConfigModal({ const [createdConfigId, setCreatedConfigId] = useState(null); const createMutation = useMutation({ - mutationFn: () => + mutationFn: (normalizedDomain: string) => createRankTrackingConfig({ data: { projectId, - domain, + domain: normalizedDomain, devices, serpDepth, locationCode, @@ -79,12 +79,12 @@ export function RankTrackingConfigModal({ }); const updateMutation = useMutation({ - mutationFn: () => + mutationFn: (normalizedDomain: string) => updateRankTrackingConfig({ data: { projectId, configId: existingConfig!.id, - domain, + domain: normalizedDomain, devices, serpDepth, locationCode, @@ -109,10 +109,16 @@ export function RankTrackingConfigModal({ toast.error("Please enter a domain"); return; } + const parsedDomain = domainField.safeParse(domain); + if (!parsedDomain.success) { + toast.error("Please enter a valid domain"); + return; + } + setDomain(parsedDomain.data); if (isEdit) { - updateMutation.mutate(); + updateMutation.mutate(parsedDomain.data); } else { - createMutation.mutate(); + createMutation.mutate(parsedDomain.data); } }; diff --git a/src/server/lib/dataforseoBacklinks.test.ts b/src/server/lib/dataforseoBacklinks.test.ts index 2f4e1e0..1ca970b 100644 --- a/src/server/lib/dataforseoBacklinks.test.ts +++ b/src/server/lib/dataforseoBacklinks.test.ts @@ -105,6 +105,10 @@ describe("normalizeBacklinksTarget", () => { normalizeBacklinksTarget("https://user:pass@example.com/private"), ); }); + + it("rejects hostnames with unrecognized public suffixes before provider calls", () => { + expectValidationError(() => normalizeBacklinksTarget("example.invalidtld")); + }); }); describe("fetchBacklinksSummaryRaw", () => { diff --git a/src/server/lib/dataforseoBacklinksTarget.ts b/src/server/lib/dataforseoBacklinksTarget.ts index 6613cf2..3fcc452 100644 --- a/src/server/lib/dataforseoBacklinksTarget.ts +++ b/src/server/lib/dataforseoBacklinksTarget.ts @@ -1,5 +1,6 @@ import { AppError } from "@/server/lib/errors"; import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; +import { parse as parseTld } from "tldts"; type NormalizedBacklinkTarget = { apiTarget: string; @@ -47,6 +48,17 @@ export function normalizeBacklinksTarget( throw new AppError("VALIDATION_ERROR", "Target is invalid"); } + const parsedHostname = parseTld(domainHostname, { + allowPrivateDomains: true, + }); + if ( + parsedHostname.isIp || + !parsedHostname.publicSuffix || + (parsedHostname.isIcann !== true && parsedHostname.isPrivate !== true) + ) { + throw new AppError("VALIDATION_ERROR", "Target is invalid"); + } + if (parsed.username || parsed.password) { throw new AppError( "VALIDATION_ERROR", diff --git a/src/types/schemas/domain.ts b/src/types/schemas/domain.ts index cde4360..5eb66ac 100644 --- a/src/types/schemas/domain.ts +++ b/src/types/schemas/domain.ts @@ -136,8 +136,15 @@ export const domainPagesPageRequestSchema = z.object({ search: z.string().optional(), }); +const optionalSearchNumberParam = z.coerce.number().optional().catch(undefined); +const optionalSearchPositiveIntParam = z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined); const filterStringParam = z.string().optional(); -const filterNumberParam = z.coerce.number().optional(); +const filterNumberParam = optionalSearchNumberParam; export const domainSearchSchema = z.object({ domain: z.string().optional(), @@ -146,15 +153,16 @@ export const domainSearchSchema = z.object({ order: z.enum(domainSortOrders).optional(), tab: z.enum(domainTabs).optional(), search: z.string().optional(), - loc: z.coerce.number().int().positive().optional(), - page: z.coerce.number().int().positive().optional(), + loc: optionalSearchPositiveIntParam, + page: optionalSearchPositiveIntParam, size: z.coerce .number() .int() .refine((value) => (DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value), ) - .optional(), + .optional() + .catch(undefined), include: filterStringParam, exclude: filterStringParam, minTraffic: filterNumberParam, diff --git a/src/types/schemas/search-params.test.ts b/src/types/schemas/search-params.test.ts index 6770a96..33f35b9 100644 --- a/src/types/schemas/search-params.test.ts +++ b/src/types/schemas/search-params.test.ts @@ -24,4 +24,18 @@ describe("search param boolean parsing", () => { subdomains: false, }); }); + + it("drops invalid optional domain pagination params", () => { + const parsed = domainSearchSchema.parse({ + page: "0", + size: "25", + loc: "not-a-location", + }); + + expect(parsed).toEqual({ + page: undefined, + size: undefined, + loc: undefined, + }); + }); });