diff --git a/python/ql/lib/change-notes/2025-11-26-socketio.md b/python/ql/lib/change-notes/2025-11-26-socketio.md new file mode 100644 index 000000000000..e58bec0bbc12 --- /dev/null +++ b/python/ql/lib/change-notes/2025-11-26-socketio.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Remote flow sources for the `python-socketio` package have been modeled. \ No newline at end of file diff --git a/python/ql/lib/semmle/python/Frameworks.qll b/python/ql/lib/semmle/python/Frameworks.qll index 955385141f7f..7694419b41d5 100644 --- a/python/ql/lib/semmle/python/Frameworks.qll +++ b/python/ql/lib/semmle/python/Frameworks.qll @@ -78,6 +78,7 @@ private import semmle.python.frameworks.Sanic private import semmle.python.frameworks.ServerLess private import semmle.python.frameworks.Setuptools private import semmle.python.frameworks.Simplejson +private import semmle.python.frameworks.Socketio private import semmle.python.frameworks.SqlAlchemy private import semmle.python.frameworks.Starlette private import semmle.python.frameworks.Stdlib diff --git a/python/ql/lib/semmle/python/frameworks/Socketio.qll b/python/ql/lib/semmle/python/frameworks/Socketio.qll new file mode 100644 index 000000000000..4006dcfbe7d8 --- /dev/null +++ b/python/ql/lib/semmle/python/frameworks/Socketio.qll @@ -0,0 +1,119 @@ +/** + * Provides definitions and modeling for the `python-socketio` PyPI package. + * See https://python-socketio.readthedocs.io/en/stable/. + */ + +private import python +private import semmle.python.dataflow.new.TaintTracking +private import semmle.python.dataflow.new.RemoteFlowSources +private import semmle.python.Concepts +private import semmle.python.ApiGraphs +private import semmle.python.frameworks.internal.PoorMansFunctionResolution + +/** + * Provides models for the `python-socketio` PyPI package. + * See https://python-socketio.readthedocs.io/en/stable/. + */ +module SocketIO { + /** Provides models for socketio `Server` and `AsyncServer` classes. */ + module Server { + /** Gets an instance of a socketio `Server` or `AsyncServer`. */ + API::Node server() { + result = API::moduleImport("socketio").getMember(["Server", "AsyncServer"]).getAnInstance() + } + + /** Gets a decorator that indicates a socketio event handler. */ + private API::Node serverEventAnnotation() { + result = server().getMember("event") + or + result = server().getMember("on").getReturn() + } + + private class EventHandler extends Http::Server::RequestHandler::Range { + EventHandler() { + serverEventAnnotation().getAValueReachableFromSource().asExpr() = this.getADecorator() + or + exists(DataFlow::CallCfgNode c, DataFlow::Node arg | + c = server().getMember("on").getACall() + | + ( + arg = c.getArg(1) + or + arg = c.getArgByName("handler") + ) and + poorMansFunctionTracker(this) = arg + ) + } + + override Parameter getARoutedParameter() { + result = this.getAnArg() and + not result = this.getArg(0) // First parameter is `sid`, which is not a remote flow source as it cannot be controlled by the client. + } + + override string getFramework() { result = "socketio" } + } + + private class CallbackArgument extends DataFlow::Node { + CallbackArgument() { + exists(DataFlow::CallCfgNode c | + c = [server(), Namespace::instance()].getMember(["emit", "send"]).getACall() + | + this = c.getArgByName("callback") + ) + } + } + + private class CallbackHandler extends Http::Server::RequestHandler::Range { + CallbackHandler() { any(CallbackArgument ca) = poorMansFunctionTracker(this) } + + override Parameter getARoutedParameter() { result = this.getAnArg() } + + override string getFramework() { result = "socketio" } + } + + private class SocketIOCall extends RemoteFlowSource::Range { + SocketIOCall() { this = [server(), Namespace::instance()].getMember("call").getACall() } + + override string getSourceType() { result = "socketio call" } + } + } + + /** Provides modeling for socketio server Namespace/AsyncNamespace classes. */ + module Namespace { + /** Gets a reference to the `socketio.Namespace` or `socketio.AsyncNamespace` classes or any subclass. */ + API::Node subclassRef() { + result = + API::moduleImport("socketio").getMember(["Namespace", "AsyncNamespace"]).getASubclass*() + } + + /** Gets a reference to an instance of a subclass of `socketio.Namespace` or `socketio.AsyncNamespace`. */ + API::Node instance() { + result = subclassRef().getAnInstance() + or + result = subclassRef().getAMember().getSelfParameter() + } + + /** A socketio Namespace class. */ + class NamespaceClass extends Class { + NamespaceClass() { this.getABase() = subclassRef().asSource().asExpr() } + + /** Gets a handler for socketio events. */ + Function getAnEventHandler() { + result = this.getAMethod() and + result.getName().matches("on_%") + } + } + + private class NamespaceEventHandler extends Http::Server::RequestHandler::Range { + NamespaceEventHandler() { this = any(NamespaceClass nc).getAnEventHandler() } + + override Parameter getARoutedParameter() { + result = this.getAnArg() and + not result = this.getArg(0) and + not result = this.getArg(1) // First 2 parameters are `self` and `sid`. + } + + override string getFramework() { result = "socketio" } + } + } +} diff --git a/python/ql/test/library-tests/frameworks/socketio/ConceptsTest.expected b/python/ql/test/library-tests/frameworks/socketio/ConceptsTest.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/frameworks/socketio/ConceptsTest.ql b/python/ql/test/library-tests/frameworks/socketio/ConceptsTest.ql new file mode 100644 index 000000000000..b557a0bccb69 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/socketio/ConceptsTest.ql @@ -0,0 +1,2 @@ +import python +import experimental.meta.ConceptsTest diff --git a/python/ql/test/library-tests/frameworks/socketio/InlineTaintTest.expected b/python/ql/test/library-tests/frameworks/socketio/InlineTaintTest.expected new file mode 100644 index 000000000000..020c338fd192 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/socketio/InlineTaintTest.expected @@ -0,0 +1,3 @@ +argumentToEnsureNotTaintedNotMarkedAsSpurious +untaintedArgumentToEnsureTaintedNotMarkedAsMissing +testFailures diff --git a/python/ql/test/library-tests/frameworks/socketio/InlineTaintTest.ql b/python/ql/test/library-tests/frameworks/socketio/InlineTaintTest.ql new file mode 100644 index 000000000000..8524da5fe7db --- /dev/null +++ b/python/ql/test/library-tests/frameworks/socketio/InlineTaintTest.ql @@ -0,0 +1,2 @@ +import experimental.meta.InlineTaintTest +import MakeInlineTaintTest diff --git a/python/ql/test/library-tests/frameworks/socketio/taint_test.py b/python/ql/test/library-tests/frameworks/socketio/taint_test.py new file mode 100644 index 000000000000..07d109aa9a2f --- /dev/null +++ b/python/ql/test/library-tests/frameworks/socketio/taint_test.py @@ -0,0 +1,69 @@ +import sys +import socketio + +def ensure_tainted(*args): + print("tainted", args) + +def ensure_not_tainted(*args): + print("not tainted", args) + +sio = socketio.Server() + +@sio.event +def connect(sid, environ, auth): # $ requestHandler routedParameter=environ routedParameter=auth + ensure_not_tainted(sid) + ensure_tainted(environ, # $ tainted + auth) # $ tainted + +@sio.event +def event1(sid, data): # $ requestHandler routedParameter=data + ensure_not_tainted(sid) + ensure_tainted(data) # $ tainted + res = sio.call("e1", sid=sid) + ensure_tainted(res) # $ tainted + sio.emit("e2", "hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + sio.send("hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + +class MyNamespace(socketio.Namespace): + def on_event2(self, sid, data): # $ requestHandler routedParameter=data + ensure_not_tainted(self, sid) + ensure_tainted(data) # $ tainted + res = self.call("e1", sid=sid) + ensure_tainted(res) # $ tainted + self.emit("e2", "hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + self.send("hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + +sio.register_namespace(MyNamespace("/ns")) + +asio = socketio.AsyncServer(async_mode='asgi') + +@asio.event +async def event3(sid, data): # $ requestHandler routedParameter=data + ensure_not_tainted(sid) + ensure_tainted(data) # $ tainted + res = await asio.call("e1", sid=sid) + ensure_tainted(res) # $ tainted + await asio.emit("e2", "hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + await asio.send("hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + +class MyAsyncNamespace(socketio.AsyncNamespace): + async def on_event4(self, sid, data): # $ requestHandler routedParameter=data + ensure_not_tainted(self, sid) + ensure_tainted(data) # $ tainted + res = await self.call("e1", sid=sid) + ensure_tainted(res) # $ tainted + await self.emit("e2", "hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + await self.send("hi", to=sid, callback=lambda x: ensure_tainted(x)) # $ tainted $ requestHandler routedParameter=x + +asio.register_namespace(MyAsyncNamespace("/ns")) + +if __name__ == "__main__": + + if "--async" in sys.argv: # $ threatModelSource[commandargs]=sys.argv + import uvicorn + app = socketio.ASGIApp(asio) + uvicorn.run(app, host='127.0.0.1', port=8000) + else: + import eventlet + app = socketio.WSGIApp(sio) + eventlet.wsgi.server(eventlet.listen(('', 8000)), app) \ No newline at end of file diff --git a/python/ql/test/library-tests/frameworks/socketio/test.py b/python/ql/test/library-tests/frameworks/socketio/test.py new file mode 100644 index 000000000000..f603edd3111f --- /dev/null +++ b/python/ql/test/library-tests/frameworks/socketio/test.py @@ -0,0 +1,29 @@ +import socketio + +sio = socketio.Server() + +@sio.on("connect") +def connect(sid, environ, auth): # $ requestHandler routedParameter=environ routedParameter=auth + print("connect", sid, environ, auth) + +@sio.on("event1") +def handle(sid, data): # $ requestHandler routedParameter=data + print("e1", sid, data) + +@sio.event +def event2(sid, data): # $ requestHandler routedParameter=data + print("e2", sid, data) + +def event3(sid, data): # $ requestHandler routedParameter=data + print("e3", sid, data) + +sio.on("event3", handler=event3) + +sio.on("event4", lambda sid,data: print("e4", sid, data)) # $ requestHandler routedParameter=data + + + +if __name__ == "__main__": + app = socketio.WSGIApp(sio) + import eventlet + eventlet.wsgi.server(eventlet.listen(('', 8000)), app) \ No newline at end of file