From 2c4ce44165273e63e05179ba113ef0827811e829 Mon Sep 17 00:00:00 2001 From: Rami Farhat Date: Fri, 12 Jun 2026 09:22:25 -0700 Subject: [PATCH 1/4] fix: allow dots and valid IoT Hub device ID characters in path validation The validatePath regex was too restrictive, blocking dots and other special characters that are valid in IoT Hub device IDs per Azure documentation (- . % _ * ? ! ( ) , : = @ $ '). This caused 'Invalid path: contains disallowed characters' errors for device IDs like 'test.test2'. Path traversal (..) and double slashes (//) are still blocked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- host/handlers/urlValidator.js | 241 +++++++++++++++++++++++++++ public/handlers/urlValidator.spec.ts | 66 +++++++- public/handlers/urlValidator.ts | 15 +- 3 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 host/handlers/urlValidator.js diff --git a/host/handlers/urlValidator.js b/host/handlers/urlValidator.js new file mode 100644 index 00000000..e5857797 --- /dev/null +++ b/host/handlers/urlValidator.js @@ -0,0 +1,241 @@ +"use strict"; +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.validateAzureIoTHostname = validateAzureIoTHostname; +exports.validateEventHubHostname = validateEventHubHostname; +exports.extractEventHubHostname = extractEventHubHostname; +exports.sanitizeHeaders = sanitizeHeaders; +exports.validatePath = validatePath; +exports.validateQueryString = validateQueryString; +// Allowed Azure IoT Hub domain suffixes (global + national clouds) +const ALLOWED_IOT_HUB_SUFFIXES = [ + '.azure-devices.net', // Global Azure + '.azure-devices.cn', // Azure China (21Vianet) + '.azure-devices.us', // Azure US Government +]; +// Allowed Azure Event Hubs domain suffixes (global + national clouds) +const ALLOWED_EVENTHUB_SUFFIXES = [ + '.servicebus.windows.net', // Global Azure + '.servicebus.chinacloudapi.cn', // Azure China (21Vianet) + '.servicebus.usgovcloudapi.net', // Azure US Government +]; +// Allowlist of headers that can be passed through from client +const ALLOWED_HEADERS = new Set([ + 'content-type', + 'accept', + 'if-match', + 'if-none-match', + 'x-ms-max-item-count', + 'x-ms-continuation' +]); +// Blocked headers that should never be forwarded +const BLOCKED_HEADERS = new Set([ + 'host', + 'authorization', // We set this ourselves + 'cookie', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-real-ip', + 'forwarded' +]); +/** + * Validates hostname is a valid Azure IoT Hub endpoint. + * Supports global and national cloud domains: + * - *.azure-devices.net (Global), *.azure-devices.cn (China), *.azure-devices.us (US Gov) + * - Also accepts privatelink variants: *.privatelink.azure-devices.{net|cn|us} + * + * Security checks: + * - No path components (no slashes) + * - No special characters except dots and hyphens in valid positions + * - Each label follows DNS naming rules + */ +function validateAzureIoTHostname(hostname) { + if (!hostname || typeof hostname !== 'string') { + return false; + } + const normalizedHost = hostname.toLowerCase().trim(); + // Must end with one of the allowed IoT Hub suffixes + const matchedSuffix = ALLOWED_IOT_HUB_SUFFIXES.find(suffix => normalizedHost.endsWith(suffix)); + if (!matchedSuffix) { + return false; + } + // Check for path injection - no slashes, backslashes, or encoded variants + if (/[\/\\%]/.test(normalizedHost)) { + return false; + } + // Check for other dangerous characters (URLs, ports, credentials) + if (/[@#\?\&\=\:]/.test(normalizedHost)) { + return false; + } + // Extract the prefix before the matched suffix + // e.g., 'myhub.azure-devices.net' -> prefix = 'myhub' + // e.g., 'myhub.privatelink.azure-devices.cn' -> prefix = 'myhub.privatelink' + // e.g., 'myhub.service.azure-devices.net' -> prefix = 'myhub.service' + // e.g., 'myhub.device.privatelink.azure-devices.net' -> prefix = 'myhub.device.privatelink' + const prefix = normalizedHost.slice(0, normalizedHost.length - matchedSuffix.length); + const prefixLabels = prefix.split('.').filter(l => l.length > 0); + // Allowed prefix patterns: + // 1 label: + // 2 labels: .privatelink | .device | .service + // 3 labels: .device.privatelink | .service.privatelink + if (prefixLabels.length < 1 || prefixLabels.length > 3) { + return false; + } + if (prefixLabels.length === 2) { + const allowedSecondLabels = ['privatelink', 'device', 'service']; + if (!allowedSecondLabels.includes(prefixLabels[1])) { + return false; + } + } + if (prefixLabels.length === 3) { + const allowedEndpointTypes = ['device', 'service']; + if (!allowedEndpointTypes.includes(prefixLabels[1]) || prefixLabels[2] !== 'privatelink') { + return false; + } + } + // Validate hub name (first label) follows DNS naming rules + const hubName = prefixLabels[0]; + const labelRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + if (hubName.length === 0 || hubName.length > 63) { + return false; + } + if (!labelRegex.test(hubName)) { + return false; + } + return true; +} +/** + * Validates hostname is a valid Azure Event Hubs endpoint. + * Supports global and national cloud domains: + * - *.servicebus.windows.net (Global) + * - *.servicebus.chinacloudapi.cn (China) + * - *.servicebus.usgovcloudapi.net (US Gov) + * - Also accepts privatelink variants for each + * + * Security checks: + * - No path components (no slashes) + * - No special characters except dots and hyphens in valid positions + * - Each label follows DNS naming rules + */ +function validateEventHubHostname(hostname) { + if (!hostname || typeof hostname !== 'string') { + return false; + } + const normalizedHost = hostname.toLowerCase().trim(); + // Must end with one of the allowed Event Hub suffixes + const matchedSuffix = ALLOWED_EVENTHUB_SUFFIXES.find(suffix => normalizedHost.endsWith(suffix)); + if (!matchedSuffix) { + return false; + } + // Check for path injection + if (/[\/\\%]/.test(normalizedHost)) { + return false; + } + // Check for dangerous characters + if (/[@#\?\&\=\:]/.test(normalizedHost)) { + return false; + } + // Extract the prefix before the matched suffix + // e.g., 'mynamespace.servicebus.windows.net' -> prefix = 'mynamespace' + // e.g., 'mynamespace.privatelink.servicebus.chinacloudapi.cn' -> prefix = 'mynamespace.privatelink' + const prefix = normalizedHost.slice(0, normalizedHost.length - matchedSuffix.length); + const prefixLabels = prefix.split('.').filter(l => l.length > 0); + // Must have exactly 1 label (namespace) or 2 labels (namespace.privatelink) + if (prefixLabels.length !== 1 && prefixLabels.length !== 2) { + return false; + } + if (prefixLabels.length === 2 && prefixLabels[1] !== 'privatelink') { + return false; + } + const namespaceName = prefixLabels[0]; + const labelRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + if (namespaceName.length === 0 || namespaceName.length > 63) { + return false; + } + if (!labelRegex.test(namespaceName)) { + return false; + } + return true; +} +/** + * Extract hostname from an EventHub connection string of the form: + * Endpoint=sb:///;SharedAccessKeyName=...;SharedAccessKey=... + */ +function extractEventHubHostname(connectionString) { + if (!connectionString || typeof connectionString !== 'string') { + throw new Error('Invalid EventHub connection string: missing or empty'); + } + const match = connectionString.match(/Endpoint=sb:\/\/([^/;\s]+)/i); + if (!match || !match[1]) { + throw new Error('Invalid EventHub connection string: unable to extract Endpoint hostname'); + } + return match[1]; +} +/** + * Sanitize headers from client request + * Only allows safe headers through, blocks dangerous ones + */ +function sanitizeHeaders(headers) { + const sanitized = {}; + if (!headers || typeof headers !== 'object') { + return sanitized; + } + for (const [key, value] of Object.entries(headers)) { + if (typeof key !== 'string' || typeof value !== 'string') { + continue; + } + const lowerKey = key.toLowerCase(); + // Skip blocked headers + if (BLOCKED_HEADERS.has(lowerKey)) { + continue; + } + // Only allow explicitly allowed headers + if (!ALLOWED_HEADERS.has(lowerKey)) { + continue; + } + // Check for CRLF injection in header value + if (/[\r\n\0]/.test(value)) { + continue; + } + // Check for excessively long header values + if (value.length > 8192) { + continue; + } + sanitized[key] = value; + } + return sanitized; +} +/** + * Validate path contains only safe characters + */ +function validatePath(path) { + if (!path || typeof path !== 'string') { + return false; + } + // Block path traversal and double slashes + if (path.includes('..') || path.includes('//')) { + return false; + } + // Allow characters valid in IoT Hub device/module IDs per Azure documentation: + // alphanumeric plus: - . % _ * ? ! ( ) , : = @ $ ' + // Also allow / for path segments and URL-encoded characters (%XX) + const pathRegex = /^[a-zA-Z0-9\-._~:@!$&'()*+,;=%/]+$/; + if (!pathRegex.test(path)) { + return false; + } + return true; +} +/** + * Validate query string contains only safe characters + */ +function validateQueryString(queryString) { + if (!queryString || typeof queryString !== 'string') { + return true; // Empty query string is valid + } + // Allow standard query string characters including ? at start, and hyphen in values + const queryRegex = /^[a-zA-Z0-9=&_%.+?\-]+$/; + return queryRegex.test(queryString); +} diff --git a/public/handlers/urlValidator.spec.ts b/public/handlers/urlValidator.spec.ts index d7d2c3d0..299c556b 100644 --- a/public/handlers/urlValidator.spec.ts +++ b/public/handlers/urlValidator.spec.ts @@ -6,7 +6,8 @@ import 'jest'; import { validateAzureIoTHostname, validateEventHubHostname, - extractEventHubHostname + extractEventHubHostname, + validatePath } from './urlValidator'; describe('validateAzureIoTHostname', () => { @@ -358,3 +359,66 @@ describe('extractEventHubHostname', () => { expect(extractEventHubHostname(connStr)).toBe('mynamespace.servicebus.windows.net'); }); }); + +describe('validatePath', () => { + describe('valid paths', () => { + it('accepts simple device path', () => { + expect(validatePath('devices/mydevice')).toBe(true); + }); + + it('accepts device ID with dots', () => { + expect(validatePath('devices/test.test2')).toBe(true); + }); + + it('accepts device ID with special characters allowed by IoT Hub', () => { + expect(validatePath("devices/my-device_01.v2")).toBe(true); + }); + + it('accepts module identity path', () => { + expect(validatePath('devices/mydevice/modules/mymodule')).toBe(true); + }); + + it('accepts path with percent-encoded characters', () => { + expect(validatePath('devices/my%20device')).toBe(true); + }); + + it('accepts device query path', () => { + expect(validatePath('devices/query')).toBe(true); + }); + + it('accepts device ID with colon', () => { + expect(validatePath('devices/device:001')).toBe(true); + }); + + it('accepts device ID with @ symbol', () => { + expect(validatePath('devices/user@device')).toBe(true); + }); + }); + + describe('invalid paths', () => { + it('rejects path traversal with double dots', () => { + expect(validatePath('devices/../etc/passwd')).toBe(false); + }); + + it('rejects double slashes', () => { + expect(validatePath('devices//mydevice')).toBe(false); + }); + + it('rejects empty string', () => { + expect(validatePath('')).toBe(false); + }); + + it('rejects null/undefined', () => { + expect(validatePath(null as unknown as string)).toBe(false); + expect(validatePath(undefined as unknown as string)).toBe(false); + }); + + it('rejects path with angle brackets', () => { + expect(validatePath('devices/