diff --git a/backend/src/db/manual-migrations/partition-audit-logs.ts b/backend/src/db/manual-migrations/partition-audit-logs.ts index 382ef0dbf..fbead3a24 100644 --- a/backend/src/db/manual-migrations/partition-audit-logs.ts +++ b/backend/src/db/manual-migrations/partition-audit-logs.ts @@ -16,7 +16,7 @@ const createAuditLogPartition = async (knex: Knex, startDate: Date, endDate: Dat const startDateStr = formatPartitionDate(startDate); const endDateStr = formatPartitionDate(endDate); - const partitionName = `${TableName.AuditLog}_${startDateStr.replace(/-/g, "")}_${endDateStr.replace(/-/g, "")}`; + const partitionName = `${TableName.AuditLog}_${startDateStr.replaceAll("-", "")}_${endDateStr.replaceAll("-", "")}`; await knex.schema.raw( `CREATE TABLE ${partitionName} PARTITION OF ${TableName.AuditLog} FOR VALUES FROM ('${startDateStr}') TO ('${endDateStr}')` diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts index 7f401216e..e67d037ea 100644 --- a/backend/src/ee/routes/est/certificate-est-router.ts +++ b/backend/src/ee/routes/est/certificate-est-router.ts @@ -16,7 +16,7 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = // for CSRs sent in PEM, we leave them as is // for CSRs sent in base64, we preprocess them to remove new lines and spaces if (!csrBody.includes("BEGIN CERTIFICATE REQUEST")) { - csrBody = csrBody.replace(/\n/g, "").replace(/ /g, ""); + csrBody = csrBody.replaceAll("\n", "").replaceAll(" ", ""); } done(null, csrBody); diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index 2057677cf..5f80ad02b 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -61,8 +61,8 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { if (ldapConfig.groupSearchBase) { const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; const groupSearchFilter = (ldapConfig.groupSearchFilter || groupFilter) - .replace(/{{\.Username}}/g, user.uid) - .replace(/{{\.UserDN}}/g, user.dn); + .replaceAll("{{.Username}}", user.uid) + .replaceAll("{{.UserDN}}", user.dn); if (!isValidLdapFilter(groupSearchFilter)) { throw new Error("Generated LDAP search filter is invalid."); diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts index ab5e0ee5a..c5a562a18 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -45,7 +45,6 @@ export const auditLogStreamServiceFactory = ({ }: TCreateAuditLogStreamDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" }); - const appCfg = getConfig(); const plan = await licenseService.getPlan(actorOrgId); if (!plan.auditLogStreams) { throw new BadRequestError({ @@ -62,9 +61,8 @@ export const auditLogStreamServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); - if (appCfg.isCloud) { - blockLocalAndPrivateIpAddresses(url); - } + const appCfg = getConfig(); + if (appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); if (totalStreams.length >= plan.auditLogStreamLimit) { @@ -135,9 +133,8 @@ export const auditLogStreamServiceFactory = ({ const { orgId } = logStream; const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); - const appCfg = getConfig(); - if (url && appCfg.isCloud) blockLocalAndPrivateIpAddresses(url); + if (url && appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url); // testing connection first const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; diff --git a/backend/src/ee/services/certificate-est/certificate-est-service.ts b/backend/src/ee/services/certificate-est/certificate-est-service.ts index 5790c8d5a..627cc58c6 100644 --- a/backend/src/ee/services/certificate-est/certificate-est-service.ts +++ b/backend/src/ee/services/certificate-est/certificate-est-service.ts @@ -1,5 +1,6 @@ import * as x509 from "@peculiar/x509"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { isCertChainValid } from "@app/services/certificate/certificate-fns"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; @@ -67,9 +68,7 @@ export const certificateEstServiceFactory = ({ const certTemplate = await certificateTemplateDAL.findById(certificateTemplateId); - const leafCertificate = decodeURIComponent(sslClientCert).match( - /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g - )?.[0]; + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; if (!leafCertificate) { throw new UnauthorizedError({ message: "Missing client certificate" }); @@ -88,10 +87,7 @@ export const certificateEstServiceFactory = ({ const verifiedChains = await Promise.all( caCertChains.map((chain) => { const caCert = new x509.X509Certificate(chain.certificate); - const caChain = - chain.certificateChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((c) => new x509.X509Certificate(c)) || []; + const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || []; return isCertChainValid([cert, caCert, ...caChain]); }) @@ -172,19 +168,15 @@ export const certificateEstServiceFactory = ({ } if (!estConfig.disableBootstrapCertValidation) { - const caCerts = estConfig.caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => { - return new x509.X509Certificate(cert); - }); + const caCerts = extractX509CertFromChain(estConfig.caChain)?.map((cert) => { + return new x509.X509Certificate(cert); + }); if (!caCerts) { throw new BadRequestError({ message: "Failed to parse certificate chain" }); } - const leafCertificate = decodeURIComponent(sslClientCert).match( - /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g - )?.[0]; + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; if (!leafCertificate) { throw new BadRequestError({ message: "Missing client certificate" }); @@ -250,13 +242,7 @@ export const certificateEstServiceFactory = ({ kmsService }); - const certificates = caCertChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); - - if (!certificates) { - throw new BadRequestError({ message: "Failed to parse certificate chain" }); - } + const certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); const caCertificate = new x509.X509Certificate(caCert); return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts index a1c3c941b..c832e9867 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -95,7 +95,7 @@ export const SapAseProvider = (): TDynamicProviderFns => { password }); - const queries = creationStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); for await (const query of queries) { // If it's an adduser query, we need to first call sp_addlogin on the MASTER database. @@ -116,7 +116,7 @@ export const SapAseProvider = (): TDynamicProviderFns => { username }); - const queries = revokeStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); const client = await $getClient(providerInputs); const masterClient = await $getClient(providerInputs, true); diff --git a/backend/src/ee/services/kmip/kmip-service.ts b/backend/src/ee/services/kmip/kmip-service.ts index dc0a8e85d..82ff1a9aa 100644 --- a/backend/src/ee/services/kmip/kmip-service.ts +++ b/backend/src/ee/services/kmip/kmip-service.ts @@ -4,8 +4,9 @@ import crypto, { KeyObject } from "crypto"; import { ActionProjectType } from "@app/db/schemas"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; -import { isValidHostname, isValidIp } from "@app/lib/ip"; +import { isValidIp } from "@app/lib/ip"; import { ms } from "@app/lib/ms"; +import { isFQDN } from "@app/lib/validator/validate-url"; import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { @@ -665,7 +666,7 @@ export const kmipServiceFactory = ({ .split(",") .map((name) => name.trim()) .map((altName) => { - if (isValidHostname(altName)) { + if (isFQDN(altName, { allow_wildcard: true })) { return { type: "dns", value: altName diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts index 99b0d8d9b..44af718ed 100644 --- a/backend/src/ee/services/ldap-config/ldap-fns.ts +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -97,12 +97,14 @@ export const searchGroups = async ( res.on("searchEntry", (entry) => { const dn = entry.dn.toString(); - const regex = /cn=([^,]+)/; - const match = dn.match(regex); - // parse the cn from the dn - const cn = (match && match[1]) as string; + const cnStartIndex = dn.indexOf("cn="); - groups.push({ dn, cn }); + if (cnStartIndex !== -1) { + const valueStartIndex = cnStartIndex + 3; + const commaIndex = dn.indexOf(",", valueStartIndex); + const cn = dn.substring(valueStartIndex, commaIndex === -1 ? undefined : commaIndex); + groups.push({ dn, cn }); + } }); res.on("error", (error) => { ldapClient.unbind(); diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index 3ade1a117..d3af24f61 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -29,15 +29,9 @@ export const parseScimFilter = (filterToParse: string | undefined) => { attributeName = "name"; } - return { [attributeName]: parsedValue.replace(/"/g, "") }; + return { [attributeName]: parsedValue.replaceAll('"', "") }; }; -export function extractScimValueFromPath(path: string): string | null { - const regex = /members\[value eq "([^"]+)"\]/; - const match = path.match(regex); - return match ? match[1] : null; -} - export const buildScimUser = ({ orgMembershipId, username, diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts index e86469c51..e3c6b6b5c 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts @@ -14,16 +14,43 @@ import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns import { TAssignOp, TDbProviderClients, TDirectAssignOp, THttpProviderFunction } from "../templates/types"; import { TSecretRotationData, TSecretRotationDbFn } from "./secret-rotation-queue-types"; -const REGEX = /\${([^}]+)}/g; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; +const replaceTemplateVariables = (str: string, getValue: (key: string) => unknown) => { + // Use array to collect pieces and join at the end (more efficient for large strings) + const parts: string[] = []; + let pos = 0; + + while (pos < str.length) { + const start = str.indexOf("${", pos); + if (start === -1) { + parts.push(str.slice(pos)); + break; + } + + parts.push(str.slice(pos, start)); + const end = str.indexOf("}", start + 2); + + if (end === -1) { + parts.push(str.slice(start)); + break; + } + + const varName = str.slice(start + 2, end); + parts.push(String(getValue(varName))); + pos = end + 1; + } + + return parts.join(""); +}; + export const interpolate = (data: any, getValue: (key: string) => unknown) => { if (!data) return; if (typeof data === "number") return data; if (typeof data === "string") { - return data.replace(REGEX, (_a, b) => getValue(b) as string); + return replaceTemplateVariables(data, getValue); } if (typeof data === "object" && Array.isArray(data)) { diff --git a/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts b/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts index 51cb9c056..cd5372c8d 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts @@ -8,7 +8,18 @@ type GetFullFolderPath = { export const getFullFolderPath = async ({ folderDAL, folderId, envId }: GetFullFolderPath): Promise => { // Helper function to remove duplicate slashes - const removeDuplicateSlashes = (path: string) => path.replace(/\/{2,}/g, "/"); + const removeDuplicateSlashes = (path: string) => { + const chars = []; + let lastWasSlash = false; + + for (let i = 0; i < path.length; i += 1) { + const char = path[i]; + if (char !== "/" || !lastWasSlash) chars.push(char); + lastWasSlash = char === "/"; + } + + return chars.join(""); + }; // Fetch all folders at once based on environment ID to avoid multiple queries const folders = await folderDAL.find({ envId }); diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts index 373bbc640..48d793970 100644 --- a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts @@ -1,14 +1,34 @@ +import { isIP } from "net"; + +import { isFQDN } from "@app/lib/validator/validate-url"; + // Validates usernames or wildcard (*) export const isValidUserPattern = (value: string): boolean => { - // Matches valid Linux usernames or a wildcard (*) - const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; + // Length check before regex to prevent ReDoS + if (typeof value !== "string") return false; + if (value.length > 32) return false; // Maximum Linux username length + if (value === "*") return true; // Handle wildcard separately + + // Simpler, more specific pattern for usernames + const userRegex = /^[a-z_][a-z0-9_-]*$/i; return userRegex.test(value); }; // Validates hostnames, wildcard domains, or IP addresses export const isValidHostPattern = (value: string): boolean => { - // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses - const hostRegex = - /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; - return hostRegex.test(value); + // Input validation + if (typeof value !== "string") return false; + + // Length check + if (value.length > 255) return false; + + // Handle the wildcard case separately + if (value === "*") return true; + + // Check for IP addresses using Node.js built-in functions + if (isIP(value)) return true; + + return isFQDN(value, { + allow_wildcard: true + }); }; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index f51f8d639..deb77cecc 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -8,6 +8,7 @@ import { promisify } from "util"; import { TSshCertificateTemplates } from "@app/db/schemas"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { @@ -18,6 +19,7 @@ import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-type const execFileAsync = promisify(execFile); +const EXEC_TIMEOUT_MS = 10000; // 10 seconds /* eslint-disable no-bitwise */ export const createSshCertSerialNumber = () => { const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits @@ -64,7 +66,9 @@ export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm) => { // Generate the SSH key pair // The "-N ''" sets an empty passphrase // The keys are created in the temporary directory - await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""]); + await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", ""], { + timeout: EXEC_TIMEOUT_MS + }); // Read the generated keys const publicKey = await fs.readFile(publicKeyFile, "utf8"); @@ -87,7 +91,10 @@ export const getSshPublicKey = async (privateKey: string) => { await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 }); // Run ssh-keygen to extract the public key - const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" }); + const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { + encoding: "utf8", + timeout: EXEC_TIMEOUT_MS + }); return stdout.trim(); } finally { // Ensure that files and the temporary directory are cleaned up @@ -143,7 +150,14 @@ export const validateSshCertificatePrincipals = ( } // restrict allowed characters to letters, digits, dot, underscore, and hyphen - if (!/^[A-Za-z0-9._-]+$/.test(sanitized)) { + if ( + !characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Period, + CharacterType.Underscore, + CharacterType.Hyphen + ])(sanitized) + ) { throw new BadRequestError({ message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.` }); @@ -266,8 +280,8 @@ export const validateSshCertificateTtl = (template: TSshCertificateTemplates, tt * that it only contains alphanumeric characters with no spaces. */ export const validateSshCertificateKeyId = (keyId: string) => { - const regex = /^[A-Za-z0-9-]+$/; - if (!regex.test(keyId)) { + const regex = characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen]); + if (!regex(keyId)) { throw new BadRequestError({ message: "Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces." @@ -298,7 +312,7 @@ const validateSshPublicKey = async (publicKey: string) => { try { await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 }); - await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile]); + await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile], { timeout: EXEC_TIMEOUT_MS }); } catch (error) { throw new BadRequestError({ message: "Failed to validate SSH public key format: could not be parsed." @@ -363,7 +377,7 @@ export const createSshCert = async ({ await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 }); // Execute the signing process - await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" }); + await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }); // Read the signed public key from the generated cert file const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8"); diff --git a/backend/src/lib/axios/digest-auth.ts b/backend/src/lib/axios/digest-auth.ts index ee9dbd79b..449c471fd 100644 --- a/backend/src/lib/axios/digest-auth.ts +++ b/backend/src/lib/axios/digest-auth.ts @@ -28,8 +28,8 @@ export const createDigestAuthRequestInterceptor = ( nc += 1; const nonceCount = nc.toString(16).padStart(8, "0"); const cnonce = crypto.randomBytes(24).toString("hex"); - const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)?.[1].replace(/"/g, ""); - const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)?.[1].replace(/"/g, ""); + const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)?.[1]?.replaceAll('"', "") || ""; + const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)?.[1]?.replaceAll('"', "") || ""; const ha1 = crypto.createHash("md5").update(`${username}:${realm}:${password}`).digest("hex"); const path = opts.url; diff --git a/backend/src/lib/base64/index.ts b/backend/src/lib/base64/index.ts index cfc0fde3f..2bffef3fc 100644 --- a/backend/src/lib/base64/index.ts +++ b/backend/src/lib/base64/index.ts @@ -1,26 +1,35 @@ -// Credit: https://github.com/miguelmota/is-base64 -export const isBase64 = ( - v: string, - opts = { allowEmpty: false, mimeRequired: false, allowMime: true, paddingRequired: false } -) => { - if (opts.allowEmpty === false && v === "") { - return false; +type Base64Options = { + urlSafe?: boolean; + padding?: boolean; +}; + +const base64WithPadding = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; +const base64WithoutPadding = /^[A-Za-z0-9+/]+$/; +const base64UrlWithPadding = /^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2}==|[A-Za-z0-9_-]{3}=|[A-Za-z0-9_-]{4})$/; +const base64UrlWithoutPadding = /^[A-Za-z0-9_-]+$/; + +export const isBase64 = (str: string, options: Base64Options = {}): boolean => { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); } - let regex = "(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+/]{3}=)?"; - const mimeRegex = "(data:\\w+\\/[a-zA-Z\\+\\-\\.]+;base64,)"; + // Default padding to true unless urlSafe is true + const opts: Base64Options = { + urlSafe: false, + padding: options.urlSafe === undefined ? true : !options.urlSafe, + ...options + }; - if (opts.mimeRequired === true) { - regex = mimeRegex + regex; - } else if (opts.allowMime === true) { - regex = `${mimeRegex}?${regex}`; + if (str === "") return true; + + let regex; + if (opts.urlSafe) { + regex = opts.padding ? base64UrlWithPadding : base64UrlWithoutPadding; + } else { + regex = opts.padding ? base64WithPadding : base64WithoutPadding; } - if (opts.paddingRequired === false) { - regex = "(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}(==)?|[A-Za-z0-9+\\/]{3}=?)?"; - } - - return new RegExp(`^${regex}$`, "gi").test(v); + return (!opts.padding || str.length % 4 === 0) && regex.test(str); }; export const getBase64SizeInBytes = (base64String: string) => { diff --git a/backend/src/lib/certificates/extract-certificate.test.ts b/backend/src/lib/certificates/extract-certificate.test.ts new file mode 100644 index 000000000..0d0fc2be6 --- /dev/null +++ b/backend/src/lib/certificates/extract-certificate.test.ts @@ -0,0 +1,42 @@ +import { extractX509CertFromChain } from "./extract-certificate"; + +describe("Extract Certificate Payload", () => { + test("Single chain", () => { + const payload = `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +BQAwDTELMAkGA1UEChMCUEgwHhcNMjQxMDI1MTU0MjAzWhcNMjUxMDI1MjE0MjAz +-----END CERTIFICATE-----`; + const result = extractX509CertFromChain(payload); + expect(result).toBeDefined(); + expect(result?.length).toBe(1); + expect(result?.[0]).toEqual(payload); + }); + + test("Multiple chain", () => { + const payload = `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +BQAwDTELMAkGA1UEChMCUEgwHhcNMjQxMDI1MTU0MjAzWhcNMjUxMDI1MjE0MjAz +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE-----`; + const result = extractX509CertFromChain(payload); + expect(result).toBeDefined(); + expect(result?.length).toBe(3); + expect(result).toEqual([ + `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +BQAwDTELMAkGA1UEChMCUEgwHhcNMjQxMDI1MTU0MjAzWhcNMjUxMDI1MjE0MjAz +-----END CERTIFICATE-----`, + `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE-----`, + `-----BEGIN CERTIFICATE----- +MIIEZzCCA0+gAwIBAgIUDk9+HZcMHppiNy0TvoBg8/aMEqIwDQYJKoZIhvcNAQEL +-----END CERTIFICATE-----` + ]); + }); +}); diff --git a/backend/src/lib/certificates/extract-certificate.ts b/backend/src/lib/certificates/extract-certificate.ts new file mode 100644 index 000000000..782ceee19 --- /dev/null +++ b/backend/src/lib/certificates/extract-certificate.ts @@ -0,0 +1,51 @@ +import { BadRequestError } from "../errors"; + +export const extractX509CertFromChain = (certificateChain: string): string[] => { + if (!certificateChain) { + throw new BadRequestError({ + message: "Certificate chain is empty or undefined" + }); + } + + const certificates: string[] = []; + let currentPosition = 0; + const chainLength = certificateChain.length; + + while (currentPosition < chainLength) { + // Find the start of a certificate + const beginMarker = "-----BEGIN CERTIFICATE-----"; + const startIndex = certificateChain.indexOf(beginMarker, currentPosition); + + if (startIndex === -1) { + break; // No more certificates found + } + + // Find the end of the certificate + const endMarker = "-----END CERTIFICATE-----"; + const endIndex = certificateChain.indexOf(endMarker, startIndex); + + if (endIndex === -1) { + throw new BadRequestError({ + message: "Malformed certificate chain: Found BEGIN marker without matching END marker" + }); + } + + // Extract the complete certificate including markers + const completeEndIndex = endIndex + endMarker.length; + const certificate = certificateChain.substring(startIndex, completeEndIndex); + + // Add the extracted certificate to our results + certificates.push(certificate); + + // Move position to after this certificate + currentPosition = completeEndIndex; + } + + if (certificates.length === 0) { + throw new BadRequestError({ + message: "No valid certificates found in the chain" + }); + } + + return certificates; +}; diff --git a/backend/src/lib/ip/index.ts b/backend/src/lib/ip/index.ts index 1e247dcde..0b35a2759 100644 --- a/backend/src/lib/ip/index.ts +++ b/backend/src/lib/ip/index.ts @@ -107,12 +107,6 @@ export const isValidIp = (ip: string) => { return net.isIPv4(ip) || net.isIPv6(ip); }; -export const isValidHostname = (name: string) => { - const hostnameRegex = /^(?!:\/\/)(\*\.)?([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/; - - return hostnameRegex.test(name); -}; - export type TIp = { ipAddress: string; type: IPType; diff --git a/backend/src/lib/validator/validate-folder-name.ts b/backend/src/lib/validator/validate-folder-name.ts index 1fce780f0..e357dbf9b 100644 --- a/backend/src/lib/validator/validate-folder-name.ts +++ b/backend/src/lib/validator/validate-folder-name.ts @@ -1,5 +1,11 @@ +import { CharacterType, characterValidator } from "./validate-string"; + // regex to allow only alphanumeric, dash, underscore -export const isValidFolderName = (name: string) => /^[a-zA-Z0-9-_]+$/.test(name); +export const isValidFolderName = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Underscore +]); export const isValidSecretPath = (path: string) => path diff --git a/backend/src/lib/validator/validate-string.test.ts b/backend/src/lib/validator/validate-string.test.ts new file mode 100644 index 000000000..73e172896 --- /dev/null +++ b/backend/src/lib/validator/validate-string.test.ts @@ -0,0 +1,23 @@ +import { CharacterType, characterValidator } from "./validate-string"; + +describe("validate-string", () => { + test("Check alphabets", () => { + expect(characterValidator([CharacterType.Alphabets])("hello")).toBeTruthy(); + expect(characterValidator([CharacterType.Alphabets])("hello world")).toBeFalsy(); + expect(characterValidator([CharacterType.Alphabets, CharacterType.Spaces])("hello world")).toBeTruthy(); + }); + + test("Check numbers", () => { + expect(characterValidator([CharacterType.Numbers])("1234567890")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric])("helloWORLD1234567890")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric])("helloWORLD1234567890-")).toBeFalsy(); + }); + + test("Check special characters", () => { + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])("Hello-World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Plus])("Hello+World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Underscore])("Hello_World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Colon])("Hello:World")).toBeTruthy(); + expect(characterValidator([CharacterType.AlphaNumeric, CharacterType.Underscore])("Hello World")).toBeFalsy(); + }); +}); diff --git a/backend/src/lib/validator/validate-string.ts b/backend/src/lib/validator/validate-string.ts new file mode 100644 index 000000000..57bc052f8 --- /dev/null +++ b/backend/src/lib/validator/validate-string.ts @@ -0,0 +1,101 @@ +export enum CharacterType { + Alphabets = "alphabets", + Numbers = "numbers", + AlphaNumeric = "alpha-numeric", + Spaces = "spaces", + SpecialCharacters = "specialCharacters", + Punctuation = "punctuation", + Period = "period", // . + Underscore = "underscore", // _ + Colon = "colon", // : + ForwardSlash = "forwardSlash", // / + Equals = "equals", // = + Plus = "plus", // + + Hyphen = "hyphen", // - + At = "at", // @ + // Additional individual characters that might be useful + Asterisk = "asterisk", // * + Ampersand = "ampersand", // & + Question = "question", // ? + Hash = "hash", // # + Percent = "percent", // % + Dollar = "dollar", // $ + Caret = "caret", // ^ + Backtick = "backtick", // ` + Pipe = "pipe", // | + Backslash = "backslash", // \ + OpenParen = "openParen", // ( + CloseParen = "closeParen", // ) + OpenBracket = "openBracket", // [ + CloseBracket = "closeBracket", // ] + OpenBrace = "openBrace", // { + CloseBrace = "closeBrace", // } + LessThan = "lessThan", // < + GreaterThan = "greaterThan", // > + SingleQuote = "singleQuote", // ' + DoubleQuote = "doubleQuote", // " + Comma = "comma", // , + Semicolon = "semicolon", // ; + Exclamation = "exclamation" // ! +} + +/** + * Validates if a string contains only specific types of characters + */ +export const characterValidator = (allowedCharacters: CharacterType[]) => { + // Create a regex pattern based on allowed character types + const patternMap: Record = { + [CharacterType.Alphabets]: "a-zA-Z", + [CharacterType.Numbers]: "0-9", + [CharacterType.AlphaNumeric]: "a-zA-Z0-9", + [CharacterType.Spaces]: "\\s", + [CharacterType.SpecialCharacters]: "!@#$%^&*()_+\\-=\\[\\]{}|;:'\",.<>/?\\\\", + [CharacterType.Punctuation]: "\\.\\,\\;\\:\\!\\?", + [CharacterType.Colon]: "\\:", + [CharacterType.ForwardSlash]: "\\/", + [CharacterType.Underscore]: "_", + [CharacterType.Hyphen]: "\\-", + [CharacterType.Period]: "\\.", + [CharacterType.Equals]: "=", + [CharacterType.Plus]: "\\+", + [CharacterType.At]: "@", + [CharacterType.Asterisk]: "\\*", + [CharacterType.Ampersand]: "&", + [CharacterType.Question]: "\\?", + [CharacterType.Hash]: "#", + [CharacterType.Percent]: "%", + [CharacterType.Dollar]: "\\$", + [CharacterType.Caret]: "\\^", + [CharacterType.Backtick]: "`", + [CharacterType.Pipe]: "\\|", + [CharacterType.Backslash]: "\\\\", + [CharacterType.OpenParen]: "\\(", + [CharacterType.CloseParen]: "\\)", + [CharacterType.OpenBracket]: "\\[", + [CharacterType.CloseBracket]: "\\]", + [CharacterType.OpenBrace]: "\\{", + [CharacterType.CloseBrace]: "\\}", + [CharacterType.LessThan]: "<", + [CharacterType.GreaterThan]: ">", + [CharacterType.SingleQuote]: "'", + [CharacterType.DoubleQuote]: '\\"', + [CharacterType.Comma]: ",", + [CharacterType.Semicolon]: ";", + [CharacterType.Exclamation]: "!" + }; + + // Combine patterns from allowed characters + const combinedPattern = allowedCharacters.map((char) => patternMap[char]).join(""); + + // Create a regex that matches only the allowed characters + const regex = new RegExp(`^[${combinedPattern}]+$`); + + /** + * Validates if the input string contains only the allowed character types + * @param input String to validate + * @returns Boolean indicating if the string is valid + */ + return function validate(input: string): boolean { + return regex.test(input); + }; +}; diff --git a/backend/src/lib/validator/validate-url.test.ts b/backend/src/lib/validator/validate-url.test.ts new file mode 100644 index 000000000..3ed5cb446 --- /dev/null +++ b/backend/src/lib/validator/validate-url.test.ts @@ -0,0 +1,15 @@ +import { isFQDN } from "./validate-url"; + +describe("isFQDN", () => { + test("Non wildcard", () => { + expect(isFQDN("www.example.com")).toBeTruthy(); + }); + + test("Wildcard", () => { + expect(isFQDN("*.example.com", { allow_wildcard: true })).toBeTruthy(); + }); + + test("Wildcard FQDN fails on option allow_wildcard false", () => { + expect(isFQDN("*.example.com")).toBeFalsy(); + }); +}); diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index fccebf47b..6feab9036 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -1,18 +1,117 @@ -import { getConfig } from "../config/env"; +import dns from "node:dns/promises"; + +import { isIPv4 } from "net"; + import { BadRequestError } from "../errors"; +import { isPrivateIp } from "../ip/ipRange"; -export const blockLocalAndPrivateIpAddresses = (url: string) => { +export const blockLocalAndPrivateIpAddresses = async (url: string) => { const validUrl = new URL(url); - const appCfg = getConfig(); - // on cloud local ips are not allowed - if ( - appCfg.isCloud && - (validUrl.host === "host.docker.internal" || - validUrl.host.match(/^10\.\d+\.\d+\.\d+/) || - validUrl.host.match(/^192\.168\.\d+\.\d+/)) - ) - throw new BadRequestError({ message: "Local IPs not allowed as URL" }); - - if (validUrl.host === "localhost" || validUrl.host === "127.0.0.1") - throw new BadRequestError({ message: "Localhost not allowed" }); + const inputHostIps: string[] = []; + if (isIPv4(validUrl.host)) { + inputHostIps.push(validUrl.host); + } else { + if (validUrl.host === "localhost" || validUrl.host === "host.docker.internal") { + throw new BadRequestError({ message: "Local IPs not allowed as URL" }); + } + const resolvedIps = await dns.resolve4(validUrl.host); + inputHostIps.push(...resolvedIps); + } + const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); + if (isInternalIp) throw new BadRequestError({ message: "Local IPs not allowed as URL" }); +}; + +type FQDNOptions = { + require_tld?: boolean; + allow_underscores?: boolean; + allow_trailing_dot?: boolean; + allow_numeric_tld?: boolean; + allow_wildcard?: boolean; + ignore_max_length?: boolean; +}; + +const defaultFqdnOptions: FQDNOptions = { + require_tld: true, + allow_underscores: false, + allow_trailing_dot: false, + allow_numeric_tld: false, + allow_wildcard: false, + ignore_max_length: false +}; + +// credits: https://github.com/validatorjs/validator.js/blob/f5da7fb6ed59b94695e6fcb2e970c80029509919/src/lib/isFQDN.js#L13 +export const isFQDN = (str: string, options: FQDNOptions = {}): boolean => { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); + } + + // Apply default options + const opts: FQDNOptions = { + ...defaultFqdnOptions, + ...options + }; + + let testStr = str; + /* Remove the optional trailing dot before checking validity */ + if (opts.allow_trailing_dot && str[str.length - 1] === ".") { + testStr = testStr.substring(0, str.length - 1); + } + + /* Remove the optional wildcard before checking validity */ + if (opts.allow_wildcard === true && str.indexOf("*.") === 0) { + testStr = testStr.substring(2); + } + + const parts = testStr.split("."); + const tld = parts[parts.length - 1]; + + if (opts.require_tld) { + // disallow fqdns without tld + if (parts.length < 2) { + return false; + } + + if ( + !opts.allow_numeric_tld && + !/^([a-z\u00A1-\u00A8\u00AA-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}|xn[a-z0-9-]{2,})$/i.test(tld) + ) { + return false; + } + + // disallow spaces + if (/\s/.test(tld)) { + return false; + } + } + + // reject numeric TLDs + if (!opts.allow_numeric_tld && /^\d+$/.test(tld)) { + return false; + } + + return parts.every((part) => { + if (part.length > 63 && !opts.ignore_max_length) { + return false; + } + + if (!/^[a-z_\u00a1-\uffff0-9-]+$/i.test(part)) { + return false; + } + + // disallow full-width chars + if (/[\uff01-\uff5e]/.test(part)) { + return false; + } + + // disallow parts starting or ending with hyphen + if (/^-|-$/.test(part)) { + return false; + } + + if (!opts.allow_underscores && /_/.test(part)) { + return false; + } + + return true; + }); }; diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 0d8eae848..43a30760e 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -1,6 +1,8 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; + interface SlugSchemaInputs { min?: number; max?: number; @@ -27,4 +29,13 @@ export const GenericResourceNameSchema = z .trim() .min(1, { message: "Name must be at least 1 character" }) .max(64, { message: "Name must be 64 or fewer characters" }) - .regex(/^[a-zA-Z0-9\-_\s]+$/, "Name can only contain alphanumeric characters, dashes, underscores, and spaces"); + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Underscore, + CharacterType.Spaces + ])(val), + "Name can only contain alphanumeric characters, dashes, underscores, and spaces" + ); diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 3dfaa6532..f03b5db4c 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -7,9 +7,11 @@ import { z } from "zod"; import { ActionProjectType, ProjectType, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { isFQDN } from "@app/lib/validator/validate-url"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -58,7 +60,6 @@ import { TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; -import { hostnameRegex } from "./certificate-authority-validators"; type TCertificateAuthorityServiceFactoryDep = { certificateAuthorityDAL: Pick< @@ -1017,9 +1018,7 @@ export const certificateAuthorityServiceFactory = ({ const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; // validate imported certificate and certificate chain - const certificates = certificateChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); + const certificates = extractX509CertFromChain(certificateChain)?.map((cert) => new x509.X509Certificate(cert)); if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); @@ -1325,7 +1324,7 @@ export const certificateAuthorityServiceFactory = ({ } // check if the altName is a valid hostname - if (hostnameRegex.test(altName)) { + if (isFQDN(altName, { allow_wildcard: true })) { return { type: "dns", value: altName @@ -1702,7 +1701,7 @@ export const certificateAuthorityServiceFactory = ({ } // check if the altName is a valid hostname - if (hostnameRegex.test(altName)) { + if (isFQDN(altName, { allow_wildcard: true })) { return { type: "dns", value: altName diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 1840652ca..979a3b9c5 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { isValidIp } from "@app/lib/ip"; +import { isFQDN } from "@app/lib/validator/validate-url"; const isValidDate = (dateString: string) => { const date = new Date(dateString); @@ -9,7 +10,6 @@ const isValidDate = (dateString: string) => { export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); -export const hostnameRegex = /^(?!:\/\/)(\*\.)?([a-zA-Z0-9-_]{1,63}\.?)+(?!:\/\/)([a-zA-Z]{2,63})$/; export const validateAltNamesField = z .string() .trim() @@ -27,7 +27,7 @@ export const validateAltNamesField = z if (data === "") return true; // Split and validate each alt name return data.split(", ").every((name) => { - return hostnameRegex.test(name) || z.string().email().safeParse(name).success || isValidIp(name); + return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); }); }, { diff --git a/backend/src/services/certificate-template/certificate-template-fns.ts b/backend/src/services/certificate-template/certificate-template-fns.ts index 0a19ac92f..fa8055f69 100644 --- a/backend/src/services/certificate-template/certificate-template-fns.ts +++ b/backend/src/services/certificate-template/certificate-template-fns.ts @@ -11,6 +11,7 @@ export const validateCertificateDetailsAgainstTemplate = ( }, template: TCertificateTemplates ) => { + // these are validated in router using validateTemplateRegexField const commonNameRegex = new RegExp(template.commonName); if (!commonNameRegex.test(cert.commonName)) { throw new BadRequestError({ diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 9a5cd9269..04bf76f5c 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -6,6 +6,7 @@ import { ActionProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -281,9 +282,7 @@ export const certificateTemplateServiceFactory = ({ }); // validate CA chain - const certificates = caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); if (!certificates) { throw new BadRequestError({ message: "Failed to parse certificate chain" }); @@ -379,9 +378,7 @@ export const certificateTemplateServiceFactory = ({ }; if (caChain) { - const certificates = caChain - .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) - ?.map((cert) => new x509.X509Certificate(cert)); + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); if (!certificates) { throw new BadRequestError({ message: "Failed to parse certificate chain" }); diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts index 41a06b05d..60694b598 100644 --- a/backend/src/services/certificate-template/certificate-template-validators.ts +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -1,13 +1,27 @@ import safe from "safe-regex"; import z from "zod"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; + export const validateTemplateRegexField = z .string() .min(1) .max(100) - .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { - message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - }) + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, // (space) + CharacterType.Asterisk, // * + CharacterType.At, // @ + CharacterType.Hyphen, // - + CharacterType.Period, // . + CharacterType.Backslash // \ + ])(val), + { + message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + } + ) // we ensure that the inputted pattern is computationally safe by limiting star height to 1 .refine((v) => safe(v), { message: "Unsafe REGEX pattern" diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index 5be3dfe4c..f611f4c15 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -93,7 +93,8 @@ export const identityAwsAuthServiceFactory = ({ .some((principalArn) => { // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" // considers exact matches + wildcard matches - const regex = new RegExp(`^${principalArn.replace(/\*/g, ".*")}$`); + // heavily validated in router + const regex = new RegExp(`^${principalArn.replaceAll("*", ".*")}$`); return regex.test(extractPrincipalArn(Arn)); }); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts index 2cc736f1e..b3f3ccc94 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -1,6 +1,8 @@ +import safe from "safe-regex"; import { z } from "zod"; const twelveDigitRegex = /^\d{12}$/; +// akhilmhdh: change this to a normal function later. Checked no redosable at the moment const arnRegex = /^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|\*)$/; export const validateAccountIds = z @@ -42,7 +44,8 @@ export const validatePrincipalArns = z // Split the string by commas to check each supposed ARN const arns = data.split(","); // Return true only if every item matches one of the allowed ARN formats - return arns.every((arn) => arnRegex.test(arn.trim())); + // and checks whether the provided regex is safe + return arns.map((el) => el.trim()).every((arn) => safe(`^${arn.replaceAll("*", ".*")}$`) && arnRegex.test(arn)); }, { message: diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 56e506759..e5c9d4eb0 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -584,7 +584,7 @@ const syncSecretsAzureKeyVault = async ({ }[] = []; Object.keys(secrets).forEach((key) => { - const hyphenatedKey = key.replace(/_/g, "-"); + const hyphenatedKey = key.replaceAll("_", "-"); if (!(hyphenatedKey in res)) { // case: secret has been created setSecrets.push({ @@ -603,7 +603,7 @@ const syncSecretsAzureKeyVault = async ({ const deleteSecrets: AzureKeyVaultSecret[] = []; Object.keys(res).forEach((key) => { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); if (!(underscoredKey in secrets)) { deleteSecrets.push(res[key]); } @@ -617,7 +617,7 @@ const syncSecretsAzureKeyVault = async ({ if (!integration.lastUsed) { Object.keys(res).forEach((key) => { // first time using integration - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); // -> apply initial sync behavior switch (metadata.initialSyncBehavior) { @@ -3578,7 +3578,7 @@ const syncSecretsTeamCity = async ({ .filter((parameter) => !parameter.inherited) .reduce( (obj, secret) => { - const secretName = secret.name.replace(/^env\./, ""); + const secretName = secret.name.startsWith(".env") ? secret.name.slice(4) : secret.name; return { ...obj, [secretName]: secret.value @@ -3635,7 +3635,7 @@ const syncSecretsTeamCity = async ({ ) ).data.property.reduce( (obj, secret) => { - const secretName = secret.name.replace(/^env\./, ""); + const secretName = secret.name.startsWith("env.") ? secret.name.slice(4) : secret.name; return { ...obj, [secretName]: secret.value diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 221840e1b..85d348854 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -43,7 +43,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Organization)) .select( db.raw(` - CASE + CASE WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}' WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}' ELSE '' @@ -80,7 +80,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Organization)) .select( db.raw(` - CASE + CASE WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.SAML}' WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN '${OrgAuthMethod.OIDC}' ELSE '' @@ -119,7 +119,7 @@ export const orgDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Organization)) .select( db.raw(` - CASE + CASE WHEN ${TableName.SamlConfig}."orgId" IS NOT NULL THEN 'saml' WHEN ${TableName.OidcConfig}."orgId" IS NOT NULL THEN 'oidc' ELSE '' diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index c03d4ffc0..e136c5a50 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -7,14 +7,16 @@ import { groupBy, removeTrailingSlash } from "@app/lib/fn"; import { ormify, selectAllTableCols } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { isValidSecretPath } from "@app/lib/validator"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { TFindFoldersDeepByParentIdsDTO } from "./secret-folder-types"; -export const validateFolderName = (folderName: string) => { - const validNameRegex = /^[a-zA-Z0-9-_]+$/; - return validNameRegex.test(folderName); -}; +export const validateFolderName = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Underscore +]); const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: string; secretPath: string }>) => { // this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2 @@ -188,9 +190,9 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str // the root folder check is used to avoid last / and also root name in folders depth: db.raw("parent.depth + 1"), path: db.raw( - `CONCAT( CASE - WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' - ELSE CONCAT('/', secret_folders.name) + `CONCAT( CASE + WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' + ELSE CONCAT('/', secret_folders.name) END, parent.path )` ), child: db.raw("COALESCE(parent.child, parent.id)"), @@ -464,7 +466,7 @@ export const secretFolderDALFactory = (db: TDbClient) => { db.raw("parents.depth + 1 as depth"), db.raw( `CONCAT( - CASE WHEN parents.path = '/' THEN '' ELSE parents.path END, + CASE WHEN parents.path = '/' THEN '' ELSE parents.path END, CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' ELSE CONCAT('/', secret_folders.name) END )` ), diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts index 44296c306..324b78130 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SecretSyncs } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { @@ -10,6 +11,25 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +const tagFieldCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, + CharacterType.Period, + CharacterType.Underscore, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Equals, + CharacterType.Plus, + CharacterType.Hyphen, + CharacterType.At +]); + +const pathCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Underscore, + CharacterType.Hyphen +]); + const AwsParameterStoreSyncDestinationConfigSchema = z.object({ region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.region), path: z @@ -17,35 +37,54 @@ const AwsParameterStoreSyncDestinationConfigSchema = z.object({ .trim() .min(1, "Parameter Store Path required") .max(2048, "Cannot exceed 2048 characters") - .regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format') + .refine( + (val) => + val.startsWith("/") && + val.endsWith("/") && + val + .split("/") + .filter(Boolean) + .every((el) => pathCharacterValidator(el)), + 'Invalid path - must follow "/example/path/" format' + ) .describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.path) }); const AwsParameterStoreSyncOptionsSchema = z.object({ keyId: z .string() - .regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID") .min(1, "Invalid KMS Key ID") .max(256, "Invalid KMS Key ID") + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Underscore, + CharacterType.Hyphen + ])(val), + "Invalid KMS Key ID" + ) .optional() .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.keyId), tags: z .object({ key: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, - "Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" - ) .min(1, "Resource tag key required") - .max(128, "Resource tag key cannot exceed 128 characters"), + .max(128, "Resource tag key cannot exceed 128 characters") + .refine( + (val) => tagFieldCharacterValidator(val), + "Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ), value: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + .max(256, "Resource tag value cannot exceed 256 characters") + .refine( + (val) => tagFieldCharacterValidator(val), "Invalid resource tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" ) - .max(256, "Resource tag value cannot exceed 256 characters") }) .array() .max(50) diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts index 5e8ce2bad..e80964721 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SecretSyncs } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { AwsSecretsManagerSyncMappingBehavior } from "@app/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -24,12 +25,23 @@ const AwsSecretsManagerSyncDestinationConfigSchema = z .describe(SecretSyncs.DESTINATION_CONFIG.AWS_SECRETS_MANAGER.mappingBehavior), secretName: z .string() - .regex( - /^[a-zA-Z0-9/_+=.@-]+$/, - "Secret name must contain only alphanumeric characters and the characters /_+=.@-" - ) + .min(1, "Secret name is required") .max(256, "Secret name cannot exceed 256 characters") + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.ForwardSlash, + CharacterType.Underscore, + CharacterType.Plus, + CharacterType.Equals, + CharacterType.Period, + CharacterType.At, + CharacterType.Hyphen + ])(val), + "Secret name must contain only alphanumeric characters and the characters /_+=.@-" + ) .describe(SecretSyncs.DESTINATION_CONFIG.AWS_SECRETS_MANAGER.secretName) }) ]) @@ -39,31 +51,54 @@ const AwsSecretsManagerSyncDestinationConfigSchema = z }) ); +const tagFieldCharacterValidator = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, + CharacterType.Period, + CharacterType.Underscore, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Equals, + CharacterType.Plus, + CharacterType.Hyphen, + CharacterType.At +]); + const AwsSecretsManagerSyncOptionsSchema = z.object({ keyId: z .string() - .regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID") .min(1, "Invalid KMS Key ID") .max(256, "Invalid KMS Key ID") + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Colon, + CharacterType.ForwardSlash, + CharacterType.Underscore, + CharacterType.Hyphen + ])(val), + "Invalid KMS Key ID" + ) .optional() .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.keyId), tags: z .object({ key: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, - "Invalid tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" - ) .min(1, "Tag key required") - .max(128, "Tag key cannot exceed 128 characters"), + .max(128, "Tag key cannot exceed 128 characters") + .refine( + (val) => tagFieldCharacterValidator(val), + "Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ), value: z .string() - .regex( - /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, - "Invalid tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" - ) .max(256, "Tag value cannot exceed 256 characters") + .refine( + (val) => tagFieldCharacterValidator(val), + "Invalid resource tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) }) .array() .max(50) diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index b50d78bb2..12f1f2aff 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -100,7 +100,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur const deleteSecrets: string[] = []; Object.keys(secretMap).forEach((infisicalKey) => { - const hyphenatedKey = infisicalKey.replace(/_/g, "-"); + const hyphenatedKey = infisicalKey.replaceAll("_", "-"); if (!(hyphenatedKey in vaultSecrets)) { // case: secret has been created setSecrets.push({ @@ -117,7 +117,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur }); Object.keys(vaultSecrets).forEach((key) => { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); if (!(underscoredKey in secretMap)) { deleteSecrets.push(key); } @@ -211,7 +211,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur ); for await (const [key] of Object.entries(vaultSecrets)) { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); if (underscoredKey in secretMap) { if (!disabledAzureKeyVaultSecretKeys.includes(underscoredKey)) { @@ -237,7 +237,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur Object.keys(vaultSecrets).forEach((key) => { if (!disabledAzureKeyVaultSecretKeys.includes(key)) { - const underscoredKey = key.replace(/-/g, "_"); + const underscoredKey = key.replaceAll("-", "_"); secretMap[underscoredKey] = { value: vaultSecrets[key].value }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 0abf02aaa..b2fe923aa 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -463,7 +463,7 @@ export const recursivelyGetSecretPaths = async ({ const formatMultiValueEnv = (val?: string) => { if (!val) return ""; if (!val.match("\n")) return val; - return `"${val.replace(/\n/g, "\\n")}"`; + return `"${val.replaceAll("\n", "\\n")}"`; }; type TSecretReferenceTraceNode = { diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index aa8cec2bc..2574dc13e 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -207,7 +207,7 @@ export const recursivelyGetSecretPaths = ({ const formatMultiValueEnv = (val?: string) => { if (!val) return ""; if (!val.match("\n")) return val; - return `"${val.replace(/\n/g, "\\n")}"`; + return `"${val.replaceAll("\n", "\\n")}"`; }; type TInterpolateSecretArg = { @@ -218,7 +218,7 @@ type TInterpolateSecretArg = { }; const MAX_SECRET_REFERENCE_DEPTH = 5; -const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; +const INTERPOLATION_SYNTAX_REG = /\${([a-zA-Z0-9-_.]+)}/g; export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderDAL }: TInterpolateSecretArg) => { const secretCache: Record> = {}; const getCacheUniqueKey = (environment: string, secretPath: string) => `${environment}-${secretPath}`; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx index 28faa5970..475c15b20 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTab/components/CertificateTemplateModal.tsx @@ -40,15 +40,7 @@ import { import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const validateTemplateRegexField = z - .string() - .trim() - .min(1) - .max(100) - .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { - message: - "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - }); +const validateTemplateRegexField = z.string().trim().min(1).max(100); const schema = z.object({ caId: z.string(), diff --git a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx index b193ae257..15a8d8c78 100644 --- a/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx +++ b/frontend/src/pages/ssh/SshCaByIDPage/components/SshCertificateTemplateModal.tsx @@ -26,21 +26,6 @@ import { } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; -// Validates usernames or wildcard (*) -export const isValidUserPattern = (value: string): boolean => { - // Matches valid Linux usernames or a wildcard (*) - const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; - return userRegex.test(value); -}; - -// Validates hostnames, wildcard domains, or IP addresses -export const isValidHostPattern = (value: string): boolean => { - // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses - const hostRegex = - /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; - return hostRegex.test(value); -}; - const schema = z .object({ sshCaId: z.string(), @@ -69,28 +54,8 @@ const schema = z "Max TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..." ) .default("30d"), - allowedUsers: z.string().refine( - (val) => { - const trimmed = val.trim(); - if (trimmed === "") return true; - const users = trimmed.split(",").map((u) => u.trim()); - return users.every(isValidUserPattern); - }, - { - message: "Invalid user pattern in allowedUsers" - } - ), - allowedHosts: z.string().refine( - (val) => { - const trimmed = val.trim(); - if (trimmed === "") return true; - const users = trimmed.split(",").map((u) => u.trim()); - return users.every(isValidHostPattern); - }, - { - message: "Invalid host pattern in allowedHosts" - } - ), + allowedUsers: z.string(), + allowedHosts: z.string(), allowUserCertificates: z.boolean().optional().default(false), allowHostCertificates: z.boolean().optional().default(false), allowCustomKeyIds: z.boolean().optional().default(false)