From 7d2d777e65e8b80c6e547e78ee83a334545fe1a6 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Mon, 16 Mar 2026 03:48:57 +0100 Subject: [PATCH 1/2] Create an AIKIDO_TRUSTED_HOSTNAMES and parse --- aikido_zen/helpers/get_trusted_hostnames.py | 17 +++++++++++++++++ docs/ssrf.md | 12 ++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 aikido_zen/helpers/get_trusted_hostnames.py diff --git a/aikido_zen/helpers/get_trusted_hostnames.py b/aikido_zen/helpers/get_trusted_hostnames.py new file mode 100644 index 000000000..5953d147d --- /dev/null +++ b/aikido_zen/helpers/get_trusted_hostnames.py @@ -0,0 +1,17 @@ +"""Helper function file, see function docstring""" + +import os + + +def get_trusted_hostnames(): + """ + Parses the AIKIDO_TRUSTED_HOSTNAMES environment variable. + Returns a list of hostnames that should be considered as the server itself + (i.e. outgoing requests to these hosts will not be flagged as SSRF). + The value is expected to be a comma-separated list of hostnames, e.g.: + AIKIDO_TRUSTED_HOSTNAMES=myapp.com,api.myapp.com + """ + env_value = os.getenv("AIKIDO_TRUSTED_HOSTNAMES") + if not env_value: + return [] + return [h.strip() for h in env_value.split(",") if h.strip()] diff --git a/docs/ssrf.md b/docs/ssrf.md index 0f3abf07f..12c288c76 100644 --- a/docs/ssrf.md +++ b/docs/ssrf.md @@ -22,6 +22,18 @@ In this example, the attacker sends a request to `localtest.me:3000/private`, wh We don't protect against stored SSRF attacks, where an attacker injects a malicious URL into your app's database. To prevent stored SSRF attacks, validate and sanitize user input before storing it in your database. +## Allowlisting your own hostnames (`AIKIDO_TRUSTED_HOSTNAMES`) + +To safely allow outgoing requests to your own services, set the `AIKIDO_TRUSTED_HOSTNAMES` environment variable to a comma-separated list of your hostnames: + +``` +AIKIDO_TRUSTED_HOSTNAMES=myapp.com,api.myapp.com,backend.internal +``` +Any outgoing request whose destination hostname exactly matches one of these values will not be flagged as SSRF + +**When should you set this?** +Set `AIKIDO_TRUSTED_HOSTNAMES` when your application makes outgoing requests to its own domain, and those hostnames appear in user-supplied input (e.g. `Host`, `Referer`, `Origin`, etc.) + ## Which built-in modules are protected? Firewall protects against SSRF attacks in the following built-in modules: From 19674ee2418ec366c639e140bf7b4e1be61fcd7e Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Mon, 16 Mar 2026 03:49:11 +0100 Subject: [PATCH 2/2] is_request_to_itself: check AIKIDO_TRUSTED_HOSTNAMES --- .../ssrf/find_hostname_in_context.py | 6 +- .../ssrf/is_request_to_itself.py | 41 +++--------- .../ssrf/is_request_to_itself_test.py | 67 ++++++++----------- 3 files changed, 39 insertions(+), 75 deletions(-) diff --git a/aikido_zen/vulnerabilities/ssrf/find_hostname_in_context.py b/aikido_zen/vulnerabilities/ssrf/find_hostname_in_context.py index 19091a7d2..4d455a6c7 100644 --- a/aikido_zen/vulnerabilities/ssrf/find_hostname_in_context.py +++ b/aikido_zen/vulnerabilities/ssrf/find_hostname_in_context.py @@ -15,9 +15,9 @@ def find_hostname_in_context(hostname, context: Context, port): # Validate hostname and port input return None - # We don't want to block outgoing requests to the same host as the server - # (often happens that we have a match on headers like `Host`, `Origin`, `Referer`, etc.) - if is_request_to_itself(context.url, hostname, port): + # We don't want to block outgoing requests to hostnames the operator has + # declared as their own server via AIKIDO_TRUSTED_HOSTNAMES. + if is_request_to_itself(hostname): return None # Gets the different hostname options: with/without punycode, with/without brackets for IPv6 diff --git a/aikido_zen/vulnerabilities/ssrf/is_request_to_itself.py b/aikido_zen/vulnerabilities/ssrf/is_request_to_itself.py index a3676c75c..6e31288cb 100644 --- a/aikido_zen/vulnerabilities/ssrf/is_request_to_itself.py +++ b/aikido_zen/vulnerabilities/ssrf/is_request_to_itself.py @@ -1,39 +1,14 @@ -from aikido_zen.helpers.get_ip_from_request import trust_proxy -from aikido_zen.helpers.get_port_from_url import get_port_from_url -from aikido_zen.helpers.try_parse_url import try_parse_url +from aikido_zen.helpers.get_trusted_hostnames import get_trusted_hostnames -def is_request_to_itself(server_url, outbound_hostname, outbound_port): +def is_request_to_itself(outbound_hostname): """ - We don't want to block outgoing requests to the same host as the server - (often happens that we have a match on headers like `Host`, `Origin`, `Referer`, etc.) - We have to check the port as well, because the hostname can be the same but with a different port + We don't want to block outgoing requests to hostnames that are explicitly + declared as the server itself via AIKIDO_TRUSTED_HOSTNAMES. Customers must explicitly list their own hostnames in + the AIKIDO_TRUSTED_HOSTNAMES environment variable (comma-separated). """ - - # When the app is not behind a reverse proxy, we can't trust the hostname inside `server_url` - # The hostname in `server_url` is built from the request headers - # The headers can be manipulated by the client if the app is directly exposed to the internet - if not trust_proxy(): - return False - - base_url = try_parse_url(server_url) - if base_url is None: - return False - - if base_url.hostname != outbound_hostname: + if not outbound_hostname or not isinstance(outbound_hostname, str): return False - base_url_port = get_port_from_url(base_url, parsed=True) - - # If the port and hostname are the same, the server is making a request to itself - if base_url_port == outbound_port: - return True - - # Special case for HTTP/HTTPS ports - # In production, the app will be served on port 80 and 443 - if base_url_port == 80 and outbound_port == 443: - return True - if base_url_port == 443 and outbound_port == 80: - return True - - return False + trusted = get_trusted_hostnames() + return outbound_hostname in trusted diff --git a/aikido_zen/vulnerabilities/ssrf/is_request_to_itself_test.py b/aikido_zen/vulnerabilities/ssrf/is_request_to_itself_test.py index 5049c2a79..5197b9a5b 100644 --- a/aikido_zen/vulnerabilities/ssrf/is_request_to_itself_test.py +++ b/aikido_zen/vulnerabilities/ssrf/is_request_to_itself_test.py @@ -6,58 +6,47 @@ @pytest.fixture(autouse=True) def clear_environment(): - # Clear the environment variable before each test - os.environ.pop("AIKIDO_TRUST_PROXY", None) + os.environ.pop("AIKIDO_TRUSTED_HOSTNAMES", None) + yield + os.environ.pop("AIKIDO_TRUSTED_HOSTNAMES", None) -def test_returns_false_if_server_url_is_empty(): - assert not is_request_to_itself("", "aikido.dev", 80) +def test_returns_false_if_no_trusted_hostnames_configured(): + assert not is_request_to_itself("aikido.dev") + assert not is_request_to_itself("localhost") -def test_returns_false_if_server_url_is_invalid(): - assert not is_request_to_itself("http://", "aikido.dev", 80) +def test_returns_false_if_hostname_not_in_trusted_list(monkeypatch): + monkeypatch.setenv("AIKIDO_TRUSTED_HOSTNAMES", "myapp.com,api.myapp.com") + assert not is_request_to_itself("aikido.dev") + assert not is_request_to_itself("google.com") -def test_returns_false_if_port_is_different(): - assert not is_request_to_itself("http://aikido.dev:4000", "aikido.dev", 80) - assert not is_request_to_itself("https://aikido.dev:4000", "aikido.dev", 443) +def test_returns_true_if_hostname_in_trusted_list(monkeypatch): + monkeypatch.setenv("AIKIDO_TRUSTED_HOSTNAMES", "myapp.com,api.myapp.com") + assert is_request_to_itself("myapp.com") + assert is_request_to_itself("api.myapp.com") -def test_returns_false_if_hostname_is_different(): - assert not is_request_to_itself("http://aikido.dev", "google.com", 80) - assert not is_request_to_itself("http://aikido.dev:4000", "google.com", 4000) - assert not is_request_to_itself("https://aikido.dev", "google.com", 443) - assert not is_request_to_itself("https://aikido.dev:4000", "google.com", 443) +def test_returns_true_for_single_trusted_hostname(monkeypatch): + monkeypatch.setenv("AIKIDO_TRUSTED_HOSTNAMES", "aikido.dev") + assert is_request_to_itself("aikido.dev") -def test_returns_true_if_server_does_request_to_itself(): - assert is_request_to_itself("https://aikido.dev", "aikido.dev", 443) - assert is_request_to_itself("http://aikido.dev:4000", "aikido.dev", 4000) - assert is_request_to_itself("http://aikido.dev", "aikido.dev", 80) - assert is_request_to_itself("https://aikido.dev:4000", "aikido.dev", 4000) +def test_strips_whitespace_from_trusted_hostnames(monkeypatch): + monkeypatch.setenv("AIKIDO_TRUSTED_HOSTNAMES", " myapp.com , api.myapp.com ") + assert is_request_to_itself("myapp.com") + assert is_request_to_itself("api.myapp.com") -def test_returns_true_for_special_case_http_to_https(): - assert is_request_to_itself("http://aikido.dev", "aikido.dev", 443) - assert is_request_to_itself("https://aikido.dev", "aikido.dev", 80) +def test_returns_false_if_hostname_is_none(): + assert not is_request_to_itself(None) -def test_returns_false_if_trust_proxy_is_false(monkeypatch): - monkeypatch.setenv("AIKIDO_TRUST_PROXY", "false") - assert not is_request_to_itself("https://aikido.dev", "aikido.dev", 443) - assert not is_request_to_itself("http://aikido.dev", "aikido.dev", 80) +def test_returns_false_if_hostname_is_empty(): + assert not is_request_to_itself("") -def test_returns_false_if_server_url_is_null(): - assert not is_request_to_itself(None, "aikido.dev", 80) - assert not is_request_to_itself(None, "aikido.dev", 443) - - -def test_returns_false_if_hostname_is_null(): - assert not is_request_to_itself("http://aikido.dev:4000", None, 80) - assert not is_request_to_itself("https://aikido.dev:4000", None, 443) - - -def test_returns_false_if_both_are_null(): - assert not is_request_to_itself(None, None, 80) - assert not is_request_to_itself(None, None, 443) +def test_returns_false_if_hostname_is_not_a_string(): + assert not is_request_to_itself(123) + assert not is_request_to_itself(["myapp.com"])