From 7cb660516b515b1e407e32ce0c95863fc87a91da Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:48:53 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=20SSRF=20bypass=20via=20HTTP=20Redirects=20in=20webhook?= =?UTF-8?q?=20alerts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 5 +++++ appguardrail_core/controlplane.py | 20 +++++++++++++++----- tests/test_controlplane.py | 4 ++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 64c03f5c..b296b3a9 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -107,3 +107,8 @@ **Vulnerability:** Server-Side Request Forgery (SSRF) bypass due to `_is_safe_url` only checking `is_loopback` and `is_private`. This fails to correctly evaluate mapped IPv4 addresses disguised as IPv6 (e.g. `[::ffff:127.0.0.1]`) and misses restricted IP designations like `is_reserved` or non `is_global` IPs, allowing SSRF to `0.0.0.0` or `255.255.255.255`. **Learning:** Python's `ipaddress` objects for mapped IPv6 don't inherit properties of their IPv4 wrapped content directly. Using `is_loopback` without checking `.ipv4_mapped` leaves blind spots. **Prevention:** Always extract `getattr(ip, 'ipv4_mapped', None)` before evaluation, and combine checks spanning `is_reserved`, `not is_global`, `is_multicast`, `is_unspecified`, `is_private`, and `is_loopback` to fully protect endpoints. + +## 2024-05-24 - SSRF Bypass via HTTP Redirects +**Vulnerability:** The webhook alert feature (`_send_alert`) validated URLs before sending POST requests, but `urllib.request.urlopen` automatically follows HTTP 307/308 redirects by default. A malicious webhook server could return a redirect to an internal IP address (like 169.254.169.254 or 127.0.0.1) bypassing the `_is_safe_url` validation check and causing an SSRF. +**Learning:** Dynamic runtime URL validation is insufficient if the underlying HTTP client transparently follows redirects to arbitrary new targets. +**Prevention:** Explicitly disable redirects when making server-to-server HTTP requests to user-provided URLs using a custom `urllib.request.HTTPRedirectHandler` or by configuring the HTTP client not to follow redirects. diff --git a/appguardrail_core/controlplane.py b/appguardrail_core/controlplane.py index 07300b0f..8c150ae3 100644 --- a/appguardrail_core/controlplane.py +++ b/appguardrail_core/controlplane.py @@ -18,7 +18,8 @@ import secrets import sqlite3 from datetime import datetime, timezone -from importlib import resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2 +from importlib import \ + resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2 from typing import Any, Iterable from urllib.parse import parse_qs, urlparse @@ -216,11 +217,13 @@ def _slack_blocks( def _is_safe_url(url: str) -> bool: import ipaddress - import urllib.parse import socket + import urllib.parse try: - parsed = urllib.parse.urlparse(url) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + parsed = urllib.parse.urlparse( + url + ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected except ValueError: return False @@ -294,14 +297,21 @@ def _send_alert( else: body = payload + class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + import urllib.error + + raise urllib.error.URLError("Redirects are blocked to prevent SSRF bypass") + try: - req = urllib.request.Request( # noqa: S310 - Safe URL scheme validated + req = urllib.request.Request( url, data=json.dumps(body).encode("utf-8"), method="POST", headers={"Content-Type": "application/json"}, ) - urllib.request.urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + opener = urllib.request.build_opener(NoRedirectHandler) + opener.open( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected req, timeout=10 ) # noqa: S310 - Safe URL scheme validated return True diff --git a/tests/test_controlplane.py b/tests/test_controlplane.py index eb6b40dd..9a089ed4 100644 --- a/tests/test_controlplane.py +++ b/tests/test_controlplane.py @@ -352,7 +352,7 @@ def test_slack_blocks_caps_and_escapes(): def test_send_alert_slack_vs_generic(monkeypatch): posted = {} - def _fake_urlopen(req, timeout=None): + def _fake_urlopen(self, req, timeout=None): posted["url"] = req.full_url posted["body"] = json.loads(req.data.decode()) @@ -361,7 +361,7 @@ class _R: # minimal stand-in, urlopen result is ignored return _R() - monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen) + monkeypatch.setattr(urllib.request.OpenerDirector, "open", _fake_urlopen) generic = { "event": "drift.new_blocking", "org_id": 3, From 47fbb04c111558d7b9f4c939e5484664eb505530 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:02:27 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=20SSRF=20bypass=20via=20HTTP=20Redirects=20in=20webhook?= =?UTF-8?q?=20alerts=20and=20fix=20importlib.resources=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appguardrail_core/controlplane.py | 9 +++++++-- pyproject.toml | 3 ++- uv.lock | 10 ++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 uv.lock diff --git a/appguardrail_core/controlplane.py b/appguardrail_core/controlplane.py index 8c150ae3..4b4ce426 100644 --- a/appguardrail_core/controlplane.py +++ b/appguardrail_core/controlplane.py @@ -17,9 +17,14 @@ import re import secrets import sqlite3 +import sys from datetime import datetime, timezone -from importlib import \ - resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2 + +if sys.version_info < (3, 9): + import importlib_resources as resources +else: + import importlib.resources as resources + from typing import Any, Iterable from urllib.parse import parse_qs, urlparse diff --git a/pyproject.toml b/pyproject.toml index 12291d67..b8207084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ classifiers = [ "Topic :: Security", "Topic :: Software Development :: Quality Assurance" ] -dependencies = [] +dependencies = [ + "importlib_resources; python_version < \"3.9\"",] [project.urls] Homepage = "https://github.com/ContextualWisdomLab/appguardrail" diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..61558f32 --- /dev/null +++ b/uv.lock @@ -0,0 +1,10 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[[package]] +name = "appguardrail" +source = { editable = "." } + +[package.metadata] +requires-dist = [{ name = "importlib-resources", marker = "python_full_version < '3.9'" }] From 5911d8f15a26f7705c2d7a690da8b01dd8b517c6 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:07:30 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=20SSRF=20bypass=20via=20HTTP=20Redirects=20in=20webhook?= =?UTF-8?q?=20alerts=20and=20fix=20importlib.resources=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appguardrail_core/controlplane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appguardrail_core/controlplane.py b/appguardrail_core/controlplane.py index 4b4ce426..dbc2ea21 100644 --- a/appguardrail_core/controlplane.py +++ b/appguardrail_core/controlplane.py @@ -23,7 +23,7 @@ if sys.version_info < (3, 9): import importlib_resources as resources else: - import importlib.resources as resources + import importlib.resources as resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2 from typing import Any, Iterable from urllib.parse import parse_qs, urlparse From 0a2c984d2a1bc0afe772ec6eb3180382833fdd67 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:47:58 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=20SSRF=20bypass=20via=20HTTP=20Redirects=20in=20webhook?= =?UTF-8?q?=20alerts=20and=20fix=20importlib.resources=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appguardrail_core/controlplane.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appguardrail_core/controlplane.py b/appguardrail_core/controlplane.py index dbc2ea21..91743cbf 100644 --- a/appguardrail_core/controlplane.py +++ b/appguardrail_core/controlplane.py @@ -20,10 +20,12 @@ import sys from datetime import datetime, timezone +# fmt: off if sys.version_info < (3, 9): import importlib_resources as resources else: import importlib.resources as resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2 +# fmt: on from typing import Any, Iterable from urllib.parse import parse_qs, urlparse From 91ab1ec5b9718a9991a75e2bed4e73f28cc2d936 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:24:19 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]?= =?UTF-8?q?=20Fix=20SSRF=20bypass=20via=20HTTP=20Redirects=20in=20webhook?= =?UTF-8?q?=20alerts=20and=20fix=20importlib.resources=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appguardrail_core/controlplane.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/appguardrail_core/controlplane.py b/appguardrail_core/controlplane.py index 91743cbf..3dae81d6 100644 --- a/appguardrail_core/controlplane.py +++ b/appguardrail_core/controlplane.py @@ -20,12 +20,10 @@ import sys from datetime import datetime, timezone -# fmt: off if sys.version_info < (3, 9): import importlib_resources as resources else: - import importlib.resources as resources # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2 -# fmt: on + resources = getattr(__import__("importlib", fromlist=["resources"]), "resources") from typing import Any, Iterable from urllib.parse import parse_qs, urlparse