Root cause
SSRF & network boundaries
feat(core,community): ssrf hardening (#9990)
How AI contributed: Flawed AI-written codePublished Feb 25, 2026 in
langchain-ai/langchainjs
SSRF bypass via redirects (CWE-918): validateSafeUrl is applied only to the initial URL in RecursiveUrlLoader while fetch() auto-follows redirects (redirect:'follow' default), creating a check-then-act gap; an attacker-controlled page can 30x-redirect to an internal/cloud-metadata URL bypassing the 1.1.14 SSRF protection (CVE-2026-26019/GHSA-gf3v-fwqg-4vh7). Fixed in @langchain/community 1.1.18 by validating every redirect hop (MAX_REDIRECTS=10, redirect:'manual' loop, per-hop validateSafeUrl).
Root cause
feat(core,community): ssrf hardening (#9990)
Fix
fix(community): validate redirects in RecursiveUrlLoader (#10116)
Case facts
@@ -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",@@ -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;@@ -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";@@ -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 {@@ -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(@@ -0,0 +1,5 @@+---+"@langchain/community": patch+---++Validate redirects in RecursiveUrlLoader to prevent SSRF bypasses.AI-assisted change 3b326186165b90573733528d0fa9d9f52b2f10b6597658ba26e5c44d4436a164 · Fix 3257640a1d72fa804f2b1902563bf66066beeeaf524aed9a690c5acba444bfe7