Harden domain and backlinks validation before external calls (#183)

* Validate backlinks targets before calling DataForSEO

* Validate backlinks targets before provider calls
This commit is contained in:
Ben Senescu 2026-05-12 19:15:51 -04:00 committed by GitHub
parent 82d6363ee9
commit ae2031bfad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 55 additions and 11 deletions

View File

@ -10,7 +10,7 @@ import { Modal } from "@/client/components/Modal";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking"; import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
import { normalizeDomain } from "@/types/schemas/domain"; import { domainField, normalizeDomain } from "@/types/schemas/domain";
import { import {
depthToPages, depthToPages,
pagesToDepth, pagesToDepth,
@ -54,11 +54,11 @@ export function RankTrackingConfigModal({
const [createdConfigId, setCreatedConfigId] = useState<string | null>(null); const [createdConfigId, setCreatedConfigId] = useState<string | null>(null);
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => mutationFn: (normalizedDomain: string) =>
createRankTrackingConfig({ createRankTrackingConfig({
data: { data: {
projectId, projectId,
domain, domain: normalizedDomain,
devices, devices,
serpDepth, serpDepth,
locationCode, locationCode,
@ -79,12 +79,12 @@ export function RankTrackingConfigModal({
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: () => mutationFn: (normalizedDomain: string) =>
updateRankTrackingConfig({ updateRankTrackingConfig({
data: { data: {
projectId, projectId,
configId: existingConfig!.id, configId: existingConfig!.id,
domain, domain: normalizedDomain,
devices, devices,
serpDepth, serpDepth,
locationCode, locationCode,
@ -109,10 +109,16 @@ export function RankTrackingConfigModal({
toast.error("Please enter a domain"); toast.error("Please enter a domain");
return; return;
} }
const parsedDomain = domainField.safeParse(domain);
if (!parsedDomain.success) {
toast.error("Please enter a valid domain");
return;
}
setDomain(parsedDomain.data);
if (isEdit) { if (isEdit) {
updateMutation.mutate(); updateMutation.mutate(parsedDomain.data);
} else { } else {
createMutation.mutate(); createMutation.mutate(parsedDomain.data);
} }
}; };

View File

@ -105,6 +105,10 @@ describe("normalizeBacklinksTarget", () => {
normalizeBacklinksTarget("https://user:pass@example.com/private"), normalizeBacklinksTarget("https://user:pass@example.com/private"),
); );
}); });
it("rejects hostnames with unrecognized public suffixes before provider calls", () => {
expectValidationError(() => normalizeBacklinksTarget("example.invalidtld"));
});
}); });
describe("fetchBacklinksSummaryRaw", () => { describe("fetchBacklinksSummaryRaw", () => {

View File

@ -1,5 +1,6 @@
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
import { parse as parseTld } from "tldts";
type NormalizedBacklinkTarget = { type NormalizedBacklinkTarget = {
apiTarget: string; apiTarget: string;
@ -47,6 +48,17 @@ export function normalizeBacklinksTarget(
throw new AppError("VALIDATION_ERROR", "Target is invalid"); 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) { if (parsed.username || parsed.password) {
throw new AppError( throw new AppError(
"VALIDATION_ERROR", "VALIDATION_ERROR",

View File

@ -136,8 +136,15 @@ export const domainPagesPageRequestSchema = z.object({
search: z.string().optional(), 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 filterStringParam = z.string().optional();
const filterNumberParam = z.coerce.number().optional(); const filterNumberParam = optionalSearchNumberParam;
export const domainSearchSchema = z.object({ export const domainSearchSchema = z.object({
domain: z.string().optional(), domain: z.string().optional(),
@ -146,15 +153,16 @@ export const domainSearchSchema = z.object({
order: z.enum(domainSortOrders).optional(), order: z.enum(domainSortOrders).optional(),
tab: z.enum(domainTabs).optional(), tab: z.enum(domainTabs).optional(),
search: z.string().optional(), search: z.string().optional(),
loc: z.coerce.number().int().positive().optional(), loc: optionalSearchPositiveIntParam,
page: z.coerce.number().int().positive().optional(), page: optionalSearchPositiveIntParam,
size: z.coerce size: z.coerce
.number() .number()
.int() .int()
.refine((value) => .refine((value) =>
(DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value), (DOMAIN_KEYWORDS_PAGE_SIZES as readonly number[]).includes(value),
) )
.optional(), .optional()
.catch(undefined),
include: filterStringParam, include: filterStringParam,
exclude: filterStringParam, exclude: filterStringParam,
minTraffic: filterNumberParam, minTraffic: filterNumberParam,

View File

@ -24,4 +24,18 @@ describe("search param boolean parsing", () => {
subdomains: false, 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,
});
});
}); });