Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions packages/components/nodes/vectorstores/Redis/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { escapeSpecialChars, unEscapeSpecialChars } from './utils'

// escapeSpecialChars

describe('escapeSpecialChars', () => {
test('escapes hyphen (original behaviour preserved)', () => {
expect(escapeSpecialChars('hello-world')).toBe('hello\\-world')
})

test('escapes colon — the primary bug trigger', () => {
expect(escapeSpecialChars('source:file.pdf')).toBe('source\\:file\\.pdf')
})

test('escapes double-quote — the primary bug trigger', () => {
expect(escapeSpecialChars('"quoted"')).toBe('\\"quoted\\"')
})

test('escapes all other RediSearch special chars', () => {
const chars = [
',',
'.',
'<',
'>',
'{',
'}',
'[',
']',
"'",
';',
'!',
'@',
'#',
'$',
'%',
'^',
'&',
'*',
'(',
')',
'+',
'=',
'~',
'\\'
]
Comment on lines +40 to +44
for (const ch of chars) {
expect(escapeSpecialChars(ch)).toBe(`\\${ch}`)
}
})

test('leaves normal alphanumeric strings untouched', () => {
expect(escapeSpecialChars('hello world 123')).toBe('hello world 123')
})

test('handles empty string', () => {
expect(escapeSpecialChars('')).toBe('')
})
})

// unEscapeSpecialChars

describe('unEscapeSpecialChars', () => {
test('unescapes hyphen (original behaviour preserved)', () => {
expect(unEscapeSpecialChars('hello\\-world')).toBe('hello-world')
})

test('unescapes colon — the primary bug trigger', () => {
expect(unEscapeSpecialChars('source\\:file\\.pdf')).toBe('source:file.pdf')
})

test('unescapes double-quote — the primary bug trigger', () => {
expect(unEscapeSpecialChars('\\"quoted\\"')).toBe('"quoted"')
})

test('unescapes all other RediSearch special chars', () => {
const chars = [
',',
'.',
'<',
'>',
'{',
'}',
'[',
']',
"'",
';',
'!',
'@',
'#',
'$',
'%',
'^',
'&',
'*',
'(',
')',
'+',
'=',
'~',
'\\'
]
Comment on lines +96 to +100
for (const ch of chars) {
expect(unEscapeSpecialChars(`\\${ch}`)).toBe(ch)
}
})

test('handles empty string', () => {
expect(unEscapeSpecialChars('')).toBe('')
})
})

// Round-trip: the core regression test
//
// Simulates the EXACT production flow:
// STORAGE — @langchain/redis vectorstores.cjs line 123:
// hashFields[metadataKey] = this.escapeSpecialChars(JSON.stringify(metadata))
// where the library's own escapeSpecialChars (line 415-416) escapes ONLY
// '-', ':', and '"' — applied to the entire JSON string.
//
// RETRIEVAL — Redis.ts (Flowise override) line 349:
// const metadataString = unEscapeSpecialChars(document[metadataKey])
// metadata: JSON.parse(metadataString)
//
// The OLD bug: only '\-' was removed, leaving '\"' and '\:' in the JSON,
// causing JSON.parse to throw "Expected property name or '}'".
// The FIX: unEscapeSpecialChars now removes all library-applied escapes so
// the string is valid JSON before JSON.parse is called.

