Planned plugin sideloading changes (looking for feedback from users)
With the recent Streamlink 8.4.0 release fixing local file-read issues in HLS/DASH, I want to address some long-standing security issues regarding plugin sideloading. I'm therefore writing this with the intention to gather some feedback from users before making any decisions. Yes, I am fully aware that this might be a bit of an overkill.
Motivation
Plugin sideloading allows for simple extensibility of the plugin system without having to directly modify Streamlink's Python distribution (the streamlink Python package). Streamlink has inherited the plugin sideloading functionality from Livestreamer (ef86768ac852, 8d2d1fb360ae) when it was forked.
The problem with the current plugin sideloading implementation is that Streamlink will by design automatically execute all Python modules in its sideloading paths when it is looking for plugins to add or override. Sideloading is enabled by default unless explicitly disabled via --no-plugin-sideloading.
There's the default sideloading path in the user's home directory (depends on the OS - on Linux it's in $XDG_DATA_HOME), but there's also the --plugin-dir and the deprecated --plugin-dirs options which allow setting arbitrary paths to load plugins from. These options can of course be set in the config files.
In Streamlink 6.6.0 (2024-02-16), lazy plugin loading was implemented for default/mainline plugins. Here, we can statically analyze plugin modules during build-time and parse the declarative URL-matcher and plugin-argument data, and then build a JSON file that can then be read during run-time to find pre-built URL-matcher data and only load the needed plugin module from the input URL while ignoring all other plugins. This accelerates the launch time and reduces overall memory usage.
For third-party plugins, this approach doesn't work, because we'd need to statically analyze plugin modules every time on each launch, which is slow. It's also prone to errors, despite our parser being quite lenient, because we don't know what third-party plugin developers do in their own implementations. Default/mainline plugins have strict rules about the declarative data and are thus always "parser-compliant". So the only option is to execute all Python modules in the sideloading paths when finding a matching plugin for the input URL.
The resulting issue is that even if we raise an error if a loaded module in the plugin-sideloading paths is not a valid plugin, we already have executed that module, which is potentially dangerous, as it may include malicious code.
This creates a privilege escalation vector where simple filesystem write access (e.g. via a different restricted service or a synced cloud folder) is automatically promoted to full code execution as soon as the user starts Streamlink.
Only execute trusted modules
The solution to the problem is to only execute trusted code, but this is a problem of its own.
- We'd either need to add a plugin whitelist, where only explicitly selected third-party plugin modules are executed.
- via plugin paths
- via plugin checksums
- via plugin paths and accompanied checksums
- Or we'd have to require cryptographically signed plugin modules, signed by the users themselves or by other users.
Both approaches introduce a breaking change that necessitates a major version bump, as this would disable the current functionality.
The issue with (1) is that selecting plugins explicitly is tedious and annoying, even if users would add the selection to their config files. Modules could still include untrusted code when selected only by path (1.1), so this is definitely not an option. Required checksums solve this, but this is also very tedious, as every single custom plugin would need to be listed, non-transparently by only its checksum (1.2), or by its path with its accompanied checksum (1.3), and this data would have to be updated every single time when plugin modules change. This approach doesn't scale well and is therefore bad.
Cryptographically signed plugin modules (2) solve the issue, but it requires additional setup, which might be confusing to new users. The whole setup and its additional tooling therefore needs to be as simple as possible, but I think this is achievable and relatively easy to teach.
Cryptographically signed plugins
Standard procedure:
- User A generates a private key + public key pair. The private key can have an optional password and is stored somewhere securely
- User A signs their custom plugin modules using the private key, with the resulting signatures either being stored in a detached file next to the modules (
pluginname.py.sig) or embedded into the plugin module file directly at a specific marker - User A can share their public key, their custom plugins and the respective signature files with User B
- Public keys are set via
--plugin-sideloading-key=..., to config files or on the CLI directly, to allow loading signed plugins
Modifying custom plugins will require re-signing them (steps 2 + 3), but the keys remain the same (steps 1 + 4).
No explicit plugin selections with checksums are required and plugin files with their signatures can be replaced directly.
Signature verification
If the public key doesn't match the plugin signature, or the plugin module or signature file have been modified, Streamlink won't execute the module and will log a warning message. Python modules without a signature will be ignored.
Streamlink would require valid signatures for third-party plugins by default, with an optional --plugin-sideloading-allow-unsigned argument to disable it and restore the current behavior.
For plugin signature verification, the --plugin-sideloading-key=... CLI argument should be simple enough to understand, with the public key being a short base64-encoded string (44 chars). This argument can be repeated, so loading plugins from multiple authors is possible. The public key arguments can be added to config files, so they don't have to be set explicitly each time.
We may add an optional prefix for public keys, so they can include a name for easier recognition and key management. Tools like OpenSSH have a known_hosts file, but something like this is unnecessary here.
Tooling
Instead of requiring external tooling, we'd implement an easy to use secondary streamlink-sign entry script / executable, so that we don't overload Streamlink's regular CLI.
pycryptodome will be used for all the crypto stuff, as it already is a dependency of Streamlink. Implementing this is pretty straightforward.
This signing-CLI receives very simple commands, for example:
streamlink-sign generate /path/to/priv-keyfor generating the private key with an optional password prompt (also writes the public key to stdout, so it can be copied)streamlink-sign getpub /path/to/priv-keyfor getting the public key from an existing private keystreamlink-sign sign /path/to/priv-key /path/to/plugin-or-dir...for signing individual modules or batch-signing directories (non-recursive)streamlink-sign verify pubkey /path/to/plugin-or-dir...for verifying plugin signatures with the given public key
The crypto stuff will be written in the streamlink.utils.sign module, so Python-API implementors can use it as well, while the new CLI implements and extends it with console I/O for interactive prompts of the optional private-key password, as well as any path confirmations when writing. The CLI can also have --passphrase-stdin and --overwrite=... arguments for scripting integrations.
Keys and signatures
Private keys are ECC based using Ed25519 for speed and short keys, especially the pub keys that are set via --plugin-sideloading-key:
https://www.pycryptodome.org/src/signature/eddsa
The password encryption is standard PBKDF2 with AES256-CBC.
Signatures are calculated from the module's SHA512. The resulting format includes a version number, so we can make future changes easily.
Notes and feedback
Config files and write access
Let's address the elephant in the room first: config files.
In the motivation section, I've explained how filesystem write access allows for trivial code execution via Streamlink.
So what stops a bad actor from adding --plugin-sideloading-allow-unsigned to the user's config file if they have write access there as well? What stops them from adding their own public key to the user's config file, so that the self-signed Python modules are executed automatically, just like before without plugin signatures?
Regarding --plugin-sideloading-allow-unsigned, we can extend the stdlib's argparse, to add support for CLI arguments which can't be read from config files, so they must always be set explicitly on the CLI. It's not super elegant code implementation-wise (due to non-public interfaces), but it's easily doable.
The problem with modified config files containing untrusted public keys unfortunately can't be solved. There would however also be tons of other ways of circumventing the plugin signatures, for example by adding a custom streamlink CLI wrapper to a directory that's listed early in the user's PATH env var (something usually in $HOME). Or by making file modifications somewhere else. The options are endless and unpredictable. An attacker who has write access already has a lot of control over the user's system, so it's not Streamlink's job to prevent this.
What we can however do is to finally address the issue of trivial execution of untrusted Python modules that may linger in the plugin sideloading paths. Required plugin signatures make it much more difficult to run untrusted code, even if it's not impossible with full write access and even if it kills the "simple extensibility" of the plugin system. A hardened plugin sideloading system is better than the current, trivial one which we inherited.
Custom plugin distributors
Another benefit of signed plugins is that users who run custom plugins written by someone else can have a strong guarantee that the plugin they're using is authentic. The code owner must be trusted. If the owner is unknown, plugins can always be self-signed.
System integration of keys
Operating systems and third-party applications can provide secure key store integrations. Considering that Streamlink runs in all kinds of environments, adding such integrations for all of them is impossible and a massive implementation burden, so this won't be done. Adding more dependencies which could automate this doesn't solve this and opens the door for more issues, and I don't want to have to deal with this.
Detached or embedded signatures?
Both approaches have their pros and cons, so I'm not 100% sure yet which one to choose. Embedded ones would probably keep it a bit simpler.
Detached
+ No plugin content is modified + A binary format could be used, which is a bit faster to parse - A bit cumbersome when copying or distributing custom plugins - Requires a secondary file read, which adds I/O overhead
Embedded
+ No secondary signature file, so copying and distributing plugins is simple + Only one file read required, which means less I/O overhead - Signing means modifying the file, which also alters the resulting checksum - A special marker is required to split the plugin content from the signature, which must be Python-syntax-compatible
Plugin loading
An update to the plugin loading mechanism would be required, so we don't read plugin module contents from the filesystem twice and accidentally introduce a short time frame where module contents could be replaced between validating and executing them. We'd also have to make the Python interpreter ignore Python bytecode for custom plugins.
Source: streamlink/streamlink