UNPKG

snstr

Version:

Secure Nostr Software Toolkit for Renegades - A comprehensive TypeScript library for Nostr protocol implementation

168 lines (167 loc) 7.71 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RelayUrlValidationError = void 0; exports.preprocessRelayUrl = preprocessRelayUrl; exports.normalizeRelayUrl = normalizeRelayUrl; exports.validateAndNormalizeRelayUrl = validateAndNormalizeRelayUrl; const nip19_1 = require("../nip19"); /** * Custom error class for relay URL validation errors */ class RelayUrlValidationError extends Error { constructor(message, errorType, invalidUrl) { super(message); this.name = "RelayUrlValidationError"; this.errorType = errorType; this.invalidUrl = invalidUrl; } } exports.RelayUrlValidationError = RelayUrlValidationError; /** * Preprocesses a relay URL before normalization and validation. * Adds wss:// prefix only to URLs without any scheme. * Throws an error for URLs with incompatible schemes. */ function preprocessRelayUrl(url) { if (!url || typeof url !== "string") { throw new RelayUrlValidationError("URL must be a non-empty string", "format"); } let trimmedUrl = url.trim(); // Handle scheme-relative URLs ("//example.com") by removing the slashes // so that the normal fallback of `wss://` works as intended. if (trimmedUrl.startsWith("//")) { trimmedUrl = trimmedUrl.slice(2); } if (!trimmedUrl) { throw new RelayUrlValidationError("URL cannot be empty or whitespace only", "format"); } // Detect an existing scheme of the form <scheme>:// const schemePattern = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//; const schemeMatch = trimmedUrl.match(schemePattern); if (schemeMatch) { const scheme = schemeMatch[1].toLowerCase(); if (scheme === "ws" || scheme === "wss") { return trimmedUrl; } throw new RelayUrlValidationError(`Invalid relay URL scheme: "${scheme}://". ` + `Relay URLs must use WebSocket protocols (ws:// or wss://). ` + `Got: "${trimmedUrl}"`, "scheme", trimmedUrl); } // Check if input already has a scheme and validate it if (trimmedUrl.includes("://")) { try { const originalParsed = new URL(trimmedUrl); if (originalParsed.protocol !== "ws:" && originalParsed.protocol !== "wss:") { throw new RelayUrlValidationError(`Invalid relay URL scheme: "${originalParsed.protocol}//". ` + `Relay URLs must use WebSocket protocols (ws:// or wss://). ` + `Got: "${trimmedUrl}"`, "scheme", trimmedUrl); } return trimmedUrl; // Return original URL with valid scheme } catch (urlError) { throw new RelayUrlValidationError(`Invalid URL format: "${trimmedUrl}". ` + `Unable to parse the provided URL.`, "format", trimmedUrl); } } // No scheme in input, so try to construct a valid URL with wss:// prefix // First split the input into host and path/query/fragment parts const firstSlashIndex = trimmedUrl.indexOf("/"); const hostPortPart = firstSlashIndex === -1 ? trimmedUrl : trimmedUrl.substring(0, firstSlashIndex); const pathQueryFragmentPart = firstSlashIndex === -1 ? "" : trimmedUrl.substring(firstSlashIndex); // Handle IPv6 addresses by properly separating port and applying brackets correctly let hostPart = hostPortPart; let portPart = ""; // Check if this looks like an IPv6 address without brackets if (hostPart.includes(":") && !hostPart.startsWith("[")) { // For IPv6 addresses with ports, we need to separate the port first // A port is typically the last segment after the final colon that's all digits const lastColonIndex = hostPart.lastIndexOf(":"); if (lastColonIndex !== -1) { const potentialPort = hostPart.substring(lastColonIndex + 1); const potentialHost = hostPart.substring(0, lastColonIndex); // Check if the part after the last colon looks like a port (all digits, 1-5 chars) if (/^\d{1,5}$/.test(potentialPort)) { const portNumber = parseInt(potentialPort, 10); // Valid port range: 1-65535 if (portNumber >= 1 && portNumber <= 65535) { // Now check if the remaining part (without the potential port) looks like IPv6 // IPv6 addresses must have at least 1 colon or contain "::" for compression const hostColonCount = (potentialHost.match(/:/g) || []).length; const hasDoubleColon = potentialHost.includes("::"); // More precise IPv6 detection for the host part // Only separate port if it's actually IPv6 (has colons or :: notation) const isLikelyIPv6 = hostColonCount >= 1 || hasDoubleColon; if (isLikelyIPv6) { // This appears to be IPv6 with a port, separate them hostPart = potentialHost; portPart = `:${potentialPort}`; } } } } // Now check if the remaining host part looks like IPv6 and needs brackets const colonCount = (hostPart.match(/:/g) || []).length; const hasDoubleColon = hostPart.includes("::"); // IPv6 addresses have multiple colons or contain "::" for compression // Only add brackets if it's clearly IPv6 (multiple colons or double colon notation) if (colonCount >= 2 || hasDoubleColon) { // Looks like IPv6, add brackets to the host part only hostPart = `[${hostPart}]`; } } // Reconstruct the full URL const fullHost = hostPart + portPart + pathQueryFragmentPart; const testUrl = `wss://${fullHost}`; try { new URL(testUrl); return testUrl; } catch (urlError) { throw new RelayUrlValidationError(`Invalid URL format: "${trimmedUrl}". ` + `Unable to construct a valid WebSocket URL.`, "construction", trimmedUrl); } } /** * Canonicalises a relay URL by lowercasing scheme & host and removing the root pathname. * Path, query and fragment parts keep their case. * Validates the normalized URL for security and throws an error if invalid. */ function normalizeRelayUrl(url) { const preprocessed = preprocessRelayUrl(url); const parsed = new URL(preprocessed); // For the root path ("/"), most libraries omit the trailing slash in their // canonical representation (e.g. `wss://example.com`). Only keep the // pathname when it is not exactly "/". const includePathname = parsed.pathname && parsed.pathname !== "/"; let normalized = `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}`; if (includePathname) { normalized += parsed.pathname; } if (parsed.search) { normalized += parsed.search; } if (parsed.hash) { normalized += parsed.hash; } // Validate the normalized URL for security before returning if (!(0, nip19_1.isValidRelayUrl)(normalized)) { throw new RelayUrlValidationError(`Normalized URL failed security validation: "${normalized}". ` + `The URL may contain invalid characters or unsafe patterns.`, "security", normalized); } return normalized; } /** * Combines normalisation and validation. Returns undefined when the URL is invalid. */ function validateAndNormalizeRelayUrl(url) { try { const normalized = normalizeRelayUrl(url); return (0, nip19_1.isValidRelayUrl)(normalized) ? normalized : undefined; } catch { return undefined; } }