From f61d442989fc758f4b5cfbbc5ee07e02962290d7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 09:51:17 -0500 Subject: [PATCH 1/9] Allow . in path; resolves #199 --- server/lib/validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 0aa590e6..f4be6277 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -35,7 +35,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean { } // Check for invalid characters - if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) { + if (!/^[a-zA-Z0-9_.*-]*$/.test(segment)) { return false; } } From 4c1366ef91ce101b0c5aaff33d0b44f50942b642 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 12:27:03 -0500 Subject: [PATCH 2/9] force router refresh on save closes #198 --- .../settings/resources/[resourceId]/authentication/page.tsx | 6 ++++++ .../settings/resources/[resourceId]/connectivity/page.tsx | 4 ++++ .../[orgId]/settings/resources/[resourceId]/rules/page.tsx | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 0e3dc7bc..df5376f9 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -49,6 +49,7 @@ import { } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { useRouter } from "next/navigation"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -82,6 +83,7 @@ export default function ResourceAuthenticationPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); + const router = useRouter(); const [pageLoading, setPageLoading] = useState(true); @@ -236,6 +238,7 @@ export default function ResourceAuthenticationPage() { title: "Saved successfully", description: "Whitelist settings have been saved" }); + router.refresh(); } catch (e) { console.error(e); toast({ @@ -283,6 +286,7 @@ export default function ResourceAuthenticationPage() { title: "Saved successfully", description: "Authentication settings have been saved" }); + router.refresh(); } catch (e) { console.error(e); toast({ @@ -314,6 +318,7 @@ export default function ResourceAuthenticationPage() { updateAuthInfo({ password: false }); + router.refresh(); }) .catch((e) => { toast({ @@ -344,6 +349,7 @@ export default function ResourceAuthenticationPage() { updateAuthInfo({ pincode: false }); + router.refresh(); }) .catch((e) => { toast({ diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index dfd2f66c..ea67e23e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -64,6 +64,7 @@ import { import { SwitchInput } from "@app/components/SwitchInput"; import { useSiteContext } from "@app/hooks/useSiteContext"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { useRouter } from "next/navigation"; // Regular expressions for validation const DOMAIN_REGEX = @@ -125,6 +126,7 @@ export default function ReverseProxyTargets(props: { const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); + const router = useRouter(); const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), @@ -299,6 +301,7 @@ export default function ReverseProxyTargets(props: { }); setTargetsToRemove([]); + router.refresh(); } catch (err) { console.error(err); toast({ @@ -339,6 +342,7 @@ export default function ReverseProxyTargets(props: { title: "SSL Configuration", description: "SSL configuration updated successfully" }); + router.refresh(); } } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 7fc16b81..1b9eb6ca 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -71,6 +71,7 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { Switch } from "@app/components/ui/switch"; +import { useRouter } from "next/navigation"; // Schema for rule validation const addRuleSchema = z.object({ @@ -107,6 +108,7 @@ export default function ResourceRules(props: { const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); + const router = useRouter(); const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), @@ -253,6 +255,7 @@ export default function ResourceRules(props: { title: "Enable Rules", description: "Rule evaluation has been updated" }); + router.refresh(); } } @@ -370,6 +373,7 @@ export default function ResourceRules(props: { }); setRulesToRemove([]); + router.refresh(); } catch (err) { console.error(err); toast({ @@ -590,7 +594,7 @@ export default function ResourceRules(props: { { await saveApplyRules(val); }} From 40922fedb8b3ad87bd1fb10aa89a85a45907ea8e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 12:32:10 -0500 Subject: [PATCH 3/9] Support v6 --- server/lib/ip.ts | 145 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 21 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 88c64acc..62cf1d2d 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -3,24 +3,98 @@ interface IPRange { end: bigint; } +type IPVersion = 4 | 6; + /** - * Converts IP address string to BigInt for numerical operations + * Detects IP version from address string + */ +function detectIpVersion(ip: string): IPVersion { + return ip.includes(':') ? 6 : 4; +} + +/** + * Converts IPv4 or IPv6 address string to BigInt for numerical operations */ function ipToBigInt(ip: string): bigint { - return ip.split('.') - .reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0)); + const version = detectIpVersion(ip); + + if (version === 4) { + return ip.split('.') + .reduce((acc, octet) => { + const num = parseInt(octet); + if (isNaN(num) || num < 0 || num > 255) { + throw new Error(`Invalid IPv4 octet: ${octet}`); + } + return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num)); + }, BigInt(0)); + } else { + // Handle IPv6 + // Expand :: notation + let fullAddress = ip; + if (ip.includes('::')) { + const parts = ip.split('::'); + if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found'); + const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length); + const padding = Array(missing).fill('0').join(':'); + fullAddress = `${parts[0]}:${padding}:${parts[1]}`; + } + + return fullAddress.split(':') + .reduce((acc, hextet) => { + const num = parseInt(hextet || '0', 16); + if (isNaN(num) || num < 0 || num > 65535) { + throw new Error(`Invalid IPv6 hextet: ${hextet}`); + } + return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num)); + }, BigInt(0)); + } } /** * Converts BigInt to IP address string */ -function bigIntToIp(num: bigint): string { - const octets: number[] = []; - for (let i = 0; i < 4; i++) { - octets.unshift(Number(num & BigInt(255))); - num = num >> BigInt(8); +function bigIntToIp(num: bigint, version: IPVersion): string { + if (version === 4) { + const octets: number[] = []; + for (let i = 0; i < 4; i++) { + octets.unshift(Number(num & BigInt(255))); + num = num >> BigInt(8); + } + return octets.join('.'); + } else { + const hextets: string[] = []; + for (let i = 0; i < 8; i++) { + hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0')); + num = num >> BigInt(16); + } + // Compress zero sequences + let maxZeroStart = -1; + let maxZeroLength = 0; + let currentZeroStart = -1; + let currentZeroLength = 0; + + for (let i = 0; i < hextets.length; i++) { + if (hextets[i] === '0000') { + if (currentZeroStart === -1) currentZeroStart = i; + currentZeroLength++; + if (currentZeroLength > maxZeroLength) { + maxZeroLength = currentZeroLength; + maxZeroStart = currentZeroStart; + } + } else { + currentZeroStart = -1; + currentZeroLength = 0; + } + } + + if (maxZeroLength > 1) { + hextets.splice(maxZeroStart, maxZeroLength, ''); + if (maxZeroStart === 0) hextets.unshift(''); + if (maxZeroStart + maxZeroLength === 8) hextets.push(''); + } + + return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':'); } - return octets.join('.'); } /** @@ -28,33 +102,56 @@ function bigIntToIp(num: bigint): string { */ function cidrToRange(cidr: string): IPRange { const [ip, prefix] = cidr.split('/'); + const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); const ipBigInt = ipToBigInt(ip); - const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1)); + + // Validate prefix length + const maxPrefix = version === 4 ? 32 : 128; + if (prefixBits < 0 || prefixBits > maxPrefix) { + throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`); + } + + const shiftBits = BigInt(maxPrefix - prefixBits); + const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1)); const start = ipBigInt & ~mask; const end = start | mask; + return { start, end }; } /** * Finds the next available CIDR block given existing allocations * @param existingCidrs Array of existing CIDR blocks - * @param blockSize Desired prefix length for the new block (e.g., 24 for /24) - * @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0") + * @param blockSize Desired prefix length for the new block + * @param startCidr Optional CIDR to start searching from * @returns Next available CIDR block or null if none found */ export function findNextAvailableCidr( existingCidrs: string[], blockSize: number, - startCidr: string = "0.0.0.0/0" + startCidr?: string ): string | null { + if (existingCidrs.length === 0) return null; + + // Determine IP version from first CIDR + const version = detectIpVersion(existingCidrs[0].split('/')[0]); + // Use appropriate default startCidr if none provided + startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); + + // Ensure all CIDRs are same version + if (existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { + throw new Error('All CIDRs must be of the same IP version'); + } + // Convert existing CIDRs to ranges and sort them const existingRanges = existingCidrs .map(cidr => cidrToRange(cidr)) .sort((a, b) => (a.start < b.start ? -1 : 1)); // Calculate block size - const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize); + const maxPrefix = version === 4 ? 32 : 128; + const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize); // Start from the beginning of the given CIDR let current = cidrToRange(startCidr).start; @@ -63,7 +160,6 @@ export function findNextAvailableCidr( // Iterate through existing ranges for (let i = 0; i <= existingRanges.length; i++) { const nextRange = existingRanges[i]; - // Align current to block size const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); @@ -74,7 +170,7 @@ export function findNextAvailableCidr( // If we're at the end of existing ranges or found a gap if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { - return `${bigIntToIp(alignedCurrent)}/${blockSize}`; + return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`; } // Move current pointer to after the current range @@ -85,12 +181,19 @@ export function findNextAvailableCidr( } /** -* Checks if a given IP address is within a CIDR range -* @param ip IP address to check -* @param cidr CIDR range to check against -* @returns boolean indicating if IP is within the CIDR range -*/ + * Checks if a given IP address is within a CIDR range + * @param ip IP address to check + * @param cidr CIDR range to check against + * @returns boolean indicating if IP is within the CIDR range + */ export function isIpInCidr(ip: string, cidr: string): boolean { + const ipVersion = detectIpVersion(ip); + const cidrVersion = detectIpVersion(cidr.split('/')[0]); + + if (ipVersion !== cidrVersion) { + throw new Error('IP address and CIDR must be of the same version'); + } + const ipBigInt = ipToBigInt(ip); const range = cidrToRange(cidr); return ipBigInt >= range.start && ipBigInt <= range.end; From 7797c6c770b5e50d10f6eaf240a904a4ec1d58a7 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 12:38:28 -0500 Subject: [PATCH 4/9] Allow the chars from RFC 3986 --- server/lib/validators.ts | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index f4be6277..675c0809 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -11,7 +11,7 @@ export function isValidIP(ip: string): boolean { export function isValidUrlGlobPattern(pattern: string): boolean { // Remove leading slash if present pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; - + // Empty string is not valid if (!pattern) { return false; @@ -19,12 +19,12 @@ export function isValidUrlGlobPattern(pattern: string): boolean { // Split path into segments const segments = pattern.split("/"); - + // Check each segment for (let i = 0; i < segments.length; i++) { const segment = segments[i]; - - // Empty segments are not allowed (double slashes) + + // Empty segments are not allowed (double slashes), except at the end if (!segment && i !== segments.length - 1) { return false; } @@ -34,11 +34,30 @@ export function isValidUrlGlobPattern(pattern: string): boolean { return false; } - // Check for invalid characters - if (!/^[a-zA-Z0-9_.*-]*$/.test(segment)) { - return false; + // Check each character in the segment + for (let j = 0; j < segment.length; j++) { + const char = segment[j]; + + // Check for percent-encoded sequences + if (char === "%" && j + 2 < segment.length) { + const hex1 = segment[j + 1]; + const hex2 = segment[j + 2]; + if (!/^[0-9A-Fa-f]$/.test(hex1) || !/^[0-9A-Fa-f]$/.test(hex2)) { + return false; + } + j += 2; // Skip the next two characters + continue; + } + + // Allow: + // - unreserved (A-Z a-z 0-9 - . _ ~) + // - sub-delims (! $ & ' ( ) * + , ; =) + // - @ : for compatibility with some systems + if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) { + return false; + } } } - + return true; -} +} \ No newline at end of file From 8dd30c88abeaaa31a5a2fe4bb66079dc57217995 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 13:12:29 -0500 Subject: [PATCH 5/9] fix reset password sql error --- server/auth/sessions/app.ts | 32 ++++++++++++++++++++++++---- server/routers/auth/resetPassword.ts | 22 +++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 18ea072b..62850453 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -11,7 +11,7 @@ import { users } from "@server/db/schema"; import db from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; @@ -95,12 +95,36 @@ export async function validateSessionToken( } export async function invalidateSession(sessionId: string): Promise { - await db.delete(resourceSessions).where(eq(resourceSessions.userSessionId, sessionId)); - await db.delete(sessions).where(eq(sessions.sessionId, sessionId)); + try { + await db.transaction(async (trx) => { + await trx + .delete(resourceSessions) + .where(eq(resourceSessions.userSessionId, sessionId)); + await trx.delete(sessions).where(eq(sessions.sessionId, sessionId)); + }); + } catch (e) { + logger.error("Failed to invalidate session", e); + } } export async function invalidateAllSessions(userId: string): Promise { - await db.delete(sessions).where(eq(sessions.userId, userId)); + try { + await db.transaction(async (trx) => { + const userSessions = await trx + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); + await trx.delete(resourceSessions).where( + inArray( + resourceSessions.userSessionId, + userSessions.map((s) => s.sessionId) + ) + ); + await trx.delete(sessions).where(eq(sessions.userId, userId)); + }); + } catch (e) { + logger.error("Failed to all invalidate user sessions", e); + } } export function serializeSessionCookie( diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 97b283c6..ac1b6600 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -149,8 +149,6 @@ export async function resetPassword( const passwordHash = await hashPassword(newPassword); - await invalidateAllSessions(resetRequest[0].userId); - await db.transaction(async (trx) => { await trx .update(users) @@ -162,11 +160,21 @@ export async function resetPassword( .where(eq(passwordResetTokens.email, email)); }); - await sendEmail(ConfirmPasswordReset({ email }), { - from: config.getNoReplyEmail(), - to: email, - subject: "Password Reset Confirmation" - }); + try { + await invalidateAllSessions(resetRequest[0].userId); + } catch (e) { + logger.error("Failed to invalidate user sessions", e); + } + + try { + await sendEmail(ConfirmPasswordReset({ email }), { + from: config.getNoReplyEmail(), + to: email, + subject: "Password Reset Confirmation" + }); + } catch (e) { + logger.error("Failed to send password reset confirmation email", e); + } return response(res, { data: null, From 2ff6d1d117c408e9d0e747eed911deb2da34edbb Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 13:27:34 -0500 Subject: [PATCH 6/9] allow any string as target --- server/routers/target/createTarget.ts | 56 +++++++++---------- server/routers/target/updateTarget.ts | 56 +++++++++---------- .../[resourceId]/connectivity/page.tsx | 42 +++++++------- 3 files changed, 77 insertions(+), 77 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index b1080d87..11f3de69 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -13,33 +13,33 @@ import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; -// Regular expressions for validation -const DOMAIN_REGEX = - /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -const IPV4_REGEX = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; - -// Schema for domain names and IP addresses -const domainSchema = z - .string() - .min(1, "Domain cannot be empty") - .max(255, "Domain name too long") - .refine( - (value) => { - // Check if it's a valid IP address (v4 or v6) - if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { - return true; - } - - // Check if it's a valid domain name - return DOMAIN_REGEX.test(value); - }, - { - message: "Invalid domain name or IP address format", - path: ["domain"] - } - ); +// // Regular expressions for validation +// const DOMAIN_REGEX = +// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +// const IPV4_REGEX = +// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; +// +// // Schema for domain names and IP addresses +// const domainSchema = z +// .string() +// .min(1, "Domain cannot be empty") +// .max(255, "Domain name too long") +// .refine( +// (value) => { +// // Check if it's a valid IP address (v4 or v6) +// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { +// return true; +// } +// +// // Check if it's a valid domain name +// return DOMAIN_REGEX.test(value); +// }, +// { +// message: "Invalid domain name or IP address format", +// path: ["domain"] +// } +// ); const createTargetParamsSchema = z .object({ @@ -52,7 +52,7 @@ const createTargetParamsSchema = z const createTargetSchema = z .object({ - ip: domainSchema, + ip: z.string().min(1).max(255), method: z.string().optional().nullable(), port: z.number().int().min(1).max(65535), enabled: z.boolean().default(true) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 2ae6222d..4dbb2f45 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -12,33 +12,33 @@ import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; -// Regular expressions for validation -const DOMAIN_REGEX = - /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -const IPV4_REGEX = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; - -// Schema for domain names and IP addresses -const domainSchema = z - .string() - .min(1, "Domain cannot be empty") - .max(255, "Domain name too long") - .refine( - (value) => { - // Check if it's a valid IP address (v4 or v6) - if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { - return true; - } - - // Check if it's a valid domain name - return DOMAIN_REGEX.test(value); - }, - { - message: "Invalid domain name or IP address format", - path: ["domain"] - } - ); +// // Regular expressions for validation +// const DOMAIN_REGEX = +// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +// const IPV4_REGEX = +// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; +// +// // Schema for domain names and IP addresses +// const domainSchema = z +// .string() +// .min(1, "Domain cannot be empty") +// .max(255, "Domain name too long") +// .refine( +// (value) => { +// // Check if it's a valid IP address (v4 or v6) +// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { +// return true; +// } +// +// // Check if it's a valid domain name +// return DOMAIN_REGEX.test(value); +// }, +// { +// message: "Invalid domain name or IP address format", +// path: ["domain"] +// } +// ); const updateTargetParamsSchema = z .object({ @@ -48,7 +48,7 @@ const updateTargetParamsSchema = z const updateTargetBodySchema = z .object({ - ip: domainSchema.optional(), + ip: z.string().min(1).max(255), method: z.string().min(1).max(10).optional().nullable(), port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index ea67e23e..d912b505 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -73,29 +73,29 @@ const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; -// Schema for domain names and IP addresses -const domainSchema = z - .string() - .min(1, "Domain cannot be empty") - .max(255, "Domain name too long") - .refine( - (value) => { - // Check if it's a valid IP address (v4 or v6) - if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { - return true; - } - - // Check if it's a valid domain name - return DOMAIN_REGEX.test(value); - }, - { - message: "Invalid domain name or IP address format", - path: ["domain"] - } - ); +// // Schema for domain names and IP addresses +// const domainSchema = z +// .string() +// .min(1, "Domain cannot be empty") +// .max(255, "Domain name too long") +// .refine( +// (value) => { +// // Check if it's a valid IP address (v4 or v6) +// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { +// return true; +// } +// +// // Check if it's a valid domain name +// return DOMAIN_REGEX.test(value); +// }, +// { +// message: "Invalid domain name or IP address format", +// path: ["domain"] +// } +// ); const addTargetSchema = z.object({ - ip: domainSchema, + ip: z.string().min(1).max(255), method: z.string().nullable(), port: z.coerce.number().int().positive() // protocol: z.string(), From a418195b283d0f9825bba34607e714cbab999b2e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Feb 2025 15:49:23 -0500 Subject: [PATCH 7/9] Fix ip range pick initial range; add test --- server/lib/ip.test.ts | 183 ++++++++++++++++++++++++++++++++++++++++++ server/lib/ip.ts | 19 +++-- 2 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 server/lib/ip.test.ts diff --git a/server/lib/ip.test.ts b/server/lib/ip.test.ts new file mode 100644 index 00000000..f3925cf1 --- /dev/null +++ b/server/lib/ip.test.ts @@ -0,0 +1,183 @@ +import { cidrToRange, findNextAvailableCidr } from "./ip"; + +/** + * Compares two objects for deep equality + * @param actual The actual value to test + * @param expected The expected value to compare against + * @param message The message to display if assertion fails + * @throws Error if objects are not equal + */ +export function assertEqualsObj(actual: T, expected: T, message: string): void { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr !== expectedStr) { + throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`); + } +} + +/** + * Compares two primitive values for equality + * @param actual The actual value to test + * @param expected The expected value to compare against + * @param message The message to display if assertion fails + * @throws Error if values are not equal + */ +export function assertEquals(actual: T, expected: T, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`); + } +} + +/** + * Tests if a function throws an expected error + * @param fn The function to test + * @param expectedError The expected error message or part of it + * @param message The message to display if assertion fails + * @throws Error if function doesn't throw or throws unexpected error + */ +export function assertThrows( + fn: () => void, + expectedError: string, + message: string +): void { + try { + fn(); + throw new Error(`${message}: Expected to throw "${expectedError}"`); + } catch (error) { + if (!(error instanceof Error)) { + throw new Error(`${message}\nUnexpected error type: ${typeof error}`); + } + + if (!error.message.includes(expectedError)) { + throw new Error( + `${message}\nExpected error: ${expectedError}\nActual error: ${error.message}` + ); + } + } +} + + +// Test cases +function testFindNextAvailableCidr() { + console.log("Running findNextAvailableCidr tests..."); + + // Test 1: Basic IPv4 allocation + { + const existing = ["10.0.0.0/16", "10.1.0.0/16"]; + const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8"); + assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed"); + } + + // Test 2: Finding gap between allocations + { + const existing = ["10.0.0.0/16", "10.2.0.0/16"]; + const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8"); + assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed"); + } + + // Test 3: No available space + { + const existing = ["10.0.0.0/8"]; + const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8"); + assertEquals(result, null, "No available space test failed"); + } + + // // Test 4: IPv6 allocation + // { + // const existing = ["2001:db8::/32", "2001:db8:1::/32"]; + // const result = findNextAvailableCidr(existing, 32, "2001:db8::/16"); + // assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed"); + // } + + // // Test 5: Mixed IP versions + // { + // const existing = ["10.0.0.0/16", "2001:db8::/32"]; + // assertThrows( + // () => findNextAvailableCidr(existing, 16), + // "All CIDRs must be of the same IP version", + // "Mixed IP versions test failed" + // ); + // } + + // Test 6: Empty input + { + const existing: string[] = []; + const result = findNextAvailableCidr(existing, 16); + assertEquals(result, null, "Empty input test failed"); + } + + // Test 7: Block size alignment + { + const existing = ["10.0.0.0/24"]; + const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16"); + assertEquals(result, "10.0.1.0/24", "Block size alignment test failed"); + } + + // Test 8: Block size alignment + { + const existing: string[] = []; + const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16"); + assertEquals(result, "10.0.0.0/24", "Block size alignment test failed"); + } + + // Test 9: Large block size request + { + const existing = ["10.0.0.0/24", "10.0.1.0/24"]; + const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16"); + assertEquals(result, null, "Large block size request test failed"); + } + + console.log("All findNextAvailableCidr tests passed!"); +} + +// function testCidrToRange() { +// console.log("Running cidrToRange tests..."); + +// // Test 1: Basic IPv4 conversion +// { +// const result = cidrToRange("192.168.0.0/24"); +// assertEqualsObj(result, { +// start: BigInt("3232235520"), +// end: BigInt("3232235775") +// }, "Basic IPv4 conversion failed"); +// } + +// // Test 2: IPv6 conversion +// { +// const result = cidrToRange("2001:db8::/32"); +// assertEqualsObj(result, { +// start: BigInt("42540766411282592856903984951653826560"), +// end: BigInt("42540766411282592875350729025363378175") +// }, "IPv6 conversion failed"); +// } + +// // Test 3: Invalid prefix length +// { +// assertThrows( +// () => cidrToRange("192.168.0.0/33"), +// "Invalid prefix length for IPv4", +// "Invalid IPv4 prefix test failed" +// ); +// } + +// // Test 4: Invalid IPv6 prefix +// { +// assertThrows( +// () => cidrToRange("2001:db8::/129"), +// "Invalid prefix length for IPv6", +// "Invalid IPv6 prefix test failed" +// ); +// } + +// console.log("All cidrToRange tests passed!"); +// } + +// Run all tests +try { + // testCidrToRange(); + testFindNextAvailableCidr(); + console.log("All tests passed successfully!"); +} catch (error) { + console.error("Test failed:", error); + process.exit(1); +} \ No newline at end of file diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 62cf1d2d..86fe1169 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -100,7 +100,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string { /** * Converts CIDR to IP range */ -function cidrToRange(cidr: string): IPRange { +export function cidrToRange(cidr: string): IPRange { const [ip, prefix] = cidr.split('/'); const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); @@ -132,15 +132,22 @@ export function findNextAvailableCidr( blockSize: number, startCidr?: string ): string | null { - if (existingCidrs.length === 0) return null; + + if (!startCidr && existingCidrs.length === 0) { + return null; + } + + // If no existing CIDRs, use the IP version from startCidr + const version = startCidr + ? detectIpVersion(startCidr.split('/')[0]) + : 4; // Default to IPv4 if no startCidr provided - // Determine IP version from first CIDR - const version = detectIpVersion(existingCidrs[0].split('/')[0]); // Use appropriate default startCidr if none provided startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); - // Ensure all CIDRs are same version - if (existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { + // If there are existing CIDRs, ensure all are same version + if (existingCidrs.length > 0 && + existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { throw new Error('All CIDRs must be of the same IP version'); } From d5a220a0047cf76afe6b7639f7510b7a1ecc0684 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 16:46:46 -0500 Subject: [PATCH 8/9] create target validator and add url validator --- server/lib/validators.ts | 47 ++++++++++++++++--- server/routers/target/createTarget.ts | 31 +----------- server/routers/target/updateTarget.ts | 31 +----------- .../[resourceId]/connectivity/page.tsx | 33 +------------ 4 files changed, 46 insertions(+), 96 deletions(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 675c0809..abb2ebb4 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -11,7 +11,7 @@ export function isValidIP(ip: string): boolean { export function isValidUrlGlobPattern(pattern: string): boolean { // Remove leading slash if present pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; - + // Empty string is not valid if (!pattern) { return false; @@ -19,11 +19,11 @@ export function isValidUrlGlobPattern(pattern: string): boolean { // Split path into segments const segments = pattern.split("/"); - + // Check each segment for (let i = 0; i < segments.length; i++) { const segment = segments[i]; - + // Empty segments are not allowed (double slashes), except at the end if (!segment && i !== segments.length - 1) { return false; @@ -37,12 +37,15 @@ export function isValidUrlGlobPattern(pattern: string): boolean { // Check each character in the segment for (let j = 0; j < segment.length; j++) { const char = segment[j]; - + // Check for percent-encoded sequences if (char === "%" && j + 2 < segment.length) { const hex1 = segment[j + 1]; const hex2 = segment[j + 2]; - if (!/^[0-9A-Fa-f]$/.test(hex1) || !/^[0-9A-Fa-f]$/.test(hex2)) { + if ( + !/^[0-9A-Fa-f]$/.test(hex1) || + !/^[0-9A-Fa-f]$/.test(hex2) + ) { return false; } j += 2; // Skip the next two characters @@ -58,6 +61,36 @@ export function isValidUrlGlobPattern(pattern: string): boolean { } } } - + return true; -} \ No newline at end of file +} + +export function isUrlValid(url: string | undefined) { + if (!url) return true; // the link is optional in the schema so if it's empty it's valid + var pattern = new RegExp( + "^(https?:\\/\\/)?" + // protocol + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name + "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address + "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path + "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string + "(\\#[-a-z\\d_]*)?$", + "i" + ); + return !!pattern.test(url); +} + +export function isTargetValid(value: string | undefined) { + if (!value) return true; + + const DOMAIN_REGEX = + /^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/; + const IPV4_REGEX = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; + + if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { + return true; + } + + return DOMAIN_REGEX.test(value); +} diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 11f3de69..8d07e5d6 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -12,34 +12,7 @@ import { fromError } from "zod-validation-error"; import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; - -// // Regular expressions for validation -// const DOMAIN_REGEX = -// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -// const IPV4_REGEX = -// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; -// -// // Schema for domain names and IP addresses -// const domainSchema = z -// .string() -// .min(1, "Domain cannot be empty") -// .max(255, "Domain name too long") -// .refine( -// (value) => { -// // Check if it's a valid IP address (v4 or v6) -// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { -// return true; -// } -// -// // Check if it's a valid domain name -// return DOMAIN_REGEX.test(value); -// }, -// { -// message: "Invalid domain name or IP address format", -// path: ["domain"] -// } -// ); +import { isTargetValid } from "@server/lib/validators"; const createTargetParamsSchema = z .object({ @@ -52,7 +25,7 @@ const createTargetParamsSchema = z const createTargetSchema = z .object({ - ip: z.string().min(1).max(255), + ip: z.string().refine(isTargetValid), method: z.string().optional().nullable(), port: z.number().int().min(1).max(65535), enabled: z.boolean().default(true) diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4dbb2f45..45051e0a 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -11,34 +11,7 @@ import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; - -// // Regular expressions for validation -// const DOMAIN_REGEX = -// /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -// const IPV4_REGEX = -// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -// const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; -// -// // Schema for domain names and IP addresses -// const domainSchema = z -// .string() -// .min(1, "Domain cannot be empty") -// .max(255, "Domain name too long") -// .refine( -// (value) => { -// // Check if it's a valid IP address (v4 or v6) -// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { -// return true; -// } -// -// // Check if it's a valid domain name -// return DOMAIN_REGEX.test(value); -// }, -// { -// message: "Invalid domain name or IP address format", -// path: ["domain"] -// } -// ); +import { isTargetValid } from "@server/lib/validators"; const updateTargetParamsSchema = z .object({ @@ -48,7 +21,7 @@ const updateTargetParamsSchema = z const updateTargetBodySchema = z .object({ - ip: z.string().min(1).max(255), + ip: z.string().refine(isTargetValid), method: z.string().min(1).max(10).optional().nullable(), port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index d912b505..c565b525 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -62,40 +62,11 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; -import { useSiteContext } from "@app/hooks/useSiteContext"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { useRouter } from "next/navigation"; - -// Regular expressions for validation -const DOMAIN_REGEX = - /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -const IPV4_REGEX = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; -const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; - -// // Schema for domain names and IP addresses -// const domainSchema = z -// .string() -// .min(1, "Domain cannot be empty") -// .max(255, "Domain name too long") -// .refine( -// (value) => { -// // Check if it's a valid IP address (v4 or v6) -// if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { -// return true; -// } -// -// // Check if it's a valid domain name -// return DOMAIN_REGEX.test(value); -// }, -// { -// message: "Invalid domain name or IP address format", -// path: ["domain"] -// } -// ); +import { isTargetValid } from "@server/lib/validators"; const addTargetSchema = z.object({ - ip: z.string().min(1).max(255), + ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive() // protocol: z.string(), From 6aa49084465df74f2da5c6d59b1168bb3cb210ac Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 14 Feb 2025 16:53:05 -0500 Subject: [PATCH 9/9] bump version --- server/lib/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 20376f8e..e502ccc8 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.0.0-beta.13"; +export const APP_VERSION = "1.0.0-beta.14"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME);