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 { 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<string | null>(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);
}
};

View File

@ -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", () => {

View File

@ -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",

View File

@ -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,

View File

@ -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,
});
});
});