rea/src/domain/browserObservation.ts

222 lines
6.9 KiB
TypeScript

import { z } from "zod";
const parseExactOrigin = (value: string): string | undefined => {
let url: URL;
try {
url = new URL(value);
} catch (cause: unknown) {
// Invalid input is represented by the undefined return.
void cause;
return undefined;
}
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.hostname.includes("*") ||
url.username !== "" ||
url.password !== "" ||
url.pathname !== "/" ||
url.search !== "" ||
url.hash !== ""
)
return undefined;
return url.origin;
};
/** Exact normalized HTTP(S) authority used for browser observation scope. */
export const browserOriginSchema = z
.string()
.min(1)
.transform((value, context) => {
const origin = parseExactOrigin(value);
if (origin === undefined) {
context.addIssue({
code: "custom",
message: "Expected one exact HTTP(S) origin without a path or wildcard",
});
return z.NEVER;
}
return origin;
});
/** Recognize the URL API's bracketed IPv6 form and normalized bare literals. */
export const isLiteralLoopbackHostname = (hostname: string): boolean =>
hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
/** Loopback-only HTTP endpoint accepted for a user-owned CDP browser. */
export const browserEndpointSchema = z
.string()
.min(1)
.transform((value, context) => {
let url: URL;
try {
url = new URL(value);
} catch (cause: unknown) {
// Invalid input is reported through the zod issue.
void cause;
context.addIssue({ code: "custom", message: "Invalid CDP endpoint URL" });
return z.NEVER;
}
if (
url.protocol !== "http:" ||
!isLiteralLoopbackHostname(url.hostname) ||
url.port === "" ||
url.username !== "" ||
url.password !== "" ||
url.pathname !== "/" ||
url.search !== "" ||
url.hash !== ""
) {
context.addIssue({
code: "custom",
message:
"CDP endpoint must be an explicit-port HTTP URL on 127.0.0.1 or ::1",
});
return z.NEVER;
}
return url.origin;
});
export const browserAllowedOriginsSchema = z
.array(browserOriginSchema)
.transform((origins) => Array.from(new Set(origins)).sort())
.default([]);
const browserInput = {
cdp_endpoint: browserEndpointSchema,
allowed_origins: browserAllowedOriginsSchema,
};
/** Public input for complete discovery, optionally filtered by exact origin. */
export const listBrowserTargetsInputSchema = z.strictObject({
...browserInput,
});
const inspectWebPageInputFacts = {
...browserInput,
target_id: z.string().trim().min(1),
observation_ms: z.number().int().min(0).default(500),
include_accessibility_text: z.boolean().default(false),
include_console_text: z.boolean().default(false),
include_json_body_shapes: z.boolean().default(false),
include_websocket_shapes: z.boolean().default(false),
include_storage_keys: z.boolean().default(false),
include_storage_fingerprints: z.boolean().default(false),
} as const;
const inspectWebPageWithoutSourceSchema = z.strictObject({
...inspectWebPageInputFacts,
include_script_sources: z.literal(false).default(false),
});
const inspectWebPageWithSourceShapeSchema = z.strictObject({
...inspectWebPageInputFacts,
include_script_sources: z.literal(true).default(true),
});
type InspectWebPageShape =
| z.output<typeof inspectWebPageWithoutSourceSchema>
| z.output<typeof inspectWebPageWithSourceShapeSchema>;
const refineInspectWebPageInput = (
input: InspectWebPageShape,
context: z.RefinementCtx,
): void => {
if (input.include_storage_fingerprints && !input.include_storage_keys)
context.addIssue({
code: "custom",
path: ["include_storage_fingerprints"],
message: "Storage fingerprints require storage key capture",
});
};
/** Caller schema for source-capturing inspection and bundle analysis. */
export const inspectWebPageWithSourceInputSchema =
inspectWebPageWithSourceShapeSchema.superRefine(refineInspectWebPageInput);
/** Caller-visible schema for one passive inspection. */
export const inspectWebPageInputSchema = z.union([
inspectWebPageWithoutSourceSchema.superRefine(refineInspectWebPageInput),
inspectWebPageWithSourceInputSchema,
]);
export type ListBrowserTargetsInput = z.infer<
typeof listBrowserTargetsInputSchema
>;
export type InspectWebPageInput = z.infer<typeof inspectWebPageInputSchema>;
export const sanitizedBrowserUrlSchema = z.object({
url: z.string(),
origin: z.string().nullable(),
query_parameter_names: z.array(z.string()),
redacted: z.boolean(),
});
export type SanitizedBrowserUrl = z.infer<typeof sanitizedBrowserUrlSchema>;
/** Preserve local URL data while removing only URL userinfo credentials. */
export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => {
let parsed: URL;
try {
parsed = new URL(value);
} catch (cause: unknown) {
// Unparseable input is preserved verbatim in the sanitized result.
void cause;
return {
url: value,
origin: null,
query_parameter_names: [],
redacted: false,
};
}
const hadCredentials = parsed.username !== "" || parsed.password !== "";
const names = [...parsed.searchParams.keys()];
const sanitizedUrl = hadCredentials
? removeUrlUserInfo(value, parsed)
: value;
return {
url: sanitizedUrl,
origin: parsed.origin === "null" ? null : parsed.origin,
query_parameter_names: names,
redacted: hadCredentials,
};
};
const removeUrlUserInfo = (value: string, parsed: URL): string => {
const schemeSeparator = value.indexOf("://");
const authorityStart =
schemeSeparator < 0
? value.startsWith("//")
? 2
: -1
: schemeSeparator + 3;
if (authorityStart < 0) return credentialFreeHref(parsed);
const remaining = value.slice(authorityStart);
const delimiter = remaining.search(/[/?#]/u);
const authorityEnd =
delimiter < 0 ? value.length : authorityStart + delimiter;
const authority = value.slice(authorityStart, authorityEnd);
const userInfoEnd = authority.lastIndexOf("@");
if (userInfoEnd < 0) return credentialFreeHref(parsed);
const candidate = `${value.slice(0, authorityStart)}${authority.slice(userInfoEnd + 1)}${value.slice(authorityEnd)}`;
try {
const candidateUrl = new URL(candidate);
return candidateUrl.username === "" && candidateUrl.password === ""
? candidate
: credentialFreeHref(parsed);
} catch (cause: unknown) {
// Fallback to the already-sanitized href when the candidate is invalid.
void cause;
return credentialFreeHref(parsed);
}
};
const credentialFreeHref = (parsed: URL): string => {
parsed.username = "";
parsed.password = "";
return parsed.href;
};
/** Normalize endpoint candidates without discarding local query or fragment data. */
export const sanitizeEndpointCandidate = (value: string): string => {
return sanitizeBrowserUrl(value).url;
};