From 1e29d550be213b8db367301137903070f8b56ed5 Mon Sep 17 00:00:00 2001 From: Julius Mieliauskas Date: Thu, 31 Jul 2025 02:41:38 +0300 Subject: [PATCH 1/3] Fix x509 SAN Extension to accept IPs and URLs as args --- .../src/lib/validator/validate-url.test.ts | 44 +++++++ backend/src/lib/validator/validate-url.ts | 113 +++++++++++++++++- .../certificate-authority-validators.ts | 4 +- .../internal-certificate-authority-fns.ts | 23 +++- 4 files changed, 178 insertions(+), 6 deletions(-) diff --git a/backend/src/lib/validator/validate-url.test.ts b/backend/src/lib/validator/validate-url.test.ts index 3ed5cb446..2f0f6d734 100644 --- a/backend/src/lib/validator/validate-url.test.ts +++ b/backend/src/lib/validator/validate-url.test.ts @@ -1,4 +1,5 @@ import { isFQDN } from "./validate-url"; +import { isURL } from "./validate-url"; describe("isFQDN", () => { test("Non wildcard", () => { @@ -13,3 +14,46 @@ describe("isFQDN", () => { expect(isFQDN("*.example.com")).toBeFalsy(); }); }); + + +describe("isURL", () => { + test("Valid HTTPS URL with subdomain", () => { + expect(isURL("https://sub.domain.example.com")).toBe(true); + }); + + test("Valid IPv6 host", () => { + expect(isURL("http://[2001:db8::1]")).toBe(true); + }); + + test("Valid URL with query and fragment", () => { + expect(isURL("https://example.com/path?query=value#fragment")).toBe(true); + }); + + test("Fails on missing protocol when required", () => { + expect(isURL("example.com")).toBe(false); + }); + + test("Fails on invalid protocol", () => { + expect(isURL("abcd://example.com")).toBe(false); + }); + + test("Fails on malformed IPv6 (missing brackets)", () => { + expect(isURL("http://2001:db8::1")).toBe(false); + }); + + test("Fails on non-numeric port", () => { + expect(isURL("http://example.com:abc")).toBe(false); + }); + + test("Fails on port out of range", () => { + expect(isURL("http://example.com:70000")).toBe(false); + }); + + test("Fails on space in URL", () => { + expect(isURL("http://exa mple.com")).toBe(false); + }); + + test("Fails protocol-relative URL", () => { + expect(isURL("//example.com")).toBe(false); + }); +}); diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index a4c07b37d..7f7659166 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -1,6 +1,6 @@ import dns from "node:dns/promises"; -import { isIPv4 } from "net"; +import { isIP, isIPv4 } from "net"; import RE2 from "re2"; import { getConfig } from "@app/lib/config/env"; @@ -133,3 +133,114 @@ export const isFQDN = (str: string, options: FQDNOptions = {}): boolean => { return true; }); }; + +type URLValidationOptions = { + protocols?: string[]; + require_tld?: boolean; + require_protocol?: boolean; + require_host?: boolean; + require_port?: boolean; + require_valid_protocol?: boolean; + allow_underscores?: boolean; + allow_trailing_dot?: boolean; + allow_protocol_relative_urls?: boolean; + validate_length?: boolean; + max_allowed_length?: number; + disallow_auth?: boolean; +}; + +const defaultUrlOptions: URLValidationOptions = { + protocols: ["http", "https", "ftp"], + require_tld: true, + require_protocol: true, + require_host: true, + require_port: false, + require_valid_protocol: true, + allow_underscores: false, + allow_trailing_dot: false, + allow_protocol_relative_urls: false, + validate_length: true, + max_allowed_length: 2084 +}; + +// credits: https://github.com/validatorjs/validator.js/blob/f5da7fb6ed59b94695e6fcb2e970c80029509919/src/lib/isURL.js +export const isURL = (str: string, options: URLValidationOptions = {}): boolean => { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); + } + + const opts = { ...defaultUrlOptions, ...options }; + + if (!str || new RE2(/[\s<>]/).test(str)) return false; // Invalid chars like space, < > + + if (opts.validate_length && str.length > opts.max_allowed_length!) return false; // URL too long + + let protocol: string | undefined; + let host: string = ""; + let hostname: string; + let port: number | undefined; + let portStr: string | undefined; + let urlWithoutAuth: string; + + let split = str.split("#"); + urlWithoutAuth = split.shift()!; + + split = urlWithoutAuth.split("?"); + urlWithoutAuth = split.shift()!; + + split = urlWithoutAuth.split("://"); + if (split.length > 1) { + protocol = split.shift()!.toLowerCase(); + if (opts.require_valid_protocol && !opts.protocols!.includes(protocol)) return false; // Unsupported protocol + } else if (opts.require_protocol) return false; // Protocol required but missing + else if (urlWithoutAuth.startsWith("//")) { + if (!opts.allow_protocol_relative_urls) return false; // Protocol-relative not allowed + urlWithoutAuth = urlWithoutAuth.slice(2); + } + + urlWithoutAuth = split.join("://"); + + if (!urlWithoutAuth && !opts.require_host) return true; + + split = urlWithoutAuth.split("/"); + const authority = split.shift()!; + const authorityParts = authority.split("@"); + + if (authorityParts.length > 1) { + if (opts.disallow_auth) return false; // Auth info not allowed + const auth = authorityParts.shift()!; + if (!auth || (auth.includes(":") && auth.split(":").length > 2)) return false; // Malformed auth + } + + hostname = authorityParts.join("@"); + + const ipv6Match = hostname.match(/^\[([^\]]+)\](?::([0-9]+))?$/); + if (ipv6Match) { + host = ipv6Match[1]; + portStr = ipv6Match[2]; + } else { + const hostSplit = hostname.split(":"); + host = hostSplit.shift()!; + portStr = hostSplit.length > 0 ? hostSplit.join(":") : undefined; + } + + if (portStr !== undefined) { + if (!/^[0-9]+$/.test(portStr)) return false; // Port must be numeric + port = parseInt(portStr, 10); + if (port <= 0 || port > 65535) return false; // Port out of range + } else if (opts.require_port) return false; // Port required but missing + + if (!host && opts.require_host) return false; // Host required but missing + + const isHostValid = + isIP(host) || + isFQDN(host, { + require_tld: opts.require_tld, + allow_underscores: opts.allow_underscores, + allow_trailing_dot: opts.allow_trailing_dot + }); + + if (!isHostValid) return false; // Invalid host format + + return true; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index fab62ddbf..3840bd61e 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { isValidIp } from "@app/lib/ip"; -import { isFQDN } from "@app/lib/validator/validate-url"; +import { isFQDN, isURL } from "@app/lib/validator/validate-url"; const isValidDate = (dateString: string) => { const date = new Date(dateString); @@ -15,7 +15,7 @@ export const validateAltNameField = z .trim() .refine( (name) => { - return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); + return isFQDN(name, { allow_wildcard: true }) || isURL(name) || z.string().email().safeParse(name).success || isValidIp(name); }, { message: "SAN must be a valid hostname, email address, or IP address" diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 80d2842fa..7d6b49af5 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -9,7 +9,8 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; -import { isFQDN } from "@app/lib/validator/validate-url"; +import { isFQDN, isURL } from "@app/lib/validator/validate-url"; +import { isIP } from "net"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -152,7 +153,7 @@ export const InternalCertificateAuthorityFns = ({ extensions.push(extendedKeyUsagesExtension); } - let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + let altNamesArray: { type: "email" | "dns" | "ip" | "url"; value: string }[] = []; if (subscriber.subjectAlternativeNames?.length) { altNamesArray = subscriber.subjectAlternativeNames.map((altName) => { @@ -160,6 +161,14 @@ export const InternalCertificateAuthorityFns = ({ return { type: "email", value: altName }; } + if(isURL(altName)) { + return { type: "url", value: altName }; + } + + if (isIP(altName)) { + return { type: "ip", value: altName }; + } + if (isFQDN(altName, { allow_wildcard: true })) { return { type: "dns", value: altName }; } @@ -418,7 +427,7 @@ export const InternalCertificateAuthorityFns = ({ ); } - let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + let altNamesArray: { type: "email" | "dns" | "ip" | "url"; value: string }[] = []; if (altNames) { altNamesArray = altNames.split(",").map((altName) => { @@ -430,6 +439,14 @@ export const InternalCertificateAuthorityFns = ({ return { type: "dns", value: altName }; } + if(isURL(altName)) { + return { type: "url", value: altName }; + } + + if (isIP(altName)) { + return { type: "ip", value: altName }; + } + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); }); From 2cbd66e8047f4869325194131cd21c64eda1a3ab Mon Sep 17 00:00:00 2001 From: Julius Mieliauskas Date: Thu, 31 Jul 2025 19:17:08 +0300 Subject: [PATCH 2/3] changed url validation to use zod --- .../src/lib/validator/validate-url.test.ts | 44 ------- backend/src/lib/validator/validate-url.ts | 113 +----------------- .../certificate-authority-validators.ts | 20 +++- .../internal-certificate-authority-fns.ts | 19 ++- 4 files changed, 25 insertions(+), 171 deletions(-) diff --git a/backend/src/lib/validator/validate-url.test.ts b/backend/src/lib/validator/validate-url.test.ts index 2f0f6d734..3ed5cb446 100644 --- a/backend/src/lib/validator/validate-url.test.ts +++ b/backend/src/lib/validator/validate-url.test.ts @@ -1,5 +1,4 @@ import { isFQDN } from "./validate-url"; -import { isURL } from "./validate-url"; describe("isFQDN", () => { test("Non wildcard", () => { @@ -14,46 +13,3 @@ describe("isFQDN", () => { expect(isFQDN("*.example.com")).toBeFalsy(); }); }); - - -describe("isURL", () => { - test("Valid HTTPS URL with subdomain", () => { - expect(isURL("https://sub.domain.example.com")).toBe(true); - }); - - test("Valid IPv6 host", () => { - expect(isURL("http://[2001:db8::1]")).toBe(true); - }); - - test("Valid URL with query and fragment", () => { - expect(isURL("https://example.com/path?query=value#fragment")).toBe(true); - }); - - test("Fails on missing protocol when required", () => { - expect(isURL("example.com")).toBe(false); - }); - - test("Fails on invalid protocol", () => { - expect(isURL("abcd://example.com")).toBe(false); - }); - - test("Fails on malformed IPv6 (missing brackets)", () => { - expect(isURL("http://2001:db8::1")).toBe(false); - }); - - test("Fails on non-numeric port", () => { - expect(isURL("http://example.com:abc")).toBe(false); - }); - - test("Fails on port out of range", () => { - expect(isURL("http://example.com:70000")).toBe(false); - }); - - test("Fails on space in URL", () => { - expect(isURL("http://exa mple.com")).toBe(false); - }); - - test("Fails protocol-relative URL", () => { - expect(isURL("//example.com")).toBe(false); - }); -}); diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index 7f7659166..a4c07b37d 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -1,6 +1,6 @@ import dns from "node:dns/promises"; -import { isIP, isIPv4 } from "net"; +import { isIPv4 } from "net"; import RE2 from "re2"; import { getConfig } from "@app/lib/config/env"; @@ -133,114 +133,3 @@ export const isFQDN = (str: string, options: FQDNOptions = {}): boolean => { return true; }); }; - -type URLValidationOptions = { - protocols?: string[]; - require_tld?: boolean; - require_protocol?: boolean; - require_host?: boolean; - require_port?: boolean; - require_valid_protocol?: boolean; - allow_underscores?: boolean; - allow_trailing_dot?: boolean; - allow_protocol_relative_urls?: boolean; - validate_length?: boolean; - max_allowed_length?: number; - disallow_auth?: boolean; -}; - -const defaultUrlOptions: URLValidationOptions = { - protocols: ["http", "https", "ftp"], - require_tld: true, - require_protocol: true, - require_host: true, - require_port: false, - require_valid_protocol: true, - allow_underscores: false, - allow_trailing_dot: false, - allow_protocol_relative_urls: false, - validate_length: true, - max_allowed_length: 2084 -}; - -// credits: https://github.com/validatorjs/validator.js/blob/f5da7fb6ed59b94695e6fcb2e970c80029509919/src/lib/isURL.js -export const isURL = (str: string, options: URLValidationOptions = {}): boolean => { - if (typeof str !== "string") { - throw new TypeError("Expected a string"); - } - - const opts = { ...defaultUrlOptions, ...options }; - - if (!str || new RE2(/[\s<>]/).test(str)) return false; // Invalid chars like space, < > - - if (opts.validate_length && str.length > opts.max_allowed_length!) return false; // URL too long - - let protocol: string | undefined; - let host: string = ""; - let hostname: string; - let port: number | undefined; - let portStr: string | undefined; - let urlWithoutAuth: string; - - let split = str.split("#"); - urlWithoutAuth = split.shift()!; - - split = urlWithoutAuth.split("?"); - urlWithoutAuth = split.shift()!; - - split = urlWithoutAuth.split("://"); - if (split.length > 1) { - protocol = split.shift()!.toLowerCase(); - if (opts.require_valid_protocol && !opts.protocols!.includes(protocol)) return false; // Unsupported protocol - } else if (opts.require_protocol) return false; // Protocol required but missing - else if (urlWithoutAuth.startsWith("//")) { - if (!opts.allow_protocol_relative_urls) return false; // Protocol-relative not allowed - urlWithoutAuth = urlWithoutAuth.slice(2); - } - - urlWithoutAuth = split.join("://"); - - if (!urlWithoutAuth && !opts.require_host) return true; - - split = urlWithoutAuth.split("/"); - const authority = split.shift()!; - const authorityParts = authority.split("@"); - - if (authorityParts.length > 1) { - if (opts.disallow_auth) return false; // Auth info not allowed - const auth = authorityParts.shift()!; - if (!auth || (auth.includes(":") && auth.split(":").length > 2)) return false; // Malformed auth - } - - hostname = authorityParts.join("@"); - - const ipv6Match = hostname.match(/^\[([^\]]+)\](?::([0-9]+))?$/); - if (ipv6Match) { - host = ipv6Match[1]; - portStr = ipv6Match[2]; - } else { - const hostSplit = hostname.split(":"); - host = hostSplit.shift()!; - portStr = hostSplit.length > 0 ? hostSplit.join(":") : undefined; - } - - if (portStr !== undefined) { - if (!/^[0-9]+$/.test(portStr)) return false; // Port must be numeric - port = parseInt(portStr, 10); - if (port <= 0 || port > 65535) return false; // Port out of range - } else if (opts.require_port) return false; // Port required but missing - - if (!host && opts.require_host) return false; // Host required but missing - - const isHostValid = - isIP(host) || - isFQDN(host, { - require_tld: opts.require_tld, - allow_underscores: opts.allow_underscores, - allow_trailing_dot: opts.allow_trailing_dot - }); - - if (!isHostValid) return false; // Invalid host format - - return true; -}; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 3840bd61e..3a1be7cdf 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { isValidIp } from "@app/lib/ip"; -import { isFQDN, isURL } from "@app/lib/validator/validate-url"; +import { isFQDN } from "@app/lib/validator/validate-url"; const isValidDate = (dateString: string) => { const date = new Date(dateString); @@ -15,10 +15,15 @@ export const validateAltNameField = z .trim() .refine( (name) => { - return isFQDN(name, { allow_wildcard: true }) || isURL(name) || z.string().email().safeParse(name).success || isValidIp(name); + return ( + isFQDN(name, { allow_wildcard: true }) || + z.string().url().safeParse(name).success || + z.string().email().safeParse(name).success || + isValidIp(name) + ); }, { - message: "SAN must be a valid hostname, email address, or IP address" + message: "SAN must be a valid hostname, email address, IP address or URL" } ); @@ -39,10 +44,15 @@ export const validateAltNamesField = z if (data === "") return true; // Split and validate each alt name return data.split(", ").every((name) => { - return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name); + return ( + isFQDN(name, { allow_wildcard: true }) || + z.string().url().safeParse(name).success || + z.string().email().safeParse(name).success || + isValidIp(name) + ); }); }, { - message: "Each alt name must be a valid hostname or email address" + message: "Each alt name must be a valid hostname, email address, IP address or URL" } ); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 7d6b49af5..086737d85 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -9,8 +9,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; -import { isFQDN, isURL } from "@app/lib/validator/validate-url"; -import { isIP } from "net"; +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 { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -161,11 +160,11 @@ export const InternalCertificateAuthorityFns = ({ return { type: "email", value: altName }; } - if(isURL(altName)) { + if (z.string().url().safeParse(altName).success) { return { type: "url", value: altName }; } - if (isIP(altName)) { + if (z.string().ip().safeParse(altName).success) { return { type: "ip", value: altName }; } @@ -435,18 +434,18 @@ export const InternalCertificateAuthorityFns = ({ return { type: "email", value: altName }; } - if (isFQDN(altName, { allow_wildcard: true })) { - return { type: "dns", value: altName }; - } - - if(isURL(altName)) { + if (z.string().url().safeParse(altName).success) { return { type: "url", value: altName }; } - if (isIP(altName)) { + if (z.string().ip().safeParse(altName).success) { return { type: "ip", value: altName }; } + if (isFQDN(altName, { allow_wildcard: true })) { + return { type: "dns", value: altName }; + } + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); }); From de2c1c556018d608a3b430bace73dc06f861d6d5 Mon Sep 17 00:00:00 2001 From: Julius Mieliauskas Date: Thu, 31 Jul 2025 23:51:07 +0300 Subject: [PATCH 3/3] removed TLD requirement from SAN extension dns field --- .../certificate-authority-validators.ts | 4 ++-- .../internal-certificate-authority-fns.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 3a1be7cdf..3fa7e8d5e 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -16,7 +16,7 @@ export const validateAltNameField = z .refine( (name) => { return ( - isFQDN(name, { allow_wildcard: true }) || + isFQDN(name, { allow_wildcard: true, require_tld: false }) || z.string().url().safeParse(name).success || z.string().email().safeParse(name).success || isValidIp(name) @@ -45,7 +45,7 @@ export const validateAltNamesField = z // Split and validate each alt name return data.split(", ").every((name) => { return ( - isFQDN(name, { allow_wildcard: true }) || + isFQDN(name, { allow_wildcard: true, require_tld: false }) || z.string().url().safeParse(name).success || z.string().email().safeParse(name).success || isValidIp(name) diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 086737d85..eace4e47a 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -160,6 +160,10 @@ export const InternalCertificateAuthorityFns = ({ return { type: "email", value: altName }; } + if (isFQDN(altName, { allow_wildcard: true, require_tld: false })) { + return { type: "dns", value: altName }; + } + if (z.string().url().safeParse(altName).success) { return { type: "url", value: altName }; } @@ -168,10 +172,6 @@ export const InternalCertificateAuthorityFns = ({ return { type: "ip", value: altName }; } - if (isFQDN(altName, { allow_wildcard: true })) { - return { type: "dns", value: altName }; - } - throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); }); @@ -434,6 +434,10 @@ export const InternalCertificateAuthorityFns = ({ return { type: "email", value: altName }; } + if (isFQDN(altName, { allow_wildcard: true, require_tld: false })) { + return { type: "dns", value: altName }; + } + if (z.string().url().safeParse(altName).success) { return { type: "url", value: altName }; } @@ -442,10 +446,6 @@ export const InternalCertificateAuthorityFns = ({ return { type: "ip", value: altName }; } - if (isFQDN(altName, { allow_wildcard: true })) { - return { type: "dns", value: altName }; - } - throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); });