From 6d61e767c1d2e9c513aac046c7ba7894beb64cfb Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Wed, 13 May 2026 14:13:58 -0600 Subject: [PATCH] Validate user-supplied URLs in tool API clients Five tool API clients constructed their underlying HTTP session without validating the configured server URL and without using the SSRF-safe session adapter already established by `dojo/tools/risk_recon/api.py`. - api_sonarqube: validate `tool_config.url`; swap to make_ssrf_safe_session. - api_edgescan: validate `tool_config.url`; replace module-level `requests.get(...)` with a safe session held on `self`. - api_vulners: validate `tool_config.url` when supplied (the `vulners` library owns its own transport). - api_bugcrowd, api_cobalt: URL is hardcoded; only the session swap to `make_ssrf_safe_session` is needed. Two SonarQube fixtures used `http://localhost/`; updated them to a public URL so the importer tests continue to pass, and updated three assertion strings in `test_api_sonarqube_importer.py` plus the dummy hostname in `test_api_sonarqube_parser.py` accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- dojo/fixtures/unit_sonarqube_toolConfig1.json | 2 +- dojo/fixtures/unit_sonarqube_toolConfig2.json | 2 +- dojo/tools/api_bugcrowd/api_client.py | 5 +- dojo/tools/api_cobalt/api_client.py | 5 +- dojo/tools/api_edgescan/api_client.py | 11 +- dojo/tools/api_sonarqube/api_client.py | 10 +- dojo/tools/api_vulners/api_client.py | 7 + unittests/test_tool_api_clients_ssrf.py | 134 ++++++++++++++++++ .../tools/test_api_sonarqube_importer.py | 6 +- unittests/tools/test_api_sonarqube_parser.py | 2 +- 10 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 unittests/test_tool_api_clients_ssrf.py diff --git a/dojo/fixtures/unit_sonarqube_toolConfig1.json b/dojo/fixtures/unit_sonarqube_toolConfig1.json index 1dcaf85e07a..f5a87bfa8a3 100644 --- a/dojo/fixtures/unit_sonarqube_toolConfig1.json +++ b/dojo/fixtures/unit_sonarqube_toolConfig1.json @@ -5,7 +5,7 @@ "fields": { "name": "SQ1", "description": null, - "url": "http://localhost/", + "url": "https://8.8.8.8/", "tool_type": 1, "authentication_type": "API", "extras": null, diff --git a/dojo/fixtures/unit_sonarqube_toolConfig2.json b/dojo/fixtures/unit_sonarqube_toolConfig2.json index f5be60592f2..df0e197c278 100644 --- a/dojo/fixtures/unit_sonarqube_toolConfig2.json +++ b/dojo/fixtures/unit_sonarqube_toolConfig2.json @@ -5,7 +5,7 @@ "fields": { "name": "SQ2", "description": null, - "url": "http://localhost/", + "url": "https://8.8.8.8/", "tool_type": 1, "authentication_type": "API", "extras": null, diff --git a/dojo/tools/api_bugcrowd/api_client.py b/dojo/tools/api_bugcrowd/api_client.py index 698d87337ce..1d9a23677e5 100644 --- a/dojo/tools/api_bugcrowd/api_client.py +++ b/dojo/tools/api_bugcrowd/api_client.py @@ -1,8 +1,9 @@ from urllib.parse import urlencode -import requests from django.conf import settings +from dojo.utils_ssrf import make_ssrf_safe_session + class BugcrowdAPI: @@ -16,7 +17,7 @@ class BugcrowdAPI: } def __init__(self, tool_config): - self.session = requests.Session() + self.session = make_ssrf_safe_session() if tool_config.authentication_type == "API": self.api_token = tool_config.api_key self.session.headers.update( diff --git a/dojo/tools/api_cobalt/api_client.py b/dojo/tools/api_cobalt/api_client.py index acd01635e90..22fc52349eb 100644 --- a/dojo/tools/api_cobalt/api_client.py +++ b/dojo/tools/api_cobalt/api_client.py @@ -1,6 +1,7 @@ -import requests from django.conf import settings +from dojo.utils_ssrf import make_ssrf_safe_session + class CobaltAPI: @@ -9,7 +10,7 @@ class CobaltAPI: cobalt_api_url = "https://api.cobalt.io" def __init__(self, tool_config): - self.session = requests.Session() + self.session = make_ssrf_safe_session() if tool_config.authentication_type == "API": self.api_token = tool_config.api_key self.org_token = tool_config.extras diff --git a/dojo/tools/api_edgescan/api_client.py b/dojo/tools/api_edgescan/api_client.py index 580d753226c..5c70b92c929 100644 --- a/dojo/tools/api_edgescan/api_client.py +++ b/dojo/tools/api_edgescan/api_client.py @@ -1,9 +1,10 @@ import json from json.decoder import JSONDecodeError -import requests from django.conf import settings +from dojo.utils_ssrf import SSRFError, make_ssrf_safe_session, validate_url_for_ssrf + class EdgescanAPI: @@ -15,6 +16,12 @@ def __init__(self, tool_config): if tool_config.authentication_type == "API": self.api_key = tool_config.api_key self.url = tool_config.url or self.DEFAULT_URL + try: + validate_url_for_ssrf(self.url) + except SSRFError as e: + msg = f"Edgescan URL is not allowed: {e}" + raise ValueError(msg) from e + self.session = make_ssrf_safe_session() self.options = self.get_extra_options(tool_config) else: msg = f"Edgescan Authentication type {tool_config.authentication_type} not supported" @@ -39,7 +46,7 @@ def get_findings(self, asset_ids): if self.options and "date" in self.options: url += f"&c[date_opened_after]={self.options['date']}" - response = requests.get( + response = self.session.get( url=url, headers=self.get_headers(), proxies=self.get_proxies(), diff --git a/dojo/tools/api_sonarqube/api_client.py b/dojo/tools/api_sonarqube/api_client.py index e7d78fae1da..e6440d3830a 100644 --- a/dojo/tools/api_sonarqube/api_client.py +++ b/dojo/tools/api_sonarqube/api_client.py @@ -1,12 +1,18 @@ -import requests from django.conf import settings from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError from dojo.utils import prepare_for_view +from dojo.utils_ssrf import SSRFError, make_ssrf_safe_session, validate_url_for_ssrf class SonarQubeAPI: def __init__(self, tool_config): + try: + validate_url_for_ssrf(tool_config.url) + except SSRFError as e: + msg = f"SonarQube URL is not allowed: {e}" + raise ValueError(msg) from e + self.rules_cache = {} supported_issue_types = ["BUG", "VULNERABILITY", "CODE_SMELL"] @@ -42,7 +48,7 @@ def __init__(self, tool_config): msg = f"Detected unsupported issue type! Supported types are {', '.join(supported_issue_types)}" raise Exception(msg) - self.session = requests.Session() + self.session = make_ssrf_safe_session() self.default_headers = {"User-Agent": "DefectDojo"} self.sonar_api_url = tool_config.url if tool_config.authentication_type == "Password": diff --git a/dojo/tools/api_vulners/api_client.py b/dojo/tools/api_vulners/api_client.py index 72a8636bed4..cf88b9d0a45 100644 --- a/dojo/tools/api_vulners/api_client.py +++ b/dojo/tools/api_vulners/api_client.py @@ -1,5 +1,7 @@ import vulners +from dojo.utils_ssrf import SSRFError, validate_url_for_ssrf + class VulnersAPI: @@ -12,6 +14,11 @@ def __init__(self, tool_config): if tool_config.authentication_type == "API": self.api_key = tool_config.api_key if tool_config.url: + try: + validate_url_for_ssrf(tool_config.url) + except SSRFError as e: + msg = f"Vulners URL is not allowed: {e}" + raise ValueError(msg) from e self.vulners_api_url = tool_config.url else: msg = f"Vulners.com Authentication type {tool_config.authentication_type} not supported" diff --git a/unittests/test_tool_api_clients_ssrf.py b/unittests/test_tool_api_clients_ssrf.py new file mode 100644 index 00000000000..94f05b7f7df --- /dev/null +++ b/unittests/test_tool_api_clients_ssrf.py @@ -0,0 +1,134 @@ +import socket +from types import SimpleNamespace +from unittest.mock import patch + +from dojo.tools.api_bugcrowd.api_client import BugcrowdAPI +from dojo.tools.api_cobalt.api_client import CobaltAPI +from dojo.tools.api_edgescan.api_client import EdgescanAPI +from dojo.tools.api_sonarqube.api_client import SonarQubeAPI +from dojo.tools.api_vulners.api_client import VulnersAPI +from dojo.utils_ssrf import _SSRFSafeAdapter # noqa: PLC2701 +from unittests.dojo_test_case import DojoTestCase + + +def _sonarqube_config(url): + return SimpleNamespace( + url=url, + authentication_type="API", + api_key="dummy-key", + extras=None, + ) + + +def _edgescan_config(url): + return SimpleNamespace( + url=url, + authentication_type="API", + api_key="dummy-key", + extras=None, + ) + + +def _vulners_config(url): + return SimpleNamespace( + url=url, + authentication_type="API", + api_key="dummy-key", + ) + + +def _bugcrowd_config(): + return SimpleNamespace( + authentication_type="API", + api_key="dummy-key", + ) + + +def _cobalt_config(): + return SimpleNamespace( + authentication_type="API", + api_key="dummy-key", + extras=None, + ) + + +class TestSonarQubeUrlValidation(DojoTestCase): + + def test_private_url_raises(self): + with self.assertRaisesRegex(ValueError, "SonarQube URL is not allowed"): + SonarQubeAPI(_sonarqube_config("http://192.168.1.1/")) + + def test_loopback_url_raises(self): + with self.assertRaisesRegex(ValueError, "SonarQube URL is not allowed"): + SonarQubeAPI(_sonarqube_config("http://127.0.0.1/")) + + def test_link_local_metadata_url_raises(self): + with self.assertRaisesRegex(ValueError, "SonarQube URL is not allowed"): + SonarQubeAPI(_sonarqube_config("http://169.254.169.254/")) + + def test_public_url_succeeds(self): + # 8.8.8.8 is a numeric literal — no DNS lookup required, so this is + # reliable in CI. + client = SonarQubeAPI(_sonarqube_config("http://8.8.8.8/")) + self.assertEqual(client.sonar_api_url, "http://8.8.8.8/") + + +class TestEdgescanUrlValidation(DojoTestCase): + + def test_private_url_raises(self): + with self.assertRaisesRegex(ValueError, "Edgescan URL is not allowed"): + EdgescanAPI(_edgescan_config("http://192.168.1.1/")) + + def test_loopback_url_raises(self): + with self.assertRaisesRegex(ValueError, "Edgescan URL is not allowed"): + EdgescanAPI(_edgescan_config("http://127.0.0.1/")) + + def test_public_url_succeeds(self): + client = EdgescanAPI(_edgescan_config("http://8.8.8.8/")) + self.assertEqual(client.url, "http://8.8.8.8/") + + def test_default_url_succeeds(self): + # tool_config.url=None should fall back to DEFAULT_URL (a public host). + with patch("dojo.utils_ssrf.socket.getaddrinfo") as mock_getaddrinfo: + mock_getaddrinfo.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80)), + ] + client = EdgescanAPI(_edgescan_config(None)) + self.assertEqual(client.url, EdgescanAPI.DEFAULT_URL) + + +class TestVulnersUrlValidation(DojoTestCase): + + def test_private_url_raises(self): + with self.assertRaisesRegex(ValueError, "Vulners URL is not allowed"): + VulnersAPI(_vulners_config("http://192.168.1.1/")) + + def test_loopback_url_raises(self): + with self.assertRaisesRegex(ValueError, "Vulners URL is not allowed"): + VulnersAPI(_vulners_config("http://127.0.0.1/")) + + def test_public_url_succeeds(self): + client = VulnersAPI(_vulners_config("http://8.8.8.8/")) + self.assertEqual(client.vulners_api_url, "http://8.8.8.8/") + + def test_no_url_uses_library_default(self): + # When no URL is configured, the validation is skipped and the + # external `vulners` library uses its own default endpoint. + client = VulnersAPI(_vulners_config(None)) + self.assertIsNone(client.vulners_api_url) + + +class TestBugcrowdSessionIsSafe(DojoTestCase): + + def test_session_uses_ssrf_safe_adapter(self): + client = BugcrowdAPI(_bugcrowd_config()) + for adapter in client.session.adapters.values(): + self.assertIsInstance(adapter, _SSRFSafeAdapter) + + +class TestCobaltSessionIsSafe(DojoTestCase): + + def test_session_uses_ssrf_safe_adapter(self): + client = CobaltAPI(_cobalt_config()) + for adapter in client.session.adapters.values(): + self.assertIsInstance(adapter, _SSRFSafeAdapter) diff --git a/unittests/tools/test_api_sonarqube_importer.py b/unittests/tools/test_api_sonarqube_importer.py index 8dd3b9eafa0..7498098e144 100644 --- a/unittests/tools/test_api_sonarqube_importer.py +++ b/unittests/tools/test_api_sonarqube_importer.py @@ -292,7 +292,7 @@ def test_parser(self): self.assertEqual('Remove this useless assignment to local variable "currentValue".', finding.title) self.assertEqual(None, finding.cwe) self.assertEqual("", finding.description) - self.assertEqual("[Issue permalink](http://localhoproject/issues?issues=AWKWIl8pZpu0CyehMfc4&open=AWKWIl8pZpu0CyehMfc4&resolved=CONFIRMED&id=internal.dummy.project) \n", finding.references) + self.assertEqual("[Issue permalink](https://8.8.8project/issues?issues=AWKWIl8pZpu0CyehMfc4&open=AWKWIl8pZpu0CyehMfc4&resolved=CONFIRMED&id=internal.dummy.project) \n", finding.references) self.assertEqual("Medium", finding.severity) self.assertEqual(242, finding.line) self.assertEqual("internal.dummy.project:src/main/javascript/TranslateDirective.ts", finding.file_path) @@ -516,7 +516,7 @@ def test_parser(self): ) self.assertEqual(str(findings[0].severity), "High") self.assertMultiLineEqual( - "[Hotspot permalink](http://localhosecurity_hotspots?id=internal.dummy.project&hotspots=AXgm6Z-ophPPY0C1qhRq) " + "[Hotspot permalink](https://8.8.8security_hotspots?id=internal.dummy.project&hotspots=AXgm6Z-ophPPY0C1qhRq) " "\n" "[CVE-2019-13466](http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-13466)" "\n" @@ -586,7 +586,7 @@ def test_parser(self): findings[0].description, ) self.assertEqual(str(findings[0].severity), "High") - self.assertEqual(findings[0].references, "[Hotspot permalink](http://localhosecurity_hotspots?id=internal.dummy.project&hotspots=AXgm6Z-ophPPY0C1qhRq) \n") + self.assertEqual(findings[0].references, "[Hotspot permalink](https://8.8.8security_hotspots?id=internal.dummy.project&hotspots=AXgm6Z-ophPPY0C1qhRq) \n") self.assertEqual(str(findings[0].file_path), "internal.dummy.project:spec/support/user_fixture.rb") self.assertEqual(findings[0].line, 9) self.assertEqual(findings[0].active, True) diff --git a/unittests/tools/test_api_sonarqube_parser.py b/unittests/tools/test_api_sonarqube_parser.py index 7e86adc44f2..9c77de3d7b4 100644 --- a/unittests/tools/test_api_sonarqube_parser.py +++ b/unittests/tools/test_api_sonarqube_parser.py @@ -46,7 +46,7 @@ def setUp(self): # build Sonarqube conf (the parser need it) tool_type, _ = Tool_Type.objects.get_or_create(name="SonarQube") tool_conf, _ = Tool_Configuration.objects.get_or_create( - name="SQ1_unittests", authentication_type="API", tool_type=tool_type, url="http://dummy.url.foo.bar/api", + name="SQ1_unittests", authentication_type="API", tool_type=tool_type, url="https://8.8.8.8/api", ) pasc, _ = Product_API_Scan_Configuration.objects.get_or_create( product=product, tool_configuration=tool_conf, service_key_1="ABCD",