UNPKG

@apidevtools/json-schema-ref-parser

Version:

Parse, Resolve, and Dereference JSON Schema $ref pointers

707 lines (604 loc) 20 kB
import convertPathToPosix from "./convert-path-to-posix.js"; const forwardSlashPattern = /\//g; const protocolPattern = /^(\w{2,}):\/\//i; const jsonPointerSlash = /~1/g; const jsonPointerTilde = /~0/g; import { isWindows } from "./is-windows.js"; const isAbsoluteWin32Path = /^[a-zA-Z]:[\\/]/; // RegExp patterns to URL-encode special characters in local filesystem paths const urlEncodePatterns = [ [/\?/g, "%3F"], [/#/g, "%23"], ] as [RegExp, string][]; // RegExp patterns to URL-decode special characters for local filesystem paths const urlDecodePatterns = [/%23/g, "#", /%24/g, "$", /%26/g, "&", /%2C/g, ",", /%40/g, "@"]; const unsafeDomainSuffixes = [".localhost", ".local", ".internal", ".intranet", ".corp", ".home", ".lan"]; export const parse = (u: string | URL) => new URL(u); /** * Returns resolved target URL relative to a base URL in a manner similar to that of a Web browser resolving an anchor tag HREF. * * @returns */ export function resolve(from: string, to: string) { // we use a non-existent URL to check if its a relative URL const fromUrl = new URL(convertPathToPosix(from), "https://aaa.nonexistanturl.com"); const resolvedUrl = new URL(convertPathToPosix(to), fromUrl); const endSpaces = to.match(/(\s*)$/)?.[1] || ""; if (resolvedUrl.hostname === "aaa.nonexistanturl.com") { // `from` is a relative URL. const { pathname, search, hash } = resolvedUrl; return pathname + search + safeDecodeURIComponent(hash) + endSpaces; } const resolved = resolvedUrl.toString() + endSpaces; // if there is a #, we want to split on the first one only, and decode the part after if (resolved.includes("#")) { const [base, hash] = resolved.split("#", 2); return base + "#" + safeDecodeURIComponent(hash || ""); } return resolved; } function safeDecodeURIComponent(value: string): string { try { return decodeURIComponent(value); } catch { try { // Preserve literal or malformed percent signs while continuing to decode // any valid percent-encoded characters in the same fragment. return decodeURIComponent(value.replace(/%(?![\dA-F]{2})/gi, "%25")); } catch { return value; } } } /** * Returns the current working directory (in Node) or the current page URL (in browsers). * * @returns */ export function cwd() { if (typeof window !== "undefined" && window.location && window.location.href) { const href = window.location.href; if (!href || !href.startsWith("http")) { // try parsing as url, and if it fails, return root url / try { new URL(href); return href; } catch { return "/"; } } return href; } if (typeof process !== "undefined" && process.cwd) { const path = process.cwd(); const lastChar = path.slice(-1); if (lastChar === "/" || lastChar === "\\") { return path; } else { return path + "/"; } } return "/"; } /** * Returns the protocol of the given URL, or `undefined` if it has no protocol. * * @param path * @returns */ export function getProtocol(path: string | undefined) { const match = protocolPattern.exec(path || ""); if (match) { return match[1].toLowerCase(); } return undefined; } /** * Returns the lowercased file extension of the given URL, * or an empty string if it has no extension. * * @param path * @returns */ export function getExtension(path: string) { const pathEnd = path.search(/[?#]/); const pathname = pathEnd >= 0 ? path.substring(0, pathEnd) : path; const lastSlash = Math.max(pathname.lastIndexOf("/"), pathname.lastIndexOf("\\")); const lastDot = pathname.lastIndexOf("."); if (lastDot > lastSlash) { return pathname.substring(lastDot).toLowerCase(); } return ""; } /** * Removes the query, if any, from the given path. * * @param path * @returns */ export function stripQuery(path: string) { const queryIndex = path.indexOf("?"); if (queryIndex >= 0) { path = path.substring(0, queryIndex); } return path; } /** * Returns the hash (URL fragment), of the given path. * If there is no hash, then the root hash ("#") is returned. * * @param path * @returns */ export function getHash(path: undefined | string) { if (!path) { return "#"; } const hashIndex = path.indexOf("#"); if (hashIndex >= 0) { return path.substring(hashIndex); } return "#"; } /** * Removes the hash (URL fragment), if any, from the given path. * * @param path * @returns */ export function stripHash(path: string | undefined) { if (!path) { return ""; } const hashIndex = path.indexOf("#"); if (hashIndex >= 0) { path = path.substring(0, hashIndex); } return path; } /** * Determines whether the given path is an HTTP(S) URL. * * @param path * @returns */ export function isHttp(path: string) { const protocol = getProtocol(path); if (protocol === "http" || protocol === "https") { return true; } else if (protocol === undefined) { // There is no protocol. If we're running in a browser, then assume it's HTTP. return typeof window !== "undefined"; } else { // It's some other protocol, such as "ftp://", "mongodb://", etc. return false; } } /** * Determines whether the given url is an unsafe or internal url. * * @param path - The URL or path to check * @returns true if the URL is unsafe/internal, false otherwise */ export function isUnsafeUrl(path: string | unknown): boolean { if (!path || typeof path !== "string") { return true; } // Trim whitespace and convert to lowercase for comparison const normalizedPath = path.trim().toLowerCase(); // Empty or just whitespace if (!normalizedPath) { return true; } // JavaScript protocols if ( normalizedPath.startsWith("javascript:") || normalizedPath.startsWith("vbscript:") || normalizedPath.startsWith("data:") ) { return true; } // File protocol if (normalizedPath.startsWith("file:")) { return true; } // if we're in the browser, we assume that it is safe if (typeof window !== "undefined" && window.location && window.location.href) { return false; } try { // Try to parse as URL const url = new URL(normalizedPath.startsWith("//") ? "http:" + normalizedPath : normalizedPath); if (isUnsafeHostname(url.hostname)) { return true; } // Check for non-standard ports that might indicate internal services const port = url.port; if (port && isInternalPort(parseInt(port))) { return true; } } catch { // If URL parsing fails, check if it's a relative path or contains suspicious patterns // Relative paths starting with / are generally safe for same-origin if (normalizedPath.startsWith("/") && !normalizedPath.startsWith("//")) { return false; } if (containsUnsafeHostname(normalizedPath)) { return true; } } return false; } /** * Determines whether an HTTP(S) URL is unsafe, including hostnames that resolve * to a non-public IP address. DNS resolution is only available in Node.js; in a * browser, {@link isUnsafeUrl} remains the source of truth and the browser's * own network security model applies. * * DNS lookup failures are allowed to propagate so callers fail closed rather * than fetching a hostname whose addresses could not be validated. */ export interface ResolvedUrlAddress { address: string; family: number; } export interface UrlSafetyResult { unsafe: boolean; addresses?: ResolvedUrlAddress[]; } /** * Checks a URL and, in Node.js, returns the exact public addresses that were * validated. Callers that perform the request must pin their connection to * these addresses; resolving the hostname again would reintroduce a DNS * rebinding race between validation and connection establishment. */ export async function resolveUrlSafety(path: string | unknown): Promise<UrlSafetyResult> { if (isUnsafeUrl(path)) { return { unsafe: true }; } if ( typeof path !== "string" || typeof process === "undefined" || !process.versions?.node || typeof window !== "undefined" ) { return { unsafe: false }; } const parsedUrl = new URL(path.startsWith("//") ? `http:${path}` : path); if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { return { unsafe: false }; } // Keep the Node-only dependency out of browser module graphs. The import is // never evaluated outside Node.js. const dnsModuleName = "node:dns/promises"; const dns = (await import(dnsModuleName)) as { lookup( hostname: string, options: { all: true; verbatim: true }, ): Promise<Array<{ address: string; family: number }>>; }; const addresses: ResolvedUrlAddress[] = await dns.lookup(normalizeHostname(parsedUrl.hostname), { all: true, verbatim: true, }); if (addresses.length === 0 || addresses.some(({ address }) => isUnsafeHostname(address))) { return { unsafe: true }; } return { unsafe: false, addresses }; } export async function isUnsafeUrlWithDns(path: string | unknown): Promise<boolean> { return (await resolveUrlSafety(path)).unsafe; } /** * Helper function to check if a hostname is local or resolves to a non-public literal address. */ function isUnsafeHostname(hostname: string): boolean { const normalizedHostname = normalizeHostname(hostname); if (!normalizedHostname) { return true; } if ( normalizedHostname === "localhost" || unsafeDomainSuffixes.some((suffix) => normalizedHostname.endsWith(suffix)) ) { return true; } const ipv4 = parseIPv4Address(normalizedHostname); if (ipv4) { return isUnsafeIPv4Address(ipv4); } const ipv6 = parseIPv6Address(normalizedHostname); if (ipv6) { return isUnsafeIPv6Address(ipv6); } return false; } function normalizeHostname(hostname: string): string { let normalizedHostname = hostname.trim().toLowerCase(); if (normalizedHostname.startsWith("[") && normalizedHostname.endsWith("]")) { normalizedHostname = normalizedHostname.slice(1, -1); } while (normalizedHostname.endsWith(".")) { normalizedHostname = normalizedHostname.slice(0, -1); } return normalizedHostname; } function parseIPv4Address(ip: string): number[] | undefined { const parts = ip.split("."); if (parts.length !== 4) { return undefined; } const octets = parts.map((part) => { if (!/^\d+$/.test(part)) { return Number.NaN; } return Number(part); }); if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { return undefined; } return octets; } /** * Helper function to check if an IPv4 address is in a non-public range. */ function isUnsafeIPv4Address([a, b, c, d]: number[]): boolean { return ( a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 0 && c === 0) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224 || (a === 255 && b === 255 && c === 255 && d === 255) ); } function parseIPv6Address(ip: string): number[] | undefined { if (!ip.includes(":")) { return undefined; } let normalizedIP = ip; const lastSeparator = normalizedIP.lastIndexOf(":"); const possibleIPv4 = normalizedIP.slice(lastSeparator + 1); if (possibleIPv4.includes(".")) { const ipv4 = parseIPv4Address(possibleIPv4); if (!ipv4) { return undefined; } const firstGroup = ipv4[0] * 256 + ipv4[1]; const secondGroup = ipv4[2] * 256 + ipv4[3]; normalizedIP = `${normalizedIP.slice(0, lastSeparator + 1)}${firstGroup.toString(16)}:${secondGroup.toString(16)}`; } const halves = normalizedIP.split("::"); if (halves.length > 2) { return undefined; } const head = parseIPv6Groups(halves[0]); const tail = halves.length === 2 ? parseIPv6Groups(halves[1]) : []; if (!head || !tail) { return undefined; } if (halves.length === 1) { return head.length === 8 ? head : undefined; } const missingGroups = 8 - head.length - tail.length; if (missingGroups < 1) { return undefined; } return [...head, ...Array<number>(missingGroups).fill(0), ...tail]; } function parseIPv6Groups(groups: string): number[] | undefined { if (!groups) { return []; } const parsedGroups = groups.split(":").map((group) => { if (!/^[\da-f]{1,4}$/i.test(group)) { return Number.NaN; } return Number.parseInt(group, 16); }); if (parsedGroups.some((group) => !Number.isInteger(group) || group < 0 || group > 0xffff)) { return undefined; } return parsedGroups; } /** * Helper function to check if an IPv6 address is in a non-public range. */ function isUnsafeIPv6Address(groups: number[]): boolean { if (groups.length !== 8) { return false; } const isUnspecified = groups.every((group) => group === 0); const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1; const isUniqueLocal = (groups[0] & 0xfe00) === 0xfc00; const isLinkLocal = (groups[0] & 0xffc0) === 0xfe80; const isMulticast = (groups[0] & 0xff00) === 0xff00; if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal || isMulticast) { return true; } const mappedIPv4 = getMappedIPv4Address(groups); return mappedIPv4 ? isUnsafeIPv4Address(mappedIPv4) : false; } function getMappedIPv4Address(groups: number[]): number[] | undefined { const firstFiveGroupsAreZero = groups.slice(0, 5).every((group) => group === 0); const firstSixGroupsAreZero = firstFiveGroupsAreZero && groups[5] === 0; const isIPv4Mapped = firstFiveGroupsAreZero && groups[5] === 0xffff; if (!firstSixGroupsAreZero && !isIPv4Mapped) { return undefined; } return [Math.floor(groups[6] / 256), groups[6] % 256, Math.floor(groups[7] / 256), groups[7] % 256]; } function containsUnsafeHostname(value: string): boolean { const candidates = value .split(/[\s/?#]+/) .map((candidate) => candidate.replace(/^[a-z][\d+.a-z-]*:\/\//i, "").replace(/:\d+$/, "")) .filter(Boolean); return candidates.some((candidate) => isUnsafeHostname(candidate)); } /** * Helper function to check if a port is typically used for internal services */ function isInternalPort(port: number): boolean { const internalPorts = [ 22, // SSH 23, // Telnet 25, // SMTP 53, // DNS 135, // RPC 139, // NetBIOS 445, // SMB 993, // IMAPS 995, // POP3S 1433, // SQL Server 1521, // Oracle 3306, // MySQL 3389, // RDP 5432, // PostgreSQL 5900, // VNC 6379, // Redis 8080, // Common internal web 8443, // Common internal HTTPS 9200, // Elasticsearch 27017, // MongoDB ]; return internalPorts.includes(port); } /** * Determines whether the given path is a filesystem path. * This includes "file://" URLs. * * @param path * @returns */ export function isFileSystemPath(path: string | undefined) { // @ts-ignore if (typeof window !== "undefined" || (typeof process !== "undefined" && process.browser)) { // We're running in a browser, so assume that all paths are URLs. // This way, even relative paths will be treated as URLs rather than as filesystem paths return false; } const protocol = getProtocol(path); return protocol === undefined || protocol === "file"; } /** * Converts a filesystem path to a properly-encoded URL. * * This is intended to handle situations where JSON Schema $Ref Parser is called * with a filesystem path that contains characters which are not allowed in URLs. * * @example * The following filesystem paths would be converted to the following URLs: * * <"!@#$%^&*+=?'>.json ==> %3C%22!@%23$%25%5E&*+=%3F\'%3E.json * C:\\My Documents\\File (1).json ==> C:/My%20Documents/File%20(1).json * file://Project #42/file.json ==> file://Project%20%2342/file.json * * @param path * @returns */ export function fromFileSystemPath(path: string) { // Step 1: On Windows, replace backslashes with forward slashes, // rather than encoding them as "%5C" if (isWindows()) { const projectDir = cwd(); const upperPath = path.toUpperCase(); const projectDirPosixPath = convertPathToPosix(projectDir); const posixUpper = projectDirPosixPath.toUpperCase(); const hasProjectDir = upperPath.includes(posixUpper); const hasProjectUri = upperPath.includes(posixUpper); const isAbsolutePath = isAbsoluteWin32Path.test(path) || path.startsWith("http://") || path.startsWith("https://") || path.startsWith("file://"); if (!(hasProjectDir || hasProjectUri || isAbsolutePath) && !projectDir.startsWith("http")) { const join = (a: string, b: string) => { if (a.endsWith("/") || a.endsWith("\\")) { return a + b; } else { return a + "/" + b; } }; path = join(projectDir, path); } path = convertPathToPosix(path); } // Step 2: `encodeURI` will take care of MOST characters path = encodeURI(path); // Step 3: Manually encode characters that are not encoded by `encodeURI`. // This includes characters such as "#" and "?", which have special meaning in URLs, // but are just normal characters in a filesystem path. for (const pattern of urlEncodePatterns) { path = path.replace(pattern[0], pattern[1]); } return path; } /** * Converts a URL to a local filesystem path. */ export function toFileSystemPath(path: string | undefined, keepFileProtocol?: boolean): string { // Bare "%" characters are valid in filesystem paths, but they make `decodeURI` throw. // Escape only the non-encoded ones so percent-encoded sequences still decode normally. path = path!.replace(/%(?![0-9A-Fa-f]{2})/g, "%25"); // Step 1: `decodeURI` will decode characters such as Cyrillic characters, spaces, etc. path = decodeURI(path!); // Step 2: Manually decode characters that are not decoded by `decodeURI`. // This includes characters such as "#" and "?", which have special meaning in URLs, // but are just normal characters in a filesystem path. for (let i = 0; i < urlDecodePatterns.length; i += 2) { path = path.replace(urlDecodePatterns[i], urlDecodePatterns[i + 1] as string); } // Step 3: If it's a "file://" URL, then format it consistently // or convert it to a local filesystem path let isFileUrl = path.toLowerCase().startsWith("file://"); if (isFileUrl) { // Strip-off the protocol, and the initial "/", if there is one path = path.replace(/^file:\/\//i, "").replace(/^\//, ""); // insert a colon (":") after the drive letter on Windows if (isWindows() && path[1] === "/") { path = `${path[0]}:${path.substring(1)}`; } if (keepFileProtocol) { // Return the consistently-formatted "file://" URL path = "file:///" + path; } else { // Convert the "file://" URL to a local filesystem path. // On Windows, it will start with something like "C:/". // On Posix, it will start with "/" isFileUrl = false; path = isWindows() ? path : "/" + path; } } // Step 4: Normalize Windows paths (unless it's a "file://" URL) if (isWindows() && !isFileUrl) { // Replace forward slashes with backslashes path = path.replace(forwardSlashPattern, "\\"); // Capitalize the drive letter if (path.match(/^[a-z]:\\/i)) { path = path[0].toUpperCase() + path.substring(1); } } return path; } /** * Converts a $ref pointer to a valid JSON Path. * * @param pointer * @returns */ export function safePointerToPath(pointer: string) { if (pointer.length <= 1 || pointer[0] !== "#" || pointer[1] !== "/") { return []; } return pointer .slice(2) .split("/") .map((value: string) => { return value.replace(jsonPointerSlash, "/").replace(jsonPointerTilde, "~"); }); }