describe('escape / unescape round-trip (regression for GitHub bug)', () => {
/**
* Mirrors @langchain/redis vectorstores.cjs line 123:
* this.escapeSpecialChars(JSON.stringify(metadata))
* The library escapes only '-', ':', '"' on the full JSON string.
*/
function simulateUpsert(metadata: object): string {
const jsonStr = JSON.stringify(metadata)
// Same order as the library (vectorstores.cjs line 415-416):
return jsonStr.replaceAll('-', '\\-').replaceAll(':', '\\:').replaceAll('"', '\\"')
}

/**
* Mirrors Redis.ts line 349 (Flowise's override of similaritySearchVectorWithScore):
* unEscapeSpecialChars(rawString) → JSON.parse
* NOTE: unescape happens BEFORE parse — that is the exact production order.
*/
function simulateRetrieval(stored: string): object {
const unescaped = unEscapeSpecialChars(stored)
return JSON.parse(unescaped)
}

test('round-trips metadata containing a colon in a value', () => {
const original = { source: 'gs://my-bucket:path/to/file.pdf', page: 1 }
const stored = simulateUpsert(original)
const retrieved = simulateRetrieval(stored)
expect(retrieved).toEqual(original)
})

test('round-trips metadata containing double-quotes in a value', () => {
const original = { title: '"Hello World"', page: 2 }
const stored = simulateUpsert(original)
const retrieved = simulateRetrieval(stored)
expect(retrieved).toEqual(original)
})

test('round-trips metadata containing a mix of special chars', () => {
const original = {
source: 'file-name (v2).pdf',
author: "O'Brien, J.",
tag: '#important & <urgent>',
page: 3
}
const stored = simulateUpsert(original)
const retrieved = simulateRetrieval(stored)
expect(retrieved).toEqual(original)
})

test('round-trips plain metadata with no special chars (sanity check)', () => {
const original = { source: 'simple_file.pdf', page: 4 }
const stored = simulateUpsert(original)
const retrieved = simulateRetrieval(stored)
expect(retrieved).toEqual(original)
})
Comment on lines +176 to +181

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We should add a regression test case to verify that metadata containing backslashes (such as Windows file paths) can be successfully round-tripped without causing JSON parsing errors.

    test('round-trips plain metadata with no special chars (sanity check)', () => {
        const original = { source: 'simple_file.pdf', page: 4 }
        const stored = simulateUpsert(original)
        const retrieved = simulateRetrieval(stored)
        expect(retrieved).toEqual(original)
    })

    test('round-trips metadata containing backslashes in a value', () => {
        const original = { path: 'C:\\\\path\\\\to\\\\file', page: 5 }
        const stored = simulateUpsert(original)
        const retrieved = simulateRetrieval(stored)
        expect(retrieved).toEqual(original)
    })

})
11 changes: 6 additions & 5 deletions packages/components/nodes/vectorstores/Redis/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { isNil } from 'lodash'

/*
* Escapes all '-' characters.
* Redis Search considers '-' as a negative operator, hence we need
* to escape it
* Escapes all RediSearch special characters.
* RediSearch reserves the following characters as operators/delimiters,
* so they must be escaped when stored as metadata values:
* , . < > { } [ ] " ' : ; ! @ # $ % ^ & * ( ) - + = ~ \
*/
export const escapeSpecialChars = (str: string) => {
return str.replaceAll('-', '\\-')
return str.replace(/([,.<>{}[\]"':;!@#$%^&*()\-+=~\\])/g, '\\$1')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In particular, unescaped | characters in filter values can be interpreted as OR operators, which can lead to query injection or filter bypasses. We should include |, ?, and / in the list of escaped characters to ensure robust query execution and security.

    return str.replace(/([,.\u003c\u003e{}[\\]\"':;!@#$%^\u0026*()\\-+=~\\\\|?\\/])/g, '\\$1')

Comment on lines +6 to +10
}

export const escapeAllStrings = (obj: object) => {
Expand All @@ -27,5 +28,5 @@ export const escapeAllStrings = (obj: object) => {
}

export const unEscapeSpecialChars = (str: string) => {
return str.replaceAll('\\-', '-')
return str.replace(/\\([,.<>{}[\]"':;!@#$%^&*()\-+=~\\])/g, '$1')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Unescaping all RediSearch special characters (especially \\) on the entire JSON string will corrupt valid JSON escape sequences (such as \\\\ representing a single backslash in a path or string value). Since @langchain/redis only escapes -, :, and " when storing metadata, unEscapeSpecialChars should only unescape these three characters to avoid corrupting the JSON structure and causing JSON.parse to fail on metadata containing backslashes (e.g., Windows file paths).

Suggested change
return str.replace(/\\([,.<>{}[\]"':;!@#$%^&*()\-+=~\\])/g, '$1')
return str.replace(/\\([-:"])/g, '$1')

Comment on lines 30 to +31
}