VYPR
Medium severity6.5NVD Advisory· Published Feb 20, 2026· Updated Apr 15, 2026

CVE-2026-27022

CVE-2026-27022

Description

@langchain/langgraph-checkpoint-redis is the Redis checkpoint and store implementation for LangGraph. A query injection vulnerability exists in the @langchain/langgraph-checkpoint-redis package's filter handling. The RedisSaver and ShallowRedisSaver classes construct RediSearch queries by directly interpolating user-provided filter keys and values without proper escaping. RediSearch has special syntax characters that can modify query behavior, and when user-controlled data contains these characters, the query logic can be manipulated to bypass intended access controls. This vulnerability is fixed in 1.0.2.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@langchain/langgraph-checkpoint-redisnpm
< 1.0.21.0.2

Affected products

1

Patches

1
814c76dc3938

fix(checkpoint): improve sanitization in Redis and MongoDB filters (#1943)

https://github.com/langchain-ai/langgraphjsHunter LovellFeb 5, 2026via ghsa
9 files changed · +217 20
  • .changeset/fix-mongodb-operator-injection.md+8 0 added
    @@ -0,0 +1,8 @@
    +---
    +"@langchain/langgraph-checkpoint-mongodb": patch
    +---
    +
    +fix(mongodb): validate filter values are primitives
    +
    +Added validation to ensure filter values in the `list()` method are primitive types
    +(string, number, boolean, or null).
    
  • .changeset/fix-redis-query-injection.md+8 0 added
    @@ -0,0 +1,8 @@
    +---
    +"@langchain/langgraph-checkpoint-redis": patch
    +---
    +
    +fix(redis): escape RediSearch filter values
    +
    +Added proper escaping for filter keys and values when constructing RediSearch queries
    +in the `list()` method to handle special characters correctly.
    
  • libs/checkpoint-mongodb/src/index.ts+6 0 modified
    @@ -154,6 +154,12 @@ export class MongoDBSaver extends BaseCheckpointSaver {
     
         if (filter) {
           Object.entries(filter).forEach(([key, value]) => {
    +        // Prevent MongoDB operator injection - only allow primitive values
    +        if (value !== null && typeof value === "object") {
    +          throw new Error(
    +            `Invalid filter value for key "${key}": filter values must be primitives (string, number, boolean, or null)`
    +          );
    +        }
             query[`metadata.${key}`] = value;
           });
         }
    
  • libs/checkpoint-mongodb/src/tests/checkpoints.test.ts+84 3 modified
    @@ -2,17 +2,98 @@ import { describe, it, expect, vi } from "vitest";
     import { type MongoClient } from "mongodb";
     import { MongoDBSaver } from "../index.js";
     
    -const client = {
    +const createMockClient = () => ({
       appendMetadata: vi.fn(),
    -  db: vi.fn(() => ({})),
    -};
    +  db: vi.fn(() => ({
    +    collection: vi.fn(() => ({
    +      find: vi.fn(() => ({
    +        sort: vi.fn(() => ({
    +          limit: vi.fn(() => ({
    +            toArray: vi.fn(() => Promise.resolve([])),
    +            async *[Symbol.asyncIterator]() {
    +              // Empty iterator
    +            },
    +          })),
    +          async *[Symbol.asyncIterator]() {
    +            // Empty iterator
    +          },
    +        })),
    +      })),
    +    })),
    +  })),
    +});
     
     describe("MongoDBSaver", () => {
       it("should set client metadata", async () => {
    +    const client = createMockClient();
         // eslint-disable-next-line no-new
         new MongoDBSaver({ client: client as unknown as MongoClient });
         expect(client.appendMetadata).toHaveBeenCalledWith({
           name: "langgraphjs_checkpoint_saver",
         });
       });
    +
    +  describe("filter validation", () => {
    +    it("should reject object values in filter to prevent MongoDB operator injection", async () => {
    +      const client = createMockClient();
    +      const saver = new MongoDBSaver({
    +        client: client as unknown as MongoClient,
    +      });
    +
    +      const config = { configurable: { thread_id: "test-thread" } };
    +
    +      // Attempt to use MongoDB operator injection
    +      const maliciousFilter = {
    +        source: { $regex: ".*" }, // MongoDB operator injection attempt
    +      };
    +
    +      const generator = saver.list(config, { filter: maliciousFilter });
    +
    +      await expect(generator.next()).rejects.toThrow(
    +        'Invalid filter value for key "source": filter values must be primitives'
    +      );
    +    });
    +
    +    it("should reject nested objects in filter", async () => {
    +      const client = createMockClient();
    +      const saver = new MongoDBSaver({
    +        client: client as unknown as MongoClient,
    +      });
    +
    +      const config = { configurable: { thread_id: "test-thread" } };
    +
    +      const maliciousFilter = {
    +        metadata: { nested: "value" },
    +      };
    +
    +      const generator = saver.list(config, { filter: maliciousFilter });
    +
    +      await expect(generator.next()).rejects.toThrow(
    +        'Invalid filter value for key "metadata": filter values must be primitives'
    +      );
    +    });
    +
    +    it("should allow primitive filter values", async () => {
    +      const client = createMockClient();
    +      const saver = new MongoDBSaver({
    +        client: client as unknown as MongoClient,
    +      });
    +
    +      const config = { configurable: { thread_id: "test-thread" } };
    +
    +      // Valid primitive filters
    +      const validFilter = {
    +        source: "input",
    +        step: 1,
    +        active: true,
    +        optional: null,
    +      };
    +
    +      const generator = saver.list(config, { filter: validFilter });
    +
    +      // Should not throw - will return empty since mock returns no results
    +      const result = await generator.next();
    +      expect(result.done).toBe(true);
    +    });
    +  });
     });
    
  • libs/checkpoint-redis/src/index.ts+8 3 modified
    @@ -13,6 +13,7 @@ import {
     } from "@langchain/langgraph-checkpoint";
     import { RunnableConfig } from "@langchain/core/runnables";
     import { createClient, createCluster } from "redis";
    +import { escapeRediSearchTagValue } from "./utils.js";
     
     // Type for Redis client - supports both standalone and cluster
     export type RedisClientType =
    @@ -293,10 +294,14 @@ export class RedisSaver extends BaseCheckpointSaver {
               } else if (value === null) {
                 // Skip null values for RediSearch query, will handle in post-processing
               } else if (typeof value === "string") {
    -            // Don't escape, just wrap in braces for exact match
    -            queryParts.push(`(@${key}:{${value}})`);
    +            // Escape both key and value to prevent RediSearch query injection
    +            const escapedKey = escapeRediSearchTagValue(key);
    +            const escapedValue = escapeRediSearchTagValue(value);
    +            queryParts.push(`(@${escapedKey}:{${escapedValue}})`);
               } else if (typeof value === "number") {
    -            queryParts.push(`(@${key}:[${value} ${value}])`);
    +            // Escape key to prevent injection; numbers don't need value escaping
    +            const escapedKey = escapeRediSearchTagValue(key);
    +            queryParts.push(`(@${escapedKey}:[${value} ${value}])`);
               } else if (
                 typeof value === "object" &&
                 Object.keys(value).length === 0
    
  • libs/checkpoint-redis/src/shallow.ts+8 2 modified
    @@ -10,6 +10,7 @@ import {
     } from "@langchain/langgraph-checkpoint";
     import { RunnableConfig } from "@langchain/core/runnables";
     import { createClient } from "redis";
    +import { escapeRediSearchTagValue } from "./utils.js";
     
     export interface TTLConfig {
       defaultTTL?: number; // TTL in minutes
    @@ -263,9 +264,14 @@ export class ShallowRedisSaver extends BaseCheckpointSaver {
               } else if (value === null) {
                 // Skip null values for RediSearch query, will handle in post-processing
               } else if (typeof value === "string") {
    -            queryParts.push(`(@${key}:{${value}})`);
    +            // Escape both key and value to prevent RediSearch query injection
    +            const escapedKey = escapeRediSearchTagValue(key);
    +            const escapedValue = escapeRediSearchTagValue(value);
    +            queryParts.push(`(@${escapedKey}:{${escapedValue}})`);
               } else if (typeof value === "number") {
    -            queryParts.push(`(@${key}:[${value} ${value}])`);
    +            // Escape key to prevent injection; numbers don't need value escaping
    +            const escapedKey = escapeRediSearchTagValue(key);
    +            queryParts.push(`(@${escapedKey}:[${value} ${value}])`);
               }
             }
           }
    
  • libs/checkpoint-redis/src/store.ts+4 12 modified
    @@ -18,6 +18,8 @@ import {
       type SearchOperation,
     } from "@langchain/langgraph-checkpoint";
     
    +import { escapeRediSearchTagValue } from "./utils.js";
    +
     // Type guard functions for operations
     export function isPutOperation(op: Operation): op is PutOperation {
       return "value" in op && "namespace" in op && "key" in op;
    @@ -1045,18 +1047,8 @@ export class RedisStore {
       }
     
       private escapeTagValue(value: string): string {
    -    // For TAG fields, we need to escape special characters
    -    // Based on RediSearch documentation, these characters need escaping in TAG fields
    -    // when used within curly braces: , . < > { } [ ] " ' : ; ! @ # $ % ^ & * ( ) - + = ~ | \ ? /
    -    // Handle empty string as a special case - use a placeholder
    -    if (value === "") {
    -      // Use a special placeholder for empty strings
    -      return "__EMPTY_STRING__";
    -    }
    -    // We'll escape the most common ones that appear in keys
    -    return value
    -      .replace(/\\/g, "\\\\")
    -      .replace(/[-\s,.:<>{}[\]"';!@#$%^&*()+=~|?/]/g, "\\$&");
    +    // Delegate to shared utility for RediSearch TAG field escaping
    +    return escapeRediSearchTagValue(value);
       }
     
       /**
    
  • libs/checkpoint-redis/src/tests/utils.test.ts+69 0 added
    @@ -0,0 +1,69 @@
    +import { describe, it, expect } from "vitest";
    +import { escapeRediSearchTagValue } from "../utils.js";
    +
    +describe("escapeRediSearchTagValue", () => {
    +  it("should return placeholder for empty string", () => {
    +    expect(escapeRediSearchTagValue("")).toBe("__EMPTY_STRING__");
    +  });
    +
    +  it("should escape backslashes", () => {
    +    expect(escapeRediSearchTagValue("foo\\bar")).toBe("foo\\\\bar");
    +  });
    +
    +  it("should escape special characters", () => {
    +    // Test various special characters that need escaping
    +    expect(escapeRediSearchTagValue("hello-world")).toBe("hello\\-world");
    +    expect(escapeRediSearchTagValue("foo.bar")).toBe("foo\\.bar");
    +    expect(escapeRediSearchTagValue("test:value")).toBe("test\\:value");
    +    expect(escapeRediSearchTagValue("key=value")).toBe("key\\=value");
    +    expect(escapeRediSearchTagValue("a|b")).toBe("a\\|b");
    +    expect(escapeRediSearchTagValue("(test)")).toBe("\\(test\\)");
    +    expect(escapeRediSearchTagValue("{test}")).toBe("\\{test\\}");
    +    expect(escapeRediSearchTagValue("[test]")).toBe("\\[test\\]");
    +  });
    +
    +  it("should escape spaces", () => {
    +    expect(escapeRediSearchTagValue("hello world")).toBe("hello\\ world");
    +  });
    +
    +  it("should not modify strings without special characters", () => {
    +    expect(escapeRediSearchTagValue("simple")).toBe("simple");
    +    expect(escapeRediSearchTagValue("CamelCase")).toBe("CamelCase");
    +    expect(escapeRediSearchTagValue("under_score")).toBe("under_score");
    +  });
    +
    +  it("should prevent RediSearch OR injection attempts", () => {
    +    // This is the attack payload that could escape thread boundaries
    +    const maliciousValue = "x}) | (@thread_id:{*";
    +    const escaped = escapeRediSearchTagValue(maliciousValue);
    +
    +    // The escaped version should not contain unescaped special characters
    +    // that could break out of the TAG field context
    +    expect(escaped).toBe("x\\}\\)\\ \\|\\ \\(\\@thread_id\\:\\{\\*");
    +
    +    // Verify no raw pipe, braces, or parentheses remain
    +    expect(escaped).not.toMatch(/(?<!\\)[|{}()]/);
    +  });
    +
    +  it("should prevent RediSearch key injection attempts", () => {
    +    // Attempt to inject via key name
    +    const maliciousKey = "})|(@thread_id:{*})|(@x";
    +    const escaped = escapeRediSearchTagValue(maliciousKey);
    +
    +    // Should escape all special characters
    +    expect(escaped).toBe("\\}\\)\\|\\(\\@thread_id\\:\\{\\*\\}\\)\\|\\(\\@x");
    +  });
    +
    +  it("should handle multiple consecutive special characters", () => {
    +    expect(escapeRediSearchTagValue("{{}}")).toBe("\\{\\{\\}\\}");
    +    expect(escapeRediSearchTagValue("|||")).toBe("\\|\\|\\|");
    +    expect(escapeRediSearchTagValue("...")).toBe("\\.\\.\\.");
    +  });
    +
    +  it("should handle mixed content", () => {
    +    expect(escapeRediSearchTagValue("user@example.com")).toBe(
    +      "user\\@example\\.com"
    +    );
    +    expect(escapeRediSearchTagValue("price: $100")).toBe("price\\:\\ \\$100");
    +  });
    +});
    
  • libs/checkpoint-redis/src/utils.ts+22 0 added
    @@ -0,0 +1,22 @@
    +/**
    + * Escape special characters in a string for use in RediSearch TAG field queries.
    + *
    + * RediSearch TAG fields have special characters that need escaping when used
    + * within curly braces: , . < > { } [ ] " ' : ; ! @ # $ % ^ & * ( ) - + = ~ | \ ? /
    + *
    + * This function is used to prevent RediSearch query injection attacks when
    + * building queries with user-provided filter values.
    + *
    + * @param value - The string value to escape
    + * @returns The escaped string safe for use in RediSearch TAG queries
    + */
    +export function escapeRediSearchTagValue(value: string): string {
    +  // Handle empty string as a special case - use a placeholder
    +  if (value === "") {
    +    return "__EMPTY_STRING__";
    +  }
    +  // Escape backslashes first, then all other special characters
    +  return value
    +    .replace(/\\/g, "\\\\")
    +    .replace(/[-\s,.:<>{}[\]"';!@#$%^&*()+=~|?/]/g, "\\$&");
    +}
    

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.