CVE-2026-27795
Description
LangChain is a framework for building LLM-powered applications. Prior to version 1.1.8, a redirect-based Server-Side Request Forgery (SSRF) bypass exists in RecursiveUrlLoader in @langchain/community. The loader validates the initial URL but allows the underlying fetch to follow redirects automatically, which permits a transition from a safe public URL to an internal or metadata endpoint without revalidation. This is a bypass of the SSRF protections introduced in 1.1.14 (CVE-2026-26019). Users should upgrade to @langchain/community 1.1.18, which validates every redirect hop by disabling automatic redirects and re-validating Location targets before following them. In this version, automatic redirects are disabled (redirect: "manual"), each 3xx Location is resolved and validated with validateSafeUrl() before the next request, and a maximum redirect limit prevents infinite loops.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@langchain/communitynpm | < 1.1.18 | 1.1.18 |
Affected products
1- cpe:2.3:a:langchain:langchain_community:*:*:*:*:*:node.js:*:*Range: <1.1.18
Patches
22812d2b2b9fdfix(community): validate redirects in RecursiveUrlLoader (#10116)
3 files changed · +134 −6
.changeset/ssrf-redirect-bypass.md+5 −0 added@@ -0,0 +1,5 @@ +--- +"@langchain/community": patch +--- + +Validate redirects in RecursiveUrlLoader to prevent SSRF bypasses.
libs/langchain-community/src/document_loaders/tests/recursive_url.test.ts+100 −1 modified@@ -1,6 +1,105 @@ -import { test, describe, expect } from "@jest/globals"; +import { + test, + describe, + expect, + jest, + beforeEach, + afterEach, +} from "@jest/globals"; import { RecursiveUrlLoader } from "../web/recursive_url.js"; +const _originalFetch = globalThis.fetch; + +describe("RecursiveUrlLoader - Redirect SSRF Protection", () => { + afterEach(() => { + globalThis.fetch = _originalFetch; + }); + + test("blocks redirects to private IPs (localhost)", async () => { + globalThis.fetch = jest.fn<typeof fetch>().mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "http://127.0.0.1/admin" }, + }) + ); + + const loader = new RecursiveUrlLoader("https://example.com/", { + maxDepth: 0, + }); + const docs = await loader.load(); + expect(docs).toHaveLength(0); + }); + + test("blocks redirects to cloud metadata IPs", async () => { + globalThis.fetch = jest.fn<typeof fetch>().mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "http://169.254.169.254/latest/meta-data/" }, + }) + ); + + const loader = new RecursiveUrlLoader("https://example.com/", { + maxDepth: 0, + }); + const docs = await loader.load(); + expect(docs).toHaveLength(0); + }); + + test("blocks redirects to private network ranges", async () => { + globalThis.fetch = jest.fn<typeof fetch>().mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "http://192.168.1.1/internal" }, + }) + ); + + const loader = new RecursiveUrlLoader("https://example.com/", { + maxDepth: 0, + }); + const docs = await loader.load(); + expect(docs).toHaveLength(0); + }); + + test("follows safe redirects", async () => { + globalThis.fetch = jest + .fn<typeof fetch>() + .mockResolvedValueOnce( + new Response(null, { + status: 301, + headers: { Location: "https://www.example.com/" }, + }) + ) + .mockResolvedValueOnce( + new Response("<html><body>Hello</body></html>", { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + ); + + const loader = new RecursiveUrlLoader("https://example.com/", { + maxDepth: 0, + }); + const docs = await loader.load(); + expect(docs).toHaveLength(1); + expect(docs[0].pageContent).toContain("Hello"); + }); + + test("throws on too many redirects", async () => { + globalThis.fetch = jest.fn<typeof fetch>().mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "https://example.com/loop" }, + }) + ); + + const loader = new RecursiveUrlLoader("https://example.com/", { + maxDepth: 0, + }); + const docs = await loader.load(); + expect(docs).toHaveLength(0); + }); +}); + describe("RecursiveUrlLoader - URL Origin Validation", () => { describe("preventOutside origin checking", () => { test("allows URLs with same origin", async () => {
libs/langchain-community/src/document_loaders/web/recursive_url.ts+29 −5 modified@@ -10,6 +10,9 @@ import { const virtualConsole = new VirtualConsole(); virtualConsole.on("error", () => {}); +const MAX_REDIRECTS = 10; +const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]); + export interface RecursiveUrlLoaderOptions { excludeDirs?: string[]; extractor?: (text: string) => string; @@ -59,9 +62,32 @@ export class RecursiveUrlLoader options: { timeout: number } & RequestInit ): Promise<Response> { const { timeout, ...rest } = options; - return this.caller.call(() => - fetch(resource, { ...rest, signal: AbortSignal.timeout(timeout) }) - ); + let currentUrl = resource; + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + validateSafeUrl(currentUrl, { allowHttp: true }); + + const response = await this.caller.call(() => + fetch(currentUrl, { + ...rest, + redirect: "manual", + signal: AbortSignal.timeout(timeout), + }) + ); + + if (REDIRECT_CODES.has(response.status)) { + const location = response.headers.get("location"); + if (!location) { + throw new Error("Redirect response missing Location header"); + } + currentUrl = new URL(location, currentUrl).href; + continue; + } + + return response; + } + + throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`); } private getChildLinks(html: string, baseUrl: string): Array<string> { @@ -143,7 +169,6 @@ export class RecursiveUrlLoader private async getUrlAsDoc(url: string): Promise<Document | null> { let res; try { - validateSafeUrl(url, { allowHttp: true }); res = await this.fetchWithTimeout(url, { timeout: this.timeout }); res = await res.text(); } catch { @@ -171,7 +196,6 @@ export class RecursiveUrlLoader let res; try { - await validateSafeUrl(url, { allowHttp: true }); res = await this.fetchWithTimeout(url, { timeout: this.timeout }); res = await res.text(); } catch {
d5e3db0d01abfeat(core,community): ssrf hardening (#9990)
9 files changed · +858 −2
.changeset/add-ssrf-protection.md+8 −0 added@@ -0,0 +1,8 @@ +--- +"@langchain/core": minor +"@langchain/community": patch +--- + +feat(core): Add SSRF protection module (`@langchain/core/utils/ssrf`) with utilities for validating URLs against private IPs, cloud metadata endpoints, and localhost. + +fix(community): Harden `RecursiveUrlLoader` against SSRF attacks by integrating `validateSafeUrl` and replacing string-based URL comparison with origin-based `isSameOrigin` from the shared SSRF module.
libs/langchain-community/package.json+1 −1 modified@@ -230,7 +230,7 @@ "@huggingface/transformers": "^3.8.1", "@ibm-cloud/watsonx-ai": "*", "@lancedb/lancedb": "^0.19.1", - "@langchain/core": "^1.0.0", + "@langchain/core": "workspace:*", "@layerup/layerup-security": "^1.5.12", "@libsql/client": "^0.17.0", "@mendable/firecrawl-js": "^1.4.3",
libs/langchain-community/src/document_loaders/tests/recursive_url.test.ts+83 −0 added@@ -0,0 +1,83 @@ +import { test, describe, expect } from "@jest/globals"; +import { RecursiveUrlLoader } from "../web/recursive_url.js"; + +describe("RecursiveUrlLoader - URL Origin Validation", () => { + describe("preventOutside origin checking", () => { + test("allows URLs with same origin", async () => { + const baseUrl = "https://example.com/docs/"; + const html = '<a href="https://example.com/docs/page">Link</a>'; + + const loader = new RecursiveUrlLoader(baseUrl, { + preventOutside: true, + }); + + // We can't directly test getChildLinks, but we can verify the behavior + // by checking the loader doesn't throw and the logic is sound. + // This test verifies that the fix uses origin comparison. + const url1 = "https://example.com/docs/page"; + const url2 = baseUrl; + expect(new URL(url1).origin).toBe(new URL(url2).origin); + }); + + test("blocks cross-origin URLs with preventOutside", async () => { + // The key test: verify that subdomain-based SSRF bypasses are blocked + const baseUrl = "https://example.com"; + const maliciousUrl = "https://example.com.attacker.com"; + + // The old vulnerable code would have allowed this: + // "https://example.com.attacker.com".startsWith("https://example.com") === true + const vulnerableCheck = maliciousUrl.startsWith(baseUrl); + expect(vulnerableCheck).toBe(true); // vulnerable approach allows this + + // But the fixed code should reject it: + // new URL(maliciousUrl).origin !== new URL(baseUrl).origin + const secureCheck = + new URL(maliciousUrl).origin === new URL(baseUrl).origin; + expect(secureCheck).toBe(false); // secure approach blocks this + }); + + test("blocks port-based SSRF bypasses", async () => { + const baseUrl = "https://example.com/"; + const maliciousUrl = "https://example.com:8080/"; + + // Different ports = different origins + const secureCheck = + new URL(maliciousUrl).origin === new URL(baseUrl).origin; + expect(secureCheck).toBe(false); + }); + + test("blocks scheme-based SSRF bypasses", async () => { + const baseUrl = "https://example.com/"; + const maliciousUrl = "http://example.com/"; // Different scheme + + // Different schemes = different origins + const secureCheck = + new URL(maliciousUrl).origin === new URL(baseUrl).origin; + expect(secureCheck).toBe(false); + }); + + test("allows same-host paths regardless of path", async () => { + const url1 = "https://example.com/path1"; + const url2 = "https://example.com/path2"; + + // Same origin, different paths should match + const result = new URL(url1).origin === new URL(url2).origin; + expect(result).toBe(true); + }); + + test("handles invalid URLs gracefully", async () => { + const baseUrl = "https://example.com/"; + const invalidUrl = "not a valid url"; + + // Invalid URLs should return false without throwing + try { + const result = new URL(invalidUrl).origin === new URL(baseUrl).origin; + // Should not reach here + expect(result).toBe(false); + } catch { + // Expected: invalid URL throws, and implementation handles it + expect(true).toBe(true); + } + }); + }); +});
libs/langchain-community/src/document_loaders/web/recursive_url.ts+4 −1 modified@@ -1,6 +1,7 @@ import { JSDOM, VirtualConsole } from "jsdom"; import { Document } from "@langchain/core/documents"; import { AsyncCaller } from "@langchain/core/utils/async_caller"; +import { isSameOrigin, validateSafeUrl } from "@langchain/core/utils/ssrf"; import { BaseDocumentLoader, DocumentLoader, @@ -102,7 +103,7 @@ export class RecursiveUrlLoader continue; if (link.startsWith("http")) { - const isAllowed = !this.preventOutside || link.startsWith(baseUrl); + const isAllowed = !this.preventOutside || isSameOrigin(link, baseUrl); if (isAllowed) absolutePaths.push(link); } else if (link.startsWith("//")) { const base = new URL(baseUrl); @@ -142,6 +143,7 @@ export class RecursiveUrlLoader private async getUrlAsDoc(url: string): Promise<Document | null> { let res; try { + validateSafeUrl(url, { allowHttp: true }); res = await this.fetchWithTimeout(url, { timeout: this.timeout }); res = await res.text(); } catch { @@ -169,6 +171,7 @@ export class RecursiveUrlLoader let res; try { + await validateSafeUrl(url, { allowHttp: true }); res = await this.fetchWithTimeout(url, { timeout: this.timeout }); res = await res.text(); } catch {
libs/langchain-core/package.json+11 −0 modified@@ -702,6 +702,17 @@ "default": "./dist/utils/math.js" } }, + "./utils/ssrf": { + "input": "./src/utils/ssrf.ts", + "require": { + "types": "./dist/utils/ssrf.d.cts", + "default": "./dist/utils/ssrf.cjs" + }, + "import": { + "types": "./dist/utils/ssrf.d.ts", + "default": "./dist/utils/ssrf.js" + } + }, "./utils/stream": { "input": "./src/utils/stream.ts", "require": {
libs/langchain-core/src/load/import_map.ts+1 −0 modified@@ -53,6 +53,7 @@ export * as utils__hash from "../utils/hash.js"; export * as utils__json_patch from "../utils/json_patch.js"; export * as utils__json_schema from "../utils/json_schema.js"; export * as utils__math from "../utils/math.js"; +export * as utils__ssrf from "../utils/ssrf.js"; export * as utils__stream from "../utils/stream.js"; export * as utils__testing from "../utils/testing/index.js"; export * as utils__tiktoken from "../utils/tiktoken.js";
libs/langchain-core/src/utils/ssrf.ts+401 −0 added@@ -0,0 +1,401 @@ +// Private IP ranges (RFC 1918, loopback, link-local, etc.) +const PRIVATE_IP_RANGES = [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "169.254.0.0/16", + "0.0.0.0/8", + "::1/128", + "fc00::/7", + "fe80::/10", + "ff00::/8", +]; + +// Cloud metadata IPs +const CLOUD_METADATA_IPS = [ + "169.254.169.254", + "169.254.170.2", + "100.100.100.200", +]; + +// Cloud metadata hostnames (case-insensitive) +const CLOUD_METADATA_HOSTNAMES = [ + "metadata.google.internal", + "metadata", + "instance-data", +]; + +// Localhost variations +const LOCALHOST_NAMES = ["localhost", "localhost.localdomain"]; + +/** + * IPv4 regex: four octets 0-255 + */ +const IPV4_REGEX = + /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; + +/** + * Check if a string is a valid IPv4 address. + */ +function isIPv4(ip: string): boolean { + return IPV4_REGEX.test(ip); +} + +/** + * Check if a string is a valid IPv6 address. + * Uses expandIpv6 for validation. + */ +function isIPv6(ip: string): boolean { + return expandIpv6(ip) !== null; +} + +/** + * Check if a string is a valid IP address (IPv4 or IPv6). + */ +function isIP(ip: string): boolean { + return isIPv4(ip) || isIPv6(ip); +} + +/** + * Parse an IP address string to an array of integers (for IPv4) or an array of 16-bit values (for IPv6) + * Returns null if the IP is invalid. + */ +function parseIp(ip: string): number[] | null { + if (isIPv4(ip)) { + return ip.split(".").map((octet) => parseInt(octet, 10)); + } else if (isIPv6(ip)) { + // Normalize IPv6 + const expanded = expandIpv6(ip); + if (!expanded) return null; + const parts = expanded.split(":"); + const result: number[] = []; + for (const part of parts) { + result.push(parseInt(part, 16)); + } + return result; + } + return null; +} + +/** + * Expand compressed IPv6 address to full form. + */ +function expandIpv6(ip: string): string | null { + // Basic structural validation + if (!ip || typeof ip !== "string") return null; + + // Must contain at least one colon + if (!ip.includes(":")) return null; + + // Check for invalid characters + if (!/^[0-9a-fA-F:]+$/.test(ip)) return null; + + let normalized = ip; + + // Handle :: compression + if (normalized.includes("::")) { + const parts = normalized.split("::"); + if (parts.length > 2) return null; // Multiple :: is invalid + + const [left, right] = parts; + const leftParts = left ? left.split(":") : []; + const rightParts = right ? right.split(":") : []; + const missing = 8 - (leftParts.length + rightParts.length); + + if (missing < 0) return null; + + const zeros = Array(missing).fill("0"); + normalized = [...leftParts, ...zeros, ...rightParts] + .filter((p) => p !== "") + .join(":"); + } + + const parts = normalized.split(":"); + if (parts.length !== 8) return null; + + // Validate each part is a valid hex group (1-4 chars) + for (const part of parts) { + if (part.length === 0 || part.length > 4) return null; + if (!/^[0-9a-fA-F]+$/.test(part)) return null; + } + + return parts.map((p) => p.padStart(4, "0").toLowerCase()).join(":"); +} + +/** + * Parse CIDR notation (e.g., "192.168.0.0/24") into network address and prefix length. + */ +function parseCidr( + cidr: string +): { addr: number[]; prefixLen: number; isIpv6: boolean } | null { + const [addrStr, prefixStr] = cidr.split("/"); + if (!addrStr || !prefixStr) { + return null; + } + + const addr = parseIp(addrStr); + if (!addr) { + return null; + } + + const prefixLen = parseInt(prefixStr, 10); + if (isNaN(prefixLen)) { + return null; + } + + const isIpv6 = isIPv6(addrStr); + + if (isIpv6 && prefixLen > 128) { + return null; + } + if (!isIpv6 && prefixLen > 32) { + return null; + } + + return { addr, prefixLen, isIpv6 }; +} + +/** + * Check if an IP address is in a given CIDR range. + */ +function isIpInCidr(ip: string, cidr: string): boolean { + const ipParsed = parseIp(ip); + if (!ipParsed) { + return false; + } + + const cidrParsed = parseCidr(cidr); + if (!cidrParsed) { + return false; + } + + // Check IPv4 vs IPv6 mismatch + const isIpv6 = isIPv6(ip); + if (isIpv6 !== cidrParsed.isIpv6) { + return false; + } + + const { addr: cidrAddr, prefixLen } = cidrParsed; + + // Convert to bits and compare + if (isIpv6) { + // IPv6: each element is 16 bits + for (let i = 0; i < Math.ceil(prefixLen / 16); i++) { + const bitsToCheck = Math.min(16, prefixLen - i * 16); + const mask = (0xffff << (16 - bitsToCheck)) & 0xffff; + if ((ipParsed[i] & mask) !== (cidrAddr[i] & mask)) { + return false; + } + } + } else { + // IPv4: each element is 8 bits + for (let i = 0; i < Math.ceil(prefixLen / 8); i++) { + const bitsToCheck = Math.min(8, prefixLen - i * 8); + const mask = (0xff << (8 - bitsToCheck)) & 0xff; + if ((ipParsed[i] & mask) !== (cidrAddr[i] & mask)) { + return false; + } + } + } + + return true; +} + +/** + * Check if an IP address is private (RFC 1918, loopback, link-local, etc.) + */ +export function isPrivateIp(ip: string): boolean { + // Validate it's a proper IP + if (!isIP(ip)) { + return false; + } + + for (const range of PRIVATE_IP_RANGES) { + if (isIpInCidr(ip, range)) { + return true; + } + } + + return false; +} + +/** + * Check if a hostname or IP is a known cloud metadata endpoint. + */ +export function isCloudMetadata(hostname: string, ip?: string): boolean { + // Check if it's a known metadata IP + if (CLOUD_METADATA_IPS.includes(ip || "")) { + return true; + } + + // Check if hostname matches (case-insensitive) + const lowerHostname = hostname.toLowerCase(); + if (CLOUD_METADATA_HOSTNAMES.includes(lowerHostname)) { + return true; + } + + return false; +} + +/** + * Check if a hostname or IP is localhost. + */ +export function isLocalhost(hostname: string, ip?: string): boolean { + // Check if it's a localhost IP + if (ip) { + // Check for typical localhost IPs (loopback range) + if (ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0") { + return true; + } + // Check if IP starts with 127. (entire loopback range) + if (ip.startsWith("127.")) { + return true; + } + } + + // Check if hostname is localhost + const lowerHostname = hostname.toLowerCase(); + if (LOCALHOST_NAMES.includes(lowerHostname)) { + return true; + } + + return false; +} + +/** + * Validate that a URL is safe to connect to. + * Performs static validation checks against hostnames and direct IP addresses. + * Does not perform DNS resolution. + * + * @param url URL to validate + * @param options.allowPrivate Allow private IPs (default: false) + * @param options.allowHttp Allow http:// scheme (default: false) + * @returns The validated URL + * @throws Error if URL is not safe + */ +export function validateSafeUrl( + url: string, + options?: { allowPrivate?: boolean; allowHttp?: boolean } +): string { + const allowPrivate = options?.allowPrivate ?? false; + const allowHttp = options?.allowHttp ?? false; + + try { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + const hostname = parsedUrl.hostname; + if (!hostname) { + throw new Error("URL missing hostname."); + } + + // Check if it's a cloud metadata endpoint (always blocked) + if (isCloudMetadata(hostname)) { + throw new Error(`URL points to cloud metadata endpoint: ${hostname}`); + } + + // Check if it's localhost (blocked unless allowPrivate is true) + if (isLocalhost(hostname)) { + if (!allowPrivate) { + throw new Error(`URL points to localhost: ${hostname}`); + } + return url; + } + + // Check scheme (after localhost checks to give better error messages) + const scheme = parsedUrl.protocol; + if (scheme !== "http:" && scheme !== "https:") { + throw new Error( + `Invalid URL scheme: ${scheme}. Only http and https are allowed.` + ); + } + + if (scheme === "http:" && !allowHttp) { + throw new Error( + "HTTP scheme not allowed. Use HTTPS or set allowHttp: true." + ); + } + + // If hostname is already an IP, validate it directly + if (isIP(hostname)) { + const ip = hostname; + + // Check if it's localhost first (before private IP check) + if (isLocalhost(hostname, ip)) { + if (!allowPrivate) { + throw new Error(`URL points to localhost: ${hostname}`); + } + return url; + } + + // Cloud metadata is always blocked + if (isCloudMetadata(hostname, ip)) { + throw new Error( + `URL resolves to cloud metadata IP: ${ip} (${hostname})` + ); + } + + // Check private IPs + if (isPrivateIp(ip)) { + if (!allowPrivate) { + throw new Error( + `URL resolves to private IP: ${ip} (${hostname}). Set allowPrivate: true to allow.` + ); + } + } + + return url; + } + + // For regular hostnames, we've already done all hostname-based checks above + // (cloud metadata, localhost). If those passed, the URL is safe. + // We don't perform DNS resolution in this environment-agnostic function. + return url; + } catch (error) { + if (error && typeof error === "object" && "message" in error) { + throw error; + } + throw new Error(`URL validation failed: ${error}`); + } +} + +/** + * Check if a URL is safe to connect to (non-throwing version). + * + * @param url URL to check + * @param options.allowPrivate Allow private IPs (default: false) + * @param options.allowHttp Allow http:// scheme (default: false) + * @returns true if URL is safe, false otherwise + */ +export function isSafeUrl( + url: string, + options?: { allowPrivate?: boolean; allowHttp?: boolean } +): boolean { + try { + validateSafeUrl(url, options); + return true; + } catch { + return false; + } +} + +/** + * Check if two URLs have the same origin (scheme, host, port). + * Uses semantic URL parsing to prevent SSRF bypasses via URL variations. + * + * @param url1 First URL + * @param url2 Second URL + * @returns true if both URLs have the same origin, false otherwise + */ +export function isSameOrigin(url1: string, url2: string): boolean { + try { + return new URL(url1).origin === new URL(url2).origin; + } catch { + return false; + } +}
libs/langchain-core/src/utils/tests/ssrf.test.ts+348 −0 added@@ -0,0 +1,348 @@ +import { describe, test, expect } from "vitest"; +import { + isPrivateIp, + isCloudMetadata, + isLocalhost, + validateSafeUrl, + isSafeUrl, + isSameOrigin, +} from "../ssrf.js"; + +describe("isPrivateIp", () => { + // RFC 1918 private ranges + test("should identify 10.x.x.x as private", () => { + expect(isPrivateIp("10.0.0.1")).toBe(true); + expect(isPrivateIp("10.255.255.255")).toBe(true); + expect(isPrivateIp("10.128.0.0")).toBe(true); + }); + + test("should identify 172.16.x.x-172.31.x.x as private", () => { + expect(isPrivateIp("172.16.0.0")).toBe(true); + expect(isPrivateIp("172.16.255.255")).toBe(true); + expect(isPrivateIp("172.31.255.255")).toBe(true); + expect(isPrivateIp("172.20.0.1")).toBe(true); + }); + + test("should identify 192.168.x.x as private", () => { + expect(isPrivateIp("192.168.0.0")).toBe(true); + expect(isPrivateIp("192.168.255.255")).toBe(true); + expect(isPrivateIp("192.168.1.1")).toBe(true); + }); + + test("should identify loopback range 127.x.x.x as private", () => { + expect(isPrivateIp("127.0.0.1")).toBe(true); + expect(isPrivateIp("127.255.255.255")).toBe(true); + expect(isPrivateIp("127.0.0.0")).toBe(true); + }); + + test("should identify link-local range 169.254.x.x as private", () => { + expect(isPrivateIp("169.254.0.0")).toBe(true); + expect(isPrivateIp("169.254.255.255")).toBe(true); + expect(isPrivateIp("169.254.169.254")).toBe(true); + }); + + test("should identify 0.0.0.x as private", () => { + expect(isPrivateIp("0.0.0.1")).toBe(true); + expect(isPrivateIp("0.0.0.0")).toBe(true); + expect(isPrivateIp("0.255.255.255")).toBe(true); + }); + + test("should identify public IPs as not private", () => { + expect(isPrivateIp("8.8.8.8")).toBe(false); + expect(isPrivateIp("1.1.1.1")).toBe(false); + expect(isPrivateIp("208.67.222.222")).toBe(false); + }); + + test("should handle IPv6 private addresses", () => { + expect(isPrivateIp("::1")).toBe(true); // loopback + expect(isPrivateIp("fc00::1")).toBe(true); // Unique Local Address + expect(isPrivateIp("fe80::1")).toBe(true); // Link-local + expect(isPrivateIp("ff00::1")).toBe(true); // Multicast + }); + + test("should handle IPv6 public addresses", () => { + expect(isPrivateIp("2001:4860:4860::8888")).toBe(false); // Google Public DNS + }); + + test("should reject invalid IPs", () => { + expect(isPrivateIp("invalid")).toBe(false); + expect(isPrivateIp("256.256.256.256")).toBe(false); + expect(isPrivateIp("")).toBe(false); + }); +}); + +describe("isCloudMetadata", () => { + test("should identify known metadata IPs", () => { + expect(isCloudMetadata("example.com", "169.254.169.254")).toBe(true); + expect(isCloudMetadata("example.com", "169.254.170.2")).toBe(true); + expect(isCloudMetadata("example.com", "100.100.100.200")).toBe(true); + }); + + test("should identify known metadata hostnames", () => { + expect(isCloudMetadata("metadata.google.internal")).toBe(true); + expect(isCloudMetadata("metadata")).toBe(true); + expect(isCloudMetadata("instance-data")).toBe(true); + }); + + test("should be case-insensitive for hostnames", () => { + expect(isCloudMetadata("METADATA")).toBe(true); + expect(isCloudMetadata("Metadata.Google.Internal")).toBe(true); + expect(isCloudMetadata("INSTANCE-DATA")).toBe(true); + }); + + test("should reject normal hostnames", () => { + expect(isCloudMetadata("example.com")).toBe(false); + expect(isCloudMetadata("google.com")).toBe(false); + expect(isCloudMetadata("localhost")).toBe(false); + }); + + test("should reject normal IPs", () => { + expect(isCloudMetadata("example.com", "8.8.8.8")).toBe(false); + expect(isCloudMetadata("example.com", "1.1.1.1")).toBe(false); + }); +}); + +describe("isLocalhost", () => { + test("should identify localhost hostname", () => { + expect(isLocalhost("localhost")).toBe(true); + expect(isLocalhost("localhost.localdomain")).toBe(true); + }); + + test("should be case-insensitive", () => { + expect(isLocalhost("LOCALHOST")).toBe(true); + expect(isLocalhost("LocalHost")).toBe(true); + }); + + test("should identify localhost IPs", () => { + expect(isLocalhost("example.com", "127.0.0.1")).toBe(true); + expect(isLocalhost("example.com", "::1")).toBe(true); + expect(isLocalhost("example.com", "0.0.0.0")).toBe(true); + }); + + test("should reject normal hostnames", () => { + expect(isLocalhost("example.com")).toBe(false); + expect(isLocalhost("google.com")).toBe(false); + }); + + test("should reject normal IPs", () => { + expect(isLocalhost("example.com", "8.8.8.8")).toBe(false); + expect(isLocalhost("example.com", "192.168.1.1")).toBe(false); + }); +}); + +describe("validateSafeUrl", () => { + test("should accept valid public HTTPS URLs", () => { + const result = validateSafeUrl("https://example.com/path"); + expect(result).toBe("https://example.com/path"); + }); + + test("should reject localhost by default", () => { + expect(() => validateSafeUrl("https://localhost:8000/path")).toThrow( + /localhost/ + ); + }); + + test("should allow localhost with allowPrivate flag", () => { + const result = validateSafeUrl("https://localhost:8000/path", { + allowPrivate: true, + }); + expect(result).toBe("https://localhost:8000/path"); + }); + + test("should reject HTTP by default", () => { + // Using a public domain to focus on scheme check + expect(() => validateSafeUrl("http://example.com/path")).toThrow( + /HTTP scheme not allowed/ + ); + }); + + test("should allow HTTP with allowHttp flag", () => { + const result = validateSafeUrl("http://example.com/path", { + allowHttp: true, + }); + expect(result).toBe("http://example.com/path"); + }); + + test("should reject invalid URL schemes", () => { + expect(() => validateSafeUrl("ftp://example.com")).toThrow(); + expect(() => validateSafeUrl("file:///etc/passwd")).toThrow(); + expect(() => validateSafeUrl("javascript:alert(1)")).toThrow(); + }); + + test("should reject URLs with missing hostname", () => { + expect(() => validateSafeUrl("https://")).toThrow( + /invalid|missing hostname/i + ); + }); + + test("should reject cloud metadata endpoints", () => { + // Direct metadata hostnames + expect(() => validateSafeUrl("https://metadata")).toThrow(/metadata/); + + expect(() => validateSafeUrl("https://instance-data")).toThrow( + /instance-data/ + ); + }); + + test("should reject private IPs by default", () => { + expect(() => validateSafeUrl("https://192.168.1.1")).toThrow( + /private|allowPrivate/i + ); + + expect(() => validateSafeUrl("https://10.0.0.1")).toThrow( + /private|allowPrivate/i + ); + }); + + test("should allow private IPs with allowPrivate flag", () => { + const result = validateSafeUrl("https://192.168.1.1", { + allowPrivate: true, + }); + expect(result).toBe("https://192.168.1.1"); + }); + + test("should handle 127.0.0.1 as localhost", () => { + expect(() => validateSafeUrl("https://127.0.0.1")).toThrow(/localhost/i); + + const result = validateSafeUrl("https://127.0.0.1", { + allowPrivate: true, + }); + expect(result).toBe("https://127.0.0.1"); + }); +}); + +describe("isSafeUrl", () => { + test("should return true for safe URLs", () => { + const result = isSafeUrl("https://example.com"); + expect(result).toBe(true); + }); + + test("should return false for localhost by default", () => { + const result = isSafeUrl("https://localhost"); + expect(result).toBe(false); + }); + + test("should return true for localhost with allowPrivate", () => { + const result = isSafeUrl("https://localhost", { + allowPrivate: true, + }); + expect(result).toBe(true); + }); + + test("should return false for invalid schemes", () => { + const result = isSafeUrl("ftp://example.com"); + expect(result).toBe(false); + }); + + test("should return false for HTTP by default", () => { + const result = isSafeUrl("http://example.com"); + expect(result).toBe(false); + }); + + test("should return true for HTTP with allowHttp", () => { + const result = isSafeUrl("http://example.com", { allowHttp: true }); + expect(result).toBe(true); + }); + + test("should return true for unknown hostnames", () => { + const result = isSafeUrl("https://nonexistent-domain-12345.test"); + expect(result).toBe(true); + }); +}); + +describe("isSameOrigin", () => { + test("should return true for identical URLs", () => { + expect(isSameOrigin("https://example.com", "https://example.com")).toBe( + true + ); + expect( + isSameOrigin("https://example.com/path", "https://example.com/other") + ).toBe(true); + }); + + test("should return true for URLs with same scheme, host, and port", () => { + expect(isSameOrigin("https://example.com:443", "https://example.com")).toBe( + true + ); + expect(isSameOrigin("http://example.com:80", "http://example.com")).toBe( + true + ); + expect( + isSameOrigin("https://example.com:8443", "https://example.com:8443") + ).toBe(true); + }); + + test("should return false for different schemes", () => { + expect(isSameOrigin("http://example.com", "https://example.com")).toBe( + false + ); + }); + + test("should return false for different hosts", () => { + expect(isSameOrigin("https://example.com", "https://other.com")).toBe( + false + ); + expect( + isSameOrigin("https://example.com", "https://subdomain.example.com") + ).toBe(false); + }); + + test("should return false for different ports", () => { + expect( + isSameOrigin("https://example.com:443", "https://example.com:8443") + ).toBe(false); + expect( + isSameOrigin("http://example.com:8080", "http://example.com:9090") + ).toBe(false); + }); + + test("should return false for invalid URLs", () => { + expect(isSameOrigin("invalid", "https://example.com")).toBe(false); + expect(isSameOrigin("https://example.com", "not-a-url")).toBe(false); + expect(isSameOrigin("", "")).toBe(false); + }); + + test("should handle subdomain as different origin", () => { + expect(isSameOrigin("https://sub.example.com", "https://example.com")).toBe( + false + ); + expect( + isSameOrigin("https://www.example.com", "https://api.example.com") + ).toBe(false); + }); + + test("should be case-insensitive for hosts", () => { + expect(isSameOrigin("https://Example.com", "https://example.com")).toBe( + true + ); + expect(isSameOrigin("HTTPS://EXAMPLE.COM", "https://example.com")).toBe( + true + ); + }); +}); + +describe("Real-world URLs", () => { + test("should allow valid webhook URLs", () => { + const result = isSafeUrl("https://webhook.site/unique-id"); + expect(result).toBe(true); + }); + + test("should handle HTTPS external APIs", () => { + // Changed to use example.com which is known to be safe + // api.example.com may not exist and cause DNS resolution failure + const result = isSafeUrl("https://www.google.com"); + expect(result).toBe(true); + }); + + test("should reject localhost callbacks by default", () => { + const result = isSafeUrl("https://localhost:3000/callback"); + expect(result).toBe(false); + }); + + test("should allow localhost callbacks with flag", () => { + const result = isSafeUrl("https://localhost:3000/callback", { + allowPrivate: true, + }); + expect(result).toBe(true); + }); +});
libs/langchain-core/tsdown.config.ts+1 −0 modified@@ -65,6 +65,7 @@ export default getBuildConfig({ "./src/utils/json_patch.ts", "./src/utils/json_schema.ts", "./src/utils/math.ts", + "./src/utils/ssrf.ts", "./src/utils/stream.ts", "./src/utils/testing/index.ts", "./src/utils/tiktoken.ts",
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/langchain-ai/langchainjs/commit/2812d2b2b9fd9343c4850e2ab906b8cf440975eenvdPatchWEB
- github.com/langchain-ai/langchainjs/commit/d5e3db0d01ab321ec70a875805b2f74aefdadf9dnvdPatchWEB
- github.com/advisories/GHSA-mphv-75cg-56wgghsaADVISORY
- github.com/langchain-ai/langchainjs/security/advisories/GHSA-mphv-75cg-56wgnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-27795ghsaADVISORY
- github.com/langchain-ai/langchainjs/pull/9990nvdIssue TrackingWEB
- github.com/langchain-ai/langchainjs/releases/tag/%40langchain%2Fcommunity%401.1.14nvdRelease NotesWEB
- github.com/langchain-ai/langchainjs/releases/tag/%40langchain%2Fcommunity%401.1.18nvdRelease NotesWEB
- github.com/langchain-ai/langchainjs/security/advisories/GHSA-gf3v-fwqg-4vh7nvdNot ApplicableWEB
News mentions
0No linked articles in our index yet.