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:
parent
82d6363ee9
commit
ae2031bfad
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user