VYPR
Medium severity4.1NVD Advisory· Published Feb 25, 2026· Updated Apr 13, 2026

CVE-2026-27795

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.

PackageAffected versionsPatched versions
@langchain/communitynpm
< 1.1.181.1.18

Affected products

1

Patches

2
2812d2b2b9fd

fix(community): validate redirects in RecursiveUrlLoader (#10116)

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

9

News mentions

0

No linked articles in our index yet.