From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id B9A361FF0EA for ; Thu, 13 Aug 2026 17:06:04 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 2E3D9218EB; Thu, 13 Aug 2026 17:06:02 +0200 (CEST) From: Thomas Ellmenreich To: pdm-devel@lists.proxmox.com Subject: [PATCH proxmox 3/4] api-types: add a scheme generic URL regex Date: Thu, 13 Aug 2026 17:05:50 +0200 Message-ID: <20260813150551.415237-4-t.ellmenreich@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260813150551.415237-1-t.ellmenreich@proxmox.com> References: <20260813150551.415237-1-t.ellmenreich@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1786633539973 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.107 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_SHORT 0.001 Use of a URL Shortener for very short URL RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record WEIRD_PORT 0.001 Uses non-standard port number for HTTP Message-ID-Hash: Y4KL3YIRO5AKTZ4AJ6U6AIFGRX53TRXR X-Message-ID-Hash: Y4KL3YIRO5AKTZ4AJ6U6AIFGRX53TRXR X-MailFrom: t.ellmenreich@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Thomas Ellmenreich X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: The current URL regex, HTTP_URL_REGEX, is specific to the HTTP and HTTPS schemes. This patch creates a new regex that is scheme-generic and thus allows any other valid scheme. It also adds tests that cover some basic URL edge cases. Signed-off-by: Thomas Ellmenreich --- pbs-api-types/src/lib.rs | 1 + proxmox-schema/src/api_types.rs | 40 ++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/pbs-api-types/src/lib.rs b/pbs-api-types/src/lib.rs index 21ef733d..924f356b 100644 --- a/pbs-api-types/src/lib.rs +++ b/pbs-api-types/src/lib.rs @@ -44,6 +44,7 @@ pub use proxmox_schema::api_types::HTTP_URL_SCHEMA; pub use proxmox_schema::api_types::MULTI_LINE_COMMENT_SCHEMA; pub use proxmox_schema::api_types::NODE_SCHEMA; pub use proxmox_schema::api_types::SINGLE_LINE_COMMENT_FORMAT; +pub use proxmox_schema::api_types::URL_SCHEMA; pub use proxmox_schema::api_types::{ BLOCKDEVICE_DISK_AND_PARTITION_NAME_SCHEMA, BLOCKDEVICE_NAME_SCHEMA, }; diff --git a/proxmox-schema/src/api_types.rs b/proxmox-schema/src/api_types.rs index 81878dbe..05e50f26 100644 --- a/proxmox-schema/src/api_types.rs +++ b/proxmox-schema/src/api_types.rs @@ -50,6 +50,10 @@ pub const CIDR_V6_REGEX_STR: &str = concatcp!(r"(?:", IPV6RE_STR, r"/\d{1,3})$") #[rustfmt::skip] pub const SAFE_ID_REGEX_STR: &str = r"(?:[A-Za-z0-9_][A-Za-z0-9._\-]*)"; +/// Regular expression string to match allowed URL schemes. +#[rustfmt::skip] +pub const URL_SCHEME_REGEX_STR: &str = r"(?:[a-zA-Z][a-zA-Z0-9\+.\-]*)"; + /// Regular expression string to match a host, optional port and optionally a /// path, although the path remains completely unvalidated except a few /// prohibited characters. @@ -113,6 +117,12 @@ const_regex! { pub DNS_NAME_OR_IP_REGEX = concatcp!(r"^(?:", DNS_NAME_STR, "|", IPRE_STR, r")$"); pub HOST_PORT_REGEX = concatcp!(r"^(?:", DNS_NAME_STR, "|", IPRE_BRACKET_STR, "):", PORT_REGEX_STR ,"$"); + /// Matches URL constructed out of a scheme, host, port, and path. With + /// the host possibly being an IPv[4|6] address and the port being + /// optional. The path remains completely unvalidated except for a few + /// prohibited characters. + pub URL_REGEX = concatcp!(r"^", URL_SCHEME_REGEX_STR, "://", URL_HOST_PORT_PATH_REGEX_STR, "$"); + /// A specialisation of [`URL_REGEX`] regex that only allows http and /// https as the URL scheme pub HTTP_URL_REGEX = concatcp!(r"^https?://", URL_HOST_PORT_PATH_REGEX_STR, "$"); @@ -166,6 +176,7 @@ pub const SYSTEMD_DATETIME_FORMAT: ApiStringFormat = pub const HOSTNAME_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&HOSTNAME_REGEX); pub const HOST_PORT_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&HOST_PORT_REGEX); pub const HTTP_URL_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&HTTP_URL_REGEX); +pub const URL_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&URL_REGEX); pub const DNS_ALIAS_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&DNS_ALIAS_REGEX); pub const DNS_NAME_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&DNS_NAME_REGEX); @@ -243,6 +254,10 @@ pub const PORT_SCHEMA: Schema = IntegerSchema::new("Node port") .maximum(65535) .schema(); +pub const URL_SCHEMA: Schema = StringSchema::new("Url with optional port.") + .format(&URL_FORMAT) + .schema(); + pub const HTTP_URL_SCHEMA: Schema = StringSchema::new("HTTP(s) url with optional port.") .format(&HTTP_URL_FORMAT) .schema(); @@ -286,7 +301,7 @@ pub const DISK_LIST_SCHEMA: Schema = StringSchema::new("A list of disk names, co mod tests { use super::{ CIDR_REGEX, CIDR_V4_REGEX, CIDR_V6_REGEX, ED25519_BASE64_KEY_REGEX, IP_BRACKET_REGEX, - IP_REGEX, IP_V4_REGEX, IP_V6_REGEX, + IP_REGEX, IP_V4_REGEX, IP_V6_REGEX, URL_REGEX, }; #[test] @@ -327,4 +342,27 @@ mod tests { // 33 bytes of data assert!(!ED25519_BASE64_KEY_REGEX.is_match("IiC3Nkh4Fn2ukUZUNmdK5K5CWO53Zmk/eGlKO4m6aCD/")); } + + #[test] + fn test_url_regex_on_valid_urls() { + assert!(URL_REGEX.is_match("https://www.example.com/index.html")); + assert!(URL_REGEX.is_match("https://[2001:db8::1]/docs/%E2%9C%93.html?lang=en")); + assert!(URL_REGEX.is_match("ftp://ftp.example.com/pub/archive/file.zip")); + assert!(URL_REGEX.is_match("file://test.com/home/alice/documents/report.pdf")); + assert!(URL_REGEX.is_match("https://example.com:443/a%20file.html?name=John%20Doe#top")); + } + + #[test] + fn test_url_regex_on_invalid_urls() { + // is missing the ':' after the scheme + assert!(!URL_REGEX.is_match("https//www.example.com/index.html")); + // has two port numbers + assert!(!URL_REGEX.is_match("http://example.com:80:90/page")); + // only has one '/' after the scheme + assert!(!URL_REGEX.is_match("ftp:/ftp.example.com/file.txt")); + // has userinfo (technically correct, but not accepted by the regex) + assert!(!URL_REGEX.is_match("mailto:alice@example.com")); + // missing closing angle ']' brachet + assert!(!URL_REGEX.is_match("https://[2001:db8::1/path")); + } } -- 2.47.3