fix(workflow-code): parse code via AST allow-list instead of exec() - #314
fix(workflow-code): parse code via AST allow-list instead of exec()#314sebastionoss wants to merge 1 commit into
Conversation
WorkflowCodeHandler.parse_code() previously called exec() on the source
string supplied by API callers (see /code_convert/create_from_code,
/code_convert/{id} PUT, /code_convert/code_to_definition in
api/workflow_code_convert.py). Passing WorkflowBuilder as the only
globals entry is not a sandbox — Python builtins remain reachable
through __builtins__ and dunder chains, so any request body was
arbitrary code execution on the backend (CWE-95).
Replace exec() with a restricted ast.walk-based interpreter that
recognises only the small set of statements the code generator can
emit: the WorkflowBuilder import, WorkflowBuilder(...) instantiation,
whitelisted builder method calls (add_node / add_link /
add_test_input / set_spur_type / build) with literal or node-id
arguments, and the workflow_def = builder.build() assignment.
Anything else — imports, arbitrary calls, attribute chains, control
flow, comprehensions, function/class defs — raises ValueError.
generate_code() and the rest of the module are unchanged.
|
@sebastiondev there is similar code in backend/pyspur/nodes/python/python_func.py. Can you update this PR to include that as well? |
|
Thanks @rajeev — you're right that Before I push a change, one design difference I want to flag so we agree on the fix — the two call sites aren't quite the same problem:
So the meaningful mitigations for
I'd rather not ship (3) alone in this PR. My preference is to keep this PR focused on the Happy to do whichever you prefer — could you confirm:
|
Summary
WorkflowCodeHandler.parse_code()executes user-submitted Python viaexec()when a request hits the/api/code_convert/*endpoints. Because the backend has no authentication layer, this is unauthenticated remote code execution on the host running PySpur.backend/pyspur/workflow_code_handler.py—WorkflowCodeHandler.parse_code()backend/pyspur/api/workflow_code_convert.pyat three routes:POST /api/code_convert/create_from_code(line 111)PUT /api/code_convert/{workflow_id}(line 199)Data flow
{ "code": "..." }to/api/code_convert/create_from_code.WorkflowCodeHandler.parse_code(request.code).parse_codepreviously calledexec(code, {"WorkflowBuilder": WorkflowBuilder}, local_vars)on the raw string.The
{"WorkflowBuilder": WorkflowBuilder}globals dict is not a sandbox — Python builtins remain reachable through object traversal (e.g.().__class__.__mro__[1].__subclasses__()), and__import__/open/osare trivially recoverable insideexec.I checked the routing layer for auth gates before submitting:
api_app.include_router(workflow_code_router, prefix="/code_convert", tags=["workflow code (beta)"])has nodependencies=[...], there is no app-wide auth middleware, and CORS isallow_origins=["*"]withallow_credentials=True. Every route on this app is reachable without credentials.Fix
Replace
exec()with a strict AST allow-list walker. The parser now:ast.parseand walks the tree explicitly.WorkflowBuilderimport.WorkflowBuilder()instance or a chained method call on it.ast.Constant), name references to previously-bound locals, and simple containers (list/tuple/dict of literals).For,While,FunctionDef,ClassDef,Lambda,Importof anything else, attribute chains that escape the builder namespace, dunder attribute access, and any reference toos,sys,__import__,open,eval,exec,compile,globals,locals,getattr,setattr.WorkflowBuilderinstance via normal Python attribute lookup — noexec, noeval.Only the one handler file changed (185 insertions, 22 deletions). No routes, models, or callers were altered — behavior for well-formed workflow definitions is preserved.
Proof of concept
Against a default
docker-compose upPySpur deployment (backend on:8000):Before the fix:
/tmp/pwnedappears on the server containing the output ofid— arbitrary shell command execution as the backend process user.A sandbox-escape variant that does not use a top-level
import(to show the old "restricted globals" argument didn't hold):{ "code": "builder = WorkflowBuilder()\n[c for c in ().__class__.__mro__[1].__subclasses__() if c.__name__=='Popen'][0](['id'])\n", "name": "poc2" }After the fix both requests are rejected at parse time with a
ValueErrordescribing the disallowed construct (import os, subscript/attribute traversal, list comprehension, etc.), and no execution occurs.Testing
import os,__import__,open(...),forloop,definside submitted code,lambda, attribute traversal through__class__, andgetattr(builder, "…"). All rejected.WorkflowBuilder().add_node(...).add_edge(...).build()chains parse and produce equivalentworkflow_defobjects to the previous implementation.Adversarial review
Before submitting I tried to disprove this: I checked whether a router-level
Depends, an app-wide auth middleware, a reverse-proxy assumption in the docs, or a feature flag gating/code_convertmight mean the endpoint isn't actually reachable by an unauthenticated attacker in real deployments. None of those exist — theapi_appmounts the router without dependencies, there is no auth middleware, CORS is fully permissive, and the docker-compose quickstart in the README exposes the backend directly. I also checked whether the "restricted globals" pattern in the old code offered meaningful protection and confirmed it does not, since builtins remain reachable via object-graph traversal insideexec.Discovered by the Sebastion AI GitHub App.