import { z } from "zod";
import { sanitizeHtml } from "./sanitize";
import { lookup } from "dns";
import { promisify } from "util";
const dnsLookup = promisify(lookup);
/**
* Check if an IPv4 address falls within a CIDR range
*/
function isIPv4InCidr(ip: string, cidr: string): boolean {
const [range, bits] = cidr.split("/");
const mask = ~((1 << (32 - parseInt(bits))) - 1);
const ipNum = ipToNumber(ip);
const rangeNum = ipToNumber(range);
if (ipNum === null || rangeNum === null) return false;
return (ipNum & mask) === (rangeNum & mask);
}
/**
* Check if an IPv6 address falls within a CIDR range
*/
function isIPv6InCidr(ip: string, cidr: string): boolean {
const [range, bits] = cidr.split("/");
const prefixLen = parseInt(bits);
const ipBuf = ipv6ToBuffer(ip);
const rangeBuf = ipv6ToBuffer(range);
if (!ipBuf || !rangeBuf) return false;
const fullBytes = Math.floor(prefixLen / 8);
const remainingBits = prefixLen % 8;
for (let i = 0; i < fullBytes; i++) {
if (ipBuf[i] !== rangeBuf[i]) return false;
}
if (remainingBits > 0) {
const mask = ~((1 << (8 - remainingBits)) - 1) & 0xff;
if ((ipBuf[fullBytes] & mask) !== (rangeBuf[fullBytes] & mask)) return false;
}
return true;
}
function ipToNumber(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) return null;
return parts.reduce((acc, part) => (acc << 8) + parseInt(part), 0) >>> 0;
}
function ipv6ToBuffer(ip: string): Buffer | null {
try {
// Expand compressed IPv6
const sections = ip.split(":");
if (sections.length > 8) return null;
// Handle :: expansion
const fullSections = [];
let foundDoubleColon = false;
for (const s of sections) {
if (s === "") {
if (foundDoubleColon) return null;
foundDoubleColon = true;
const remaining = 8 - sections.length + 1;
for (let i = 0; i < remaining; i++) fullSections.push("0000");
} else {
fullSections.push(s.padStart(4, "0"));
}
}
while (fullSections.length < 8) fullSections.push("0000");
const hex = fullSections.join("");
return Buffer.from(hex, "hex");
} catch {
return null;
}
}
/**
* Resolve hostname and check if the resolved IP is private/reserved
*/
async function isPrivateOrReservedIP(hostname: string): Promise {
// Always block these hostnames before DNS resolution
const alwaysBlockedHostnames = [
"localhost",
"127.0.0.1",
"::1",
"metadata.google.internal",
];
if (alwaysBlockedHostnames.includes(hostname.toLowerCase())) return true;
try {
const { address } = await dnsLookup(hostname);
const ip = address.toLowerCase();
// Block 0.0.0.0
if (ip === "0.0.0.0") return true;
// Block 169.254.0.0/16 (link-local)
if (ip.startsWith("169.254.")) return true;
// Block 10.0.0.0/8 (RFC 1918)
if (ip.startsWith("10.")) return true;
// Block 172.16.0.0/12 (RFC 1918)
if (isIPv4InCidr(ip, "172.16.0.0/12")) return true;
// Block 192.168.0.0/16 (RFC 1918)
if (ip.startsWith("192.168.")) return true;
// Block 127.0.0.0/8 (loopback)
if (ip.startsWith("127.")) return true;
// IPv6 private/reserved ranges
if (ip.includes(":")) {
// fc00::/7 (unique local addresses)
if (isIPv6InCidr(ip, "fc00::/7")) return true;
// fe80::/10 (link-local)
if (isIPv6InCidr(ip, "fe80::/10")) return true;
// ::1 (loopback)
if (ip === "::1") return true;
// ::0 (unspecified)
if (ip === "::") return true;
}
} catch {
// DNS resolution failed — reject to be safe
return true;
}
return false;
}
/**
* Validation Schemas for OpenCodeHub APIs
* These schemas prevent injection attacks and ensure data integrity
*/
// Branch Protection
export const BranchProtectionSchema = z.object({
pattern: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9\/*_-]+$/, "Invalid branch pattern"),
active: z.boolean().optional().default(true),
requiresPr: z.boolean().optional().default(false),
requiredApprovals: z.number().int().min(0).max(10).optional().default(1),
dismissStaleReviews: z.boolean().optional().default(false),
requireCodeOwnerReviews: z.boolean().optional().default(false),
allowForcePushes: z.boolean().optional().default(false),
});
// Repository Creation
export const CreateRepositorySchema = z.object({
name: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, "Invalid repository name"),
description: z.string().max(500).optional(),
visibility: z.enum(["public", "private", "internal"]).default("public"),
defaultBranch: z.string().min(1).max(255).default("main"),
hasIssues: z.boolean().optional().default(true),
hasWiki: z.boolean().optional().default(true),
hasActions: z.boolean().optional().default(true),
allowForking: z.boolean().optional().default(true),
});
// User Registration
export const RegisterUserSchema = z.object({
username: z
.string()
.min(3)
.max(39)
.regex(/^[a-zA-Z0-9_-]+$/, "Invalid username"),
email: z.string().email("Invalid email address"),
password: z.string().min(8).max(100),
displayName: z.string().max(100).optional(),
});
// Issue Creation
export const CreateIssueSchema = z.object({
title: z.string().min(1).max(500),
body: z.string().max(65535).transform(sanitizeHtml).optional(),
});
// Pull Request Creation
export const CreatePullRequestSchema = z.object({
title: z.string().min(1).max(500),
body: z.string().max(65535).transform(sanitizeHtml).optional(),
head: z.string().min(1).max(255),
base: z.string().min(1).max(255),
});
// Storage Configuration
export const StorageConfigSchema = z.object({
type: z.enum(["local", "s3"]),
basePath: z.string().min(1).max(1000).optional(),
bucket: z.string().max(255).optional(),
region: z.string().max(100).optional(),
endpoint: z.string().url().optional().or(z.literal("")),
accessKeyId: z.string().max(255).optional(),
secretAccessKey: z.string().max(255).optional(),
});
// General Config
export const GeneralConfigSchema = z.object({
siteName: z.string().min(1).max(100).optional(),
siteDescription: z.string().max(500).optional(),
allowSignups: z.boolean().optional(),
smtpHost: z.string().max(255).optional(),
smtpPort: z.number().int().min(1).max(65535).optional(),
smtpUser: z.string().max(255).optional(),
smtpPass: z.string().max(255).optional(),
smtpFrom: z.string().email().optional(),
});
// Webhook Configuration
export const WebhookConfigSchema = z.object({
url: z
.string()
.url()
.refine(
async (url) => {
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) return false;
const h = parsed.hostname.toLowerCase();
// Check if the resolved IP is private/reserved via DNS lookup
return !(await isPrivateOrReservedIP(h));
} catch {
return false;
}
},
{
message:
"Webhook URL must use HTTP/HTTPS and must not target private/reserved addresses",
},
),
events: z.array(z.string()).min(1),
secret: z.string().max(255).optional(),
active: z.boolean().optional().default(true),
});