Skip to content
File

Blob: src/worker/validation.ts

typescript147 lines
1import { HttpError } from "@/worker/http";
2import { BranchName, type BranchName as BranchNameType } from "@/contracts";
3import { expectTrusted } from "@/worker/contracts";
4 
5const SLUG_PATTERN = /^[A-Za-z0-9_-]+$/u;
6const IPV4_HOST_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}$/u;
7const DNS_HOST_PATTERN = /^(?=.{1,253}$)(?!-)[A-Za-z0-9-]+(?:\.(?!-)[A-Za-z0-9-]+)+$/u;
8 
9export const assertValidSlug = (value: string, fieldName: string): void => {
10 if (!SLUG_PATTERN.test(value)) {
11 throw new HttpError(
12 400,
13 "invalid_slug",
14 `${fieldName} must use only alphanumeric, hyphen, or underscore characters.`,
15 );
16 }
17};
18 
19const assertNonEmptyTrimmedString = (value: string, fieldName: string): string => {
20 const trimmedValue = value.trim();
21 
22 if (trimmedValue.length === 0) {
23 throw new HttpError(400, "invalid_request", `${fieldName} cannot be empty.`);
24 }
25 
26 return trimmedValue;
27};
28 
29export const normalizeEmailAddress = (value: string): string =>
30 assertNonEmptyTrimmedString(value, "email").toLowerCase();
31 
32export const normalizeDisplayName = (value: string): string => assertNonEmptyTrimmedString(value, "displayName");
33 
34const isIpLiteralHost = (hostname: string): boolean => IPV4_HOST_PATTERN.test(hostname) || hostname.includes(":");
35 
36const normalizePublicHttpsUrl = (
37 value: string,
38 fieldName: string,
39 options: {
40 requirePath: boolean;
41 stripGitSuffix: boolean;
42 },
43): string => {
44 const rawValue = assertNonEmptyTrimmedString(value, fieldName);
45 let url: URL;
46 
47 try {
48 url = new URL(rawValue);
49 } catch (error) {
50 throw new HttpError(400, "invalid_request", `${fieldName} must be a valid HTTPS URL.`, error);
51 }
52 
53 if (url.protocol !== "https:") {
54 throw new HttpError(400, "invalid_request", `${fieldName} must use https://.`);
55 }
56 
57 if (url.username || url.password) {
58 throw new HttpError(400, "invalid_request", `${fieldName} cannot include embedded credentials.`);
59 }
60 
61 if (url.search || url.hash) {
62 throw new HttpError(400, "invalid_request", `${fieldName} cannot include a query string or fragment.`);
63 }
64 
65 const hostname = url.hostname.toLowerCase();
66 if (
67 hostname === "localhost" ||
68 hostname.endsWith(".localhost") ||
69 hostname === "127.0.0.1" ||
70 hostname === "::1" ||
71 isIpLiteralHost(hostname) ||
72 !DNS_HOST_PATTERN.test(hostname)
73 ) {
74 throw new HttpError(400, "invalid_request", `${fieldName} must use a public DNS hostname.`);
75 }
76 
77 let normalizedPathname = url.pathname.replace(/\/+$/u, "");
78 if (options.stripGitSuffix && normalizedPathname.endsWith(".git")) {
79 normalizedPathname = normalizedPathname.slice(0, -4);
80 }
81 
82 if (options.requirePath && normalizedPathname.length === 0) {
83 throw new HttpError(400, "invalid_request", `${fieldName} must include a path.`);
84 }
85 
86 const host = url.port.length > 0 && url.port !== "443" ? `${hostname}:${url.port}` : hostname;
87 return `https://${host}${normalizedPathname}`;
88};
89 
90export const normalizeRepositoryUrl = (value: string): string => {
91 try {
92 return normalizePublicHttpsUrl(value, "repoUrl", {
93 requirePath: true,
94 stripGitSuffix: true,
95 });
96 } catch (error) {
97 if (error instanceof HttpError && error.code === "invalid_request") {
98 throw new HttpError(400, "invalid_repo_url", error.message, error.details);
99 }
100 
101 throw error;
102 }
103};
104 
105export const normalizeWebhookInstanceUrl = (value: string): string => {
106 try {
107 return normalizePublicHttpsUrl(value, "instanceUrl", {
108 requirePath: false,
109 stripGitSuffix: false,
110 });
111 } catch (error) {
112 if (error instanceof HttpError && error.code === "invalid_request") {
113 throw new HttpError(400, "invalid_instance_url", error.message, error.details);
114 }
115 
116 throw error;
117 }
118};
119 
120export const normalizeConfigPath = (value: string): string => {
121 const rawValue = assertNonEmptyTrimmedString(value, "configPath");
122 
123 if (rawValue.startsWith("/")) {
124 throw new HttpError(400, "invalid_config_path", "configPath must be repo-relative.");
125 }
126 
127 if (rawValue.includes("\\")) {
128 throw new HttpError(400, "invalid_config_path", "configPath must use forward slashes.");
129 }
130 
131 const segments = rawValue.split("/");
132 if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
133 throw new HttpError(
134 400,
135 "invalid_config_path",
136 "configPath must be a normalized repo-relative path without traversal.",
137 );
138 }
139 
140 return rawValue;
141};
142 
143export const normalizeProjectName = (value: string): string => assertNonEmptyTrimmedString(value, "name");
144 
145export const normalizeBranchName = (value: string): BranchNameType =>
146 expectTrusted(BranchName, assertNonEmptyTrimmedString(value, "defaultBranch"), "BranchName");