VYPR
Moderate severityNVD Advisory· Published Feb 11, 2026· Updated Feb 12, 2026

@langchain/community affected by SSRF Bypass in RecursiveUrlLoader via insufficient URL origin validation

CVE-2026-26019

Description

LangChain is a framework for building LLM-powered applications. Prior to 1.1.14, the RecursiveUrlLoader class in @langchain/community is a web crawler that recursively follows links from a starting URL. Its preventOutside option (enabled by default) is intended to restrict crawling to the same site as the base URL. The implementation used String.startsWith() to compare URLs, which does not perform semantic URL validation. An attacker who controls content on a crawled page could include links to domains that share a string prefix with the target, causing the crawler to follow links to attacker-controlled or internal infrastructure. Additionally, the crawler performed no validation against private or reserved IP addresses. A crawled page could include links targeting cloud metadata services, localhost, or RFC 1918 addresses, and the crawler would fetch them without restriction. This vulnerability is fixed in 1.1.14.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@langchain/communitynpm
< 1.1.141.1.14

Affected products

1

Patches

1
d5e3db0d01ab

feat(core,community): ssrf hardening (#9990)

https://github.com/langchain-ai/langchainjsHunter LovellFeb 11, 2026via ghsa
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

6

News mentions

0

No linked articles in our index yet.