fix: reject symlinks in extension upload to prevent arbitrary file exfiltration (CWE-22)#11
Open
sebastiondev wants to merge 1 commit intoagbcloud:masterfrom
Open
Conversation
… (CWE-22) ExtensionsService.create() and .update() accepted symlinks as local_path without resolving them. An attacker could create a symlink pointing at a sensitive file (e.g. /etc/shadow) named with a .zip extension, and the target file contents would be read and uploaded to the cloud context. Add _validate_local_path() helper that: - Rejects symbolic links with a clear ValueError - Resolves the real path via os.path.realpath() - Verifies the result is a regular file Apply validation in both create() and update() before any I/O occurs. Both copies of extension.py (agb/ and python/agb/) are patched.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ExtensionsService.create()andExtensionsService.update()(in bothagb/extension.pyandpython/agb/extension.py) accept alocal_pathargument and upload the referenced file to the cloud extensions context. Before this change, the only checks wereos.path.exists(local_path)and a.zipextension check on the supplied string. Neither rejects symbolic links, andopen(local_path, "rb")transparently follows them.This means a symlink such as
evil.zip -> /etc/shadow(or any other readable file on the host) is happily opened, read, and uploaded to the user-accessible cloud context, where the contents can be retrieved. The.zipsuffix gate is bypassed because it inspects the link name, not the target.agb/extension.py→ExtensionsService.create,ExtensionsService.updatepython/agb/extension.py→ExtensionsService.create,ExtensionsService.updatelocal_path→os.path.exists(passes for symlinks) →.zipsuffix check on the link name →_upload_to_cloud→open(local_path, "rb")follows the symlink → arbitrary file contents uploaded.Fix
Introduce a small helper,
_validate_local_path, called at the top of bothcreate()andupdate()before any I/O:os.path.islink(local_path)is true, with a clearValueError.os.path.realpath()to catch symlinks in intermediate path components.os.path.isfile), raisingFileNotFoundErrorotherwise.The existing
FileNotFoundErrorsemantics for non-existent paths are preserved. The change is minimal and contained — no public API changes, no new dependencies.Tests
Added
tests/unit/test_extension_symlink_traversal.py(7 tests, all passing) covering both modules and both methods:/etc/shadow-like target) — rejected withValueErrormentioning "symbolic link", and_upload_to_cloudis never called..zipsuffix pointing at a non-zip target — still rejected (verifies the suffix gate isn't the only barrier)..zipfile — still works as before (regression check).FileNotFoundError(behavior preserved).update().Security analysis
The exploit requires:
local_pathintoExtensionsService.create()/update().In that scenario, the SDK silently exfiltrates any file readable by the process to a location the attacker controls. The fix closes this by refusing symlinks before the file is opened, so the
.zipextension gate cannot be bypassed viaevil.zip -> /etc/passwd, and intermediate-component symlinks (/uploads/link_to_etc/passwd) are also caught via canonicalization.Adversarial review
Before submitting, I tried to disprove this. The strongest counter-argument is that if the attacker can already plant symlinks on the host or directly control
local_path, they likely have meaningful local access already — so why is this a vulnerability rather than a configuration issue? The reason it still matters: the SDK turns "ability to drop a symlink into a watched directory" (a low-privilege primitive that exists in many shared-tenant or upload-staging setups) into "arbitrary file read by the SDK process, exfiltrated to a remote context the attacker can read back". That's a meaningful privilege step, and the.zipsuffix check gives a false sense that only zip files are uploaded. There's no framework or upstream protection here —open()follows symlinks by default — so the fix has to live in the SDK. I'd treat this as a hardening / defense-in-depth fix rather than a critical RCE, but worth landing.Happy to adjust naming, error messages, or split into per-module PRs if you'd prefer.
cc @lewiswigmore