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