From 0052db00938023a246eab9b0a0a89cea6eea954b Mon Sep 17 00:00:00 2001 From: Deep Santoshwar Date: Wed, 5 Aug 2026 17:22:53 +0530 Subject: [PATCH 1/6] fix(webhooks): validate organisation and integration URLs against SSRF OrganisationWebhookSerializer, the GitLab/Sentry/generic webhook integrations, and every integration sharing IntegrationsModel.base_url (Dynatrace, Datadog, New Relic, Grafana, Amplitude, Heap, Mixpanel, Rudderstack) accepted arbitrary URLs with only format validation. Apply the existing NoSSRFURLField, used by environment webhooks, to these fields so they're resolved and checked against internal/private address ranges too. Also restrict NoSSRFURLField itself to http/https schemes, closing an ftp/ftps gap in the existing validator. --- api/integrations/common/serializers.py | 14 ++++ api/integrations/gitlab/serializers.py | 3 + api/integrations/sentry/serializers.py | 3 + api/integrations/webhook/serializers.py | 3 + api/organisations/serializers.py | 3 + .../datadog/test_unit_datadog_views.py | 24 +++++++ .../dynatrace/test_unit_dynatrace_views.py | 27 ++++++++ .../integrations/gitlab/test_configuration.py | 19 ++++++ .../new_relic/test_unit_new_relic_views.py | 24 +++++++ .../sentry/test_unit_sentry_views.py | 21 ++++++ .../webhook/test_unit_webhook_views.py | 22 ++++++ .../test_unit_organisations_serializers.py | 68 ++++++++++++++++++- .../unit/webhooks/test_webhooks_fields.py | 21 ++++++ api/webhooks/fields.py | 35 ++++++++-- 14 files changed, 279 insertions(+), 8 deletions(-) diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index ee6a1b0fb135..f11621d75f88 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -3,10 +3,24 @@ from django.db.models import Model from rest_framework.serializers import ModelSerializer +from webhooks.fields import NoSSRFURLField + class _BaseIntegrationModelSerializer(ModelSerializer): # type: ignore[type-arg] one_to_one_field_name = None + def build_standard_field(self, field_name, model_field): # type: ignore[no-untyped-def] # noqa: E501 + # Every integration model's `base_url` is a plain `URLField`, which + # only checks URL format. Swap in `NoSSRFURLField` (same field_kwargs + # derived from the model, e.g. required/allow_null/max_length) to + # also block SSRF against internal/private network addresses. + field_class, field_kwargs = super().build_standard_field( + field_name, model_field + ) + if field_name == "base_url": + field_class = NoSSRFURLField + return field_class, field_kwargs + def create(self, validated_data): # type: ignore[no-untyped-def] if existing_obj := self._get_existing_integration_model_obj(validated_data): existing_obj.deleted_at = None # type: ignore[attr-defined] diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index 0c0297d29fb0..7d891cdd18aa 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -4,11 +4,14 @@ from integrations.common.serializers import BaseProjectIntegrationModelSerializer from integrations.gitlab.models import GitLabConfiguration +from webhooks.fields import NoSSRFURLField WRITE_ONLY_PLACEHOLDER = "write-only" class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer): + gitlab_instance_url = NoSSRFURLField() + class Meta: model = GitLabConfiguration fields = ("id", "gitlab_instance_url", "access_token", "labeling_enabled") diff --git a/api/integrations/sentry/serializers.py b/api/integrations/sentry/serializers.py index 52f23e6ffec5..38ef3dd98ac7 100644 --- a/api/integrations/sentry/serializers.py +++ b/api/integrations/sentry/serializers.py @@ -1,4 +1,5 @@ from integrations.common.serializers import BaseEnvironmentIntegrationModelSerializer +from webhooks.fields import NoSSRFURLField from .models import SentryChangeTrackingConfiguration @@ -6,6 +7,8 @@ class SentryChangeTrackingConfigurationSerializer( BaseEnvironmentIntegrationModelSerializer ): + webhook_url = NoSSRFURLField() + class Meta: model = SentryChangeTrackingConfiguration fields = ["id", "environment", "webhook_url", "secret"] diff --git a/api/integrations/webhook/serializers.py b/api/integrations/webhook/serializers.py index b844ff6b7308..f790cc8fc01d 100644 --- a/api/integrations/webhook/serializers.py +++ b/api/integrations/webhook/serializers.py @@ -10,11 +10,14 @@ ) from segments.models import Segment from util.mappers.engine import map_environment_to_evaluation_context +from webhooks.fields import NoSSRFURLField from .models import WebhookConfiguration class WebhookConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): + url = NoSSRFURLField() + class Meta: model = WebhookConfiguration fields = ("id", "url", "secret") diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index baf70e32ebc7..d9df633f07d0 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -12,6 +12,7 @@ ) from organisations.invites.models import Invite from users.models import FFAdminUser, UserPermissionGroup +from webhooks.fields import NoSSRFURLField from .models import ( Organisation, @@ -234,6 +235,8 @@ class PortalUrlSerializer(serializers.Serializer): # type: ignore[type-arg] class OrganisationWebhookSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + url = NoSSRFURLField() + class Meta: model = OrganisationWebhook fields = ("id", "url", "enabled", "secret", "created_at", "updated_at") diff --git a/api/tests/unit/integrations/datadog/test_unit_datadog_views.py b/api/tests/unit/integrations/datadog/test_unit_datadog_views.py index 87a532da23e0..94c1d5120227 100644 --- a/api/tests/unit/integrations/datadog/test_unit_datadog_views.py +++ b/api/tests/unit/integrations/datadog/test_unit_datadog_views.py @@ -177,3 +177,27 @@ def test_datadog_project_view__no_permissions__return_expected( # Then assert response.status_code == status.HTTP_403_FORBIDDEN assert not DataDogConfiguration.objects.filter(project=project).exists() + + +def test_datadog_config__private_ip_base_url__returns_bad_request( + admin_client: APIClient, + project: Project, +) -> None: + # Given + data = { + "base_url": "http://169.254.169.254/", + "api_key": "abc-123", + "use_custom_source": True, + } + url = reverse("api-v1:projects:integrations-datadog-list", args=[project.id]) + + # When + response = admin_client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not DataDogConfiguration.objects.filter(project=project).exists() diff --git a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py index e752bfeb3e01..519adf0e4eb9 100644 --- a/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py +++ b/api/tests/unit/integrations/dynatrace/test_unit_dynatrace_views.py @@ -171,3 +171,30 @@ def test_dynatrace_environment_view__no_permissions__return_expected( # Then assert response.status_code == status.HTTP_403_FORBIDDEN assert not DynatraceConfiguration.objects.filter(environment=environment).exists() + + +def test_create_dynatrace_config__private_ip_base_url__returns_bad_request( + admin_client: APIClient, + environment: Environment, +) -> None: + # Given + data = { + "base_url": "http://127.0.0.1/", + "api_key": "abc-123", + "entity_selector": "type(APPLICATION),entityName(docs)", + } + url = reverse( + "api-v1:environments:integrations-dynatrace-list", + args=[environment.api_key], + ) + + # When + response = admin_client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not DynatraceConfiguration.objects.filter(environment=environment).exists() diff --git a/api/tests/unit/integrations/gitlab/test_configuration.py b/api/tests/unit/integrations/gitlab/test_configuration.py index c66b77794eca..f0cf31b93d77 100644 --- a/api/tests/unit/integrations/gitlab/test_configuration.py +++ b/api/tests/unit/integrations/gitlab/test_configuration.py @@ -315,3 +315,22 @@ def test_delete_configuration__non_admin__returns_403( # Then assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_create_configuration__private_ip_instance_url__returns_400( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given / When + response = admin_client_new.post( + f"/api/v1/projects/{project.id}/integrations/gitlab/", + data={ + "gitlab_instance_url": "http://127.0.0.1/", + "access_token": "glpat-xxxxxxxxxxxxxxxxxxxx", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not GitLabConfiguration.objects.filter(project=project).exists() diff --git a/api/tests/unit/integrations/new_relic/test_unit_new_relic_views.py b/api/tests/unit/integrations/new_relic/test_unit_new_relic_views.py index ed9a962a73e9..266fc95a04cc 100644 --- a/api/tests/unit/integrations/new_relic/test_unit_new_relic_views.py +++ b/api/tests/unit/integrations/new_relic/test_unit_new_relic_views.py @@ -173,3 +173,27 @@ def test_new_relic_config__project_with_deleted_config__creates_new_configuratio assert response_json["api_key"] == api_key assert response_json["base_url"] == base_url assert response_json["app_id"] == app_id + + +def test_new_relic_config__private_ip_base_url__returns_bad_request( + admin_client: APIClient, + project: Project, +) -> None: + # Given + data = { + "base_url": "http://127.0.0.1/", + "api_key": "key-123", + "app_id": "app-123", + } + url = reverse("api-v1:projects:integrations-new-relic-list", args=[project.id]) + + # When + response = admin_client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not NewRelicConfiguration.objects.filter(project=project).exists() diff --git a/api/tests/unit/integrations/sentry/test_unit_sentry_views.py b/api/tests/unit/integrations/sentry/test_unit_sentry_views.py index 520591190633..15cf7670b985 100644 --- a/api/tests/unit/integrations/sentry/test_unit_sentry_views.py +++ b/api/tests/unit/integrations/sentry/test_unit_sentry_views.py @@ -106,3 +106,24 @@ def test_sentry_change_tracking_setup__invalid_payload__rejects_with_400( # Then assert response.status_code == 400 assert response.json() == errors + + +def test_sentry_change_tracking_setup__private_ip_webhook_url__rejects_with_400( + admin_client: APIClient, + environment: Environment, +) -> None: + # Given + url = f"/api/v1/environments/{environment.api_key}/integrations/sentry/" + payload = { + "webhook_url": "http://127.0.0.1/webhook", + "secret": "hush hush!", + } + + # When + response = admin_client.post(url, payload, format="json") + + # Then + assert response.status_code == 400 + assert not SentryChangeTrackingConfiguration.objects.filter( + environment=environment + ).exists() diff --git a/api/tests/unit/integrations/webhook/test_unit_webhook_views.py b/api/tests/unit/integrations/webhook/test_unit_webhook_views.py index 312f54ef26b3..64bda29e22da 100644 --- a/api/tests/unit/integrations/webhook/test_unit_webhook_views.py +++ b/api/tests/unit/integrations/webhook/test_unit_webhook_views.py @@ -116,3 +116,25 @@ def test_delete_webhook_config__existing_config__returns_204( # type: ignore[no # Then assert res.status_code == status.HTTP_204_NO_CONTENT assert not WebhookConfiguration.objects.filter(environment=environment).exists() + + +def test_create_webhook_config__private_ip_url__returns_400( # type: ignore[no-untyped-def] + admin_client, organisation, environment +): + # Given + url = reverse( + "api-v1:environments:integrations-webhook-list", + args=[environment.api_key], + ) + data = {"url": "http://127.0.0.1/webhooks", "secret": "random_secret"} + + # When + response = admin_client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not WebhookConfiguration.objects.filter(environment=environment).exists() diff --git a/api/tests/unit/organisations/test_unit_organisations_serializers.py b/api/tests/unit/organisations/test_unit_organisations_serializers.py index d9033659f800..1f713c6985f9 100644 --- a/api/tests/unit/organisations/test_unit_organisations_serializers.py +++ b/api/tests/unit/organisations/test_unit_organisations_serializers.py @@ -1,8 +1,15 @@ +import socket +from unittest import mock + +import pytest from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture from organisations.models import Organisation -from organisations.serializers import UpdateSubscriptionSerializer +from organisations.serializers import ( + OrganisationWebhookSerializer, + UpdateSubscriptionSerializer, +) def test_update_subscription_serializer__create__updates_subscription( @@ -39,3 +46,62 @@ def test_update_subscription_serializer__create__updates_subscription( organisation.subscription.refresh_from_db() assert organisation.subscription.subscription_id == "new-sub-id" assert organisation.subscription.plan == "startup-v2" + + +def test_organisation_webhook_serializer__private_ip__is_invalid() -> None: + # Given + serializer = OrganisationWebhookSerializer(data={"url": "http://127.0.0.1/hook"}) + + # When + is_valid = serializer.is_valid() + + # Then + assert is_valid is False + assert "internal_address" in str(serializer.errors["url"]) + + +def test_organisation_webhook_serializer__hostname_resolving_to_private_ip__is_invalid() -> ( # noqa: E501 + None +): + # Given — a hostname that resolves to an RFC1918 address + serializer = OrganisationWebhookSerializer( + data={"url": "http://internal.example.com/hook"} + ) + + # When + with mock.patch( + "core.network.socket.getaddrinfo", + return_value=[(socket.AF_INET, None, None, None, ("10.0.0.5", 0))], + ): + is_valid = serializer.is_valid() + + # Then + assert is_valid is False + assert "internal_address" in str(serializer.errors["url"]) + + +def test_organisation_webhook_serializer__non_http_scheme__is_invalid() -> None: + # Given + serializer = OrganisationWebhookSerializer(data={"url": "ftp://example.com/hook"}) + + # When + is_valid = serializer.is_valid() + + # Then + assert is_valid is False + assert "url" in serializer.errors + + +@pytest.mark.parametrize( + "url", + ["https://example.com/hook", "http://8.8.8.8/hook"], +) +def test_organisation_webhook_serializer__public_url__is_valid(url: str) -> None: + # Given + serializer = OrganisationWebhookSerializer(data={"url": url}) + + # When + is_valid = serializer.is_valid() + + # Then + assert is_valid is True diff --git a/api/tests/unit/webhooks/test_webhooks_fields.py b/api/tests/unit/webhooks/test_webhooks_fields.py index 5706a8337279..256bb92afd02 100644 --- a/api/tests/unit/webhooks/test_webhooks_fields.py +++ b/api/tests/unit/webhooks/test_webhooks_fields.py @@ -116,3 +116,24 @@ def test_no_ssrf_url_field__unresolvable_hostname__returns_value( # Then assert result == "https://unresolvable.example.com/hook" + + +@pytest.mark.parametrize( + "url,label", + [ + ("ftp://example.com/hook", "ftp"), + ("ftps://example.com/hook", "ftps"), + ("file:///etc/passwd", "file"), + ("gopher://example.com/hook", "gopher"), + ], +) +def test_no_ssrf_url_field__non_http_scheme__raises_validation_error( # noqa: FT004 + field: NoSSRFURLField, + url: str, + label: str, +) -> None: + # Given / When / Then + with pytest.raises(ValidationError) as exc_info: + field.run_validation(url) + + assert "invalid" in str(exc_info.value.detail) diff --git a/api/webhooks/fields.py b/api/webhooks/fields.py index 13ffe8fa8b9b..be17d9a5791e 100644 --- a/api/webhooks/fields.py +++ b/api/webhooks/fields.py @@ -1,5 +1,7 @@ +from typing import Any from urllib.parse import urlparse +from django.core.validators import URLValidator from rest_framework import serializers from core.network import is_internal_address @@ -7,13 +9,16 @@ class NoSSRFURLField(serializers.URLField): """ - A URL field that rejects URLs resolving to internal network addresses, - preventing Server-Side Request Forgery (SSRF) attacks. - - Blocks loopback (127.0.0.0/8, ::1), RFC 1918 private ranges - (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local - (169.254.0.0/16, fe80::/10), and other reserved/multicast ranges. - Hostnames are resolved to their IP address before checking. + A URL field that only allows http(s) URLs and rejects URLs resolving to + internal network addresses, preventing Server-Side Request Forgery + (SSRF) attacks. + + Restricts the scheme to http/https, and blocks loopback (127.0.0.0/8, + ::1), RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, + 192.168.0.0/16), link-local (169.254.0.0/16, fe80::/10), and other + reserved/multicast ranges. Hostnames are resolved to their IP address + before checking, so DNS names that resolve to an internal address are + rejected too. """ default_error_messages = { @@ -23,6 +28,22 @@ class NoSSRFURLField(serializers.URLField): ), } + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + # The base URLField's validator allows ftp/ftps; replace it so only + # http/https URLs are accepted. + self.validators = [ + validator + for validator in self.validators + if not isinstance(validator, URLValidator) + ] + self.validators.append( + URLValidator( + schemes=["http", "https"], + message=self.error_messages["invalid"], + ) + ) + def run_validators(self, value: str) -> None: super().run_validators(value) From 6735c7c812449f4dfbdb68c692576d51c3fbd176 Mon Sep 17 00:00:00 2001 From: Deep Santoshwar Date: Wed, 5 Aug 2026 18:09:52 +0530 Subject: [PATCH 2/6] fix(webhooks): restore max_length on SSRF URL fields, generalize error message Explicit NoSSRFURLField() declarations dropped the model's max_length=200 constraint. Also update the internal-address error message since the field is now shared by non-webhook integrations (Sentry, GitLab). --- api/integrations/gitlab/serializers.py | 2 +- api/integrations/sentry/serializers.py | 2 +- api/integrations/webhook/serializers.py | 2 +- api/webhooks/fields.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index 7d891cdd18aa..38151f0544dc 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -10,7 +10,7 @@ class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer): - gitlab_instance_url = NoSSRFURLField() + gitlab_instance_url = NoSSRFURLField(max_length=200) class Meta: model = GitLabConfiguration diff --git a/api/integrations/sentry/serializers.py b/api/integrations/sentry/serializers.py index 38ef3dd98ac7..f1b542f01e5f 100644 --- a/api/integrations/sentry/serializers.py +++ b/api/integrations/sentry/serializers.py @@ -7,7 +7,7 @@ class SentryChangeTrackingConfigurationSerializer( BaseEnvironmentIntegrationModelSerializer ): - webhook_url = NoSSRFURLField() + webhook_url = NoSSRFURLField(max_length=200) class Meta: model = SentryChangeTrackingConfiguration diff --git a/api/integrations/webhook/serializers.py b/api/integrations/webhook/serializers.py index f790cc8fc01d..d42719bd3a8c 100644 --- a/api/integrations/webhook/serializers.py +++ b/api/integrations/webhook/serializers.py @@ -16,7 +16,7 @@ class WebhookConfigurationSerializer(BaseEnvironmentIntegrationModelSerializer): - url = NoSSRFURLField() + url = NoSSRFURLField(max_length=200) class Meta: model = WebhookConfiguration diff --git a/api/webhooks/fields.py b/api/webhooks/fields.py index be17d9a5791e..59fa3d40bc3f 100644 --- a/api/webhooks/fields.py +++ b/api/webhooks/fields.py @@ -24,7 +24,7 @@ class NoSSRFURLField(serializers.URLField): default_error_messages = { **serializers.URLField.default_error_messages, "internal_address": ( - "Webhook URLs must not target internal or private network addresses." + "URLs must not target internal or private network addresses." ), } From d5b3b4213f0f689eb09229e1eeb05432c98bb48c Mon Sep 17 00:00:00 2001 From: Deep Santoshwar Date: Thu, 6 Aug 2026 20:41:02 +0530 Subject: [PATCH 3/6] fix(webhooks): register NoSSRFURLField via serializer_field_mapping Generalizes SSRF protection to every model URLField automatically instead of declaring NoSSRFURLField on each serializer by hand, so future URL fields aren't left unprotected by omission. --- api/integrations/common/serializers.py | 14 -------------- api/integrations/gitlab/serializers.py | 3 --- api/integrations/sentry/serializers.py | 3 --- api/tests/unit/webhooks/test_webhooks_fields.py | 11 +++++++++++ api/webhooks/apps.py | 11 +++++++++++ 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index f11621d75f88..ee6a1b0fb135 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -3,24 +3,10 @@ from django.db.models import Model from rest_framework.serializers import ModelSerializer -from webhooks.fields import NoSSRFURLField - class _BaseIntegrationModelSerializer(ModelSerializer): # type: ignore[type-arg] one_to_one_field_name = None - def build_standard_field(self, field_name, model_field): # type: ignore[no-untyped-def] # noqa: E501 - # Every integration model's `base_url` is a plain `URLField`, which - # only checks URL format. Swap in `NoSSRFURLField` (same field_kwargs - # derived from the model, e.g. required/allow_null/max_length) to - # also block SSRF against internal/private network addresses. - field_class, field_kwargs = super().build_standard_field( - field_name, model_field - ) - if field_name == "base_url": - field_class = NoSSRFURLField - return field_class, field_kwargs - def create(self, validated_data): # type: ignore[no-untyped-def] if existing_obj := self._get_existing_integration_model_obj(validated_data): existing_obj.deleted_at = None # type: ignore[attr-defined] diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index 38151f0544dc..0c0297d29fb0 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -4,14 +4,11 @@ from integrations.common.serializers import BaseProjectIntegrationModelSerializer from integrations.gitlab.models import GitLabConfiguration -from webhooks.fields import NoSSRFURLField WRITE_ONLY_PLACEHOLDER = "write-only" class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer): - gitlab_instance_url = NoSSRFURLField(max_length=200) - class Meta: model = GitLabConfiguration fields = ("id", "gitlab_instance_url", "access_token", "labeling_enabled") diff --git a/api/integrations/sentry/serializers.py b/api/integrations/sentry/serializers.py index f1b542f01e5f..52f23e6ffec5 100644 --- a/api/integrations/sentry/serializers.py +++ b/api/integrations/sentry/serializers.py @@ -1,5 +1,4 @@ from integrations.common.serializers import BaseEnvironmentIntegrationModelSerializer -from webhooks.fields import NoSSRFURLField from .models import SentryChangeTrackingConfiguration @@ -7,8 +6,6 @@ class SentryChangeTrackingConfigurationSerializer( BaseEnvironmentIntegrationModelSerializer ): - webhook_url = NoSSRFURLField(max_length=200) - class Meta: model = SentryChangeTrackingConfiguration fields = ["id", "environment", "webhook_url", "secret"] diff --git a/api/tests/unit/webhooks/test_webhooks_fields.py b/api/tests/unit/webhooks/test_webhooks_fields.py index 256bb92afd02..f470f6570bc7 100644 --- a/api/tests/unit/webhooks/test_webhooks_fields.py +++ b/api/tests/unit/webhooks/test_webhooks_fields.py @@ -2,11 +2,22 @@ from unittest import mock import pytest +from django.db import models from rest_framework.exceptions import ValidationError +from rest_framework.serializers import ModelSerializer from webhooks.fields import NoSSRFURLField +def test_serializer_field_mapping__model_url_field__maps_to_no_ssrf_url_field() -> None: + # Given — registered in `WebhooksAppConfig.ready()`, so any + # `ModelSerializer` built from a `models.URLField` gets this field + # automatically, without declaring it on each serializer. + + # When / Then + assert ModelSerializer.serializer_field_mapping[models.URLField] is NoSSRFURLField + + @pytest.fixture() def field() -> NoSSRFURLField: return NoSSRFURLField() diff --git a/api/webhooks/apps.py b/api/webhooks/apps.py index 3a356b5e2f5c..7c40a72d6e55 100644 --- a/api/webhooks/apps.py +++ b/api/webhooks/apps.py @@ -3,3 +3,14 @@ class WebhooksAppConfig(AppConfig): name = "webhooks" + + def ready(self) -> None: + from django.db import models + from rest_framework.serializers import ModelSerializer + + from webhooks.fields import NoSSRFURLField + + # Any `ModelSerializer` field built from a `models.URLField` gets the + # SSRF-safe field instead, for every current and future serializer, + # without each one having to opt in. + ModelSerializer.serializer_field_mapping[models.URLField] = NoSSRFURLField From 9c3e501df1244b3a9f2ccd2f4054e26bc8a6a6c3 Mon Sep 17 00:00:00 2001 From: Deep Santoshwar Date: Thu, 6 Aug 2026 21:01:24 +0530 Subject: [PATCH 4/6] fix(webhooks): exempt FeatureExternalResource.url from SSRF mapping CodeRabbit flagged that the global serializer_field_mapping override reaches this URLField too, even though it's never fetched server-side (only regex-matched and sent as GitHub API payload data), so blocking private-looking hosts here would break self-hosted GitHub/GitLab Enterprise Server users for no security benefit. Also strengthens the mapping test to check the actual serializer-built field, not just the registry entry. --- .../feature_external_resources/serializers.py | 4 ++++ api/tests/unit/webhooks/test_webhooks_fields.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/api/features/feature_external_resources/serializers.py b/api/features/feature_external_resources/serializers.py index 610c5c3a5835..105af136724e 100644 --- a/api/features/feature_external_resources/serializers.py +++ b/api/features/feature_external_resources/serializers.py @@ -7,6 +7,10 @@ class FeatureExternalResourceSerializer(serializers.ModelSerializer): # type: ignore[type-arg] metadata = serializers.JSONField(required=False, allow_null=True, default=None) + # Not SSRF-relevant: never fetched server-side, only regex-matched and + # passed as GitHub API payload data. Overrides the SSRF-safe default so + # self-hosted GitHub/GitLab instances on internal hosts still work. + url = serializers.URLField() class Meta: model = FeatureExternalResource diff --git a/api/tests/unit/webhooks/test_webhooks_fields.py b/api/tests/unit/webhooks/test_webhooks_fields.py index f470f6570bc7..b9dd29cab516 100644 --- a/api/tests/unit/webhooks/test_webhooks_fields.py +++ b/api/tests/unit/webhooks/test_webhooks_fields.py @@ -18,6 +18,22 @@ def test_serializer_field_mapping__model_url_field__maps_to_no_ssrf_url_field() assert ModelSerializer.serializer_field_mapping[models.URLField] is NoSSRFURLField +def test_serializer_field_mapping__gitlab_instance_url__builds_no_ssrf_url_field() -> ( + None +): + # Given — `GitLabConfigurationSerializer` relies on the mapping instead + # of declaring `gitlab_instance_url` explicitly. + from integrations.gitlab.serializers import GitLabConfigurationSerializer + + # When + built_field = GitLabConfigurationSerializer().fields["gitlab_instance_url"] + + # Then + assert type(built_field) is NoSSRFURLField + with pytest.raises(ValidationError): + built_field.run_validation("http://127.0.0.1/") + + @pytest.fixture() def field() -> NoSSRFURLField: return NoSSRFURLField() From 971acb7c929c1fbfeb92386f961ed7f69ce23453 Mon Sep 17 00:00:00 2001 From: Deep Santoshwar Date: Thu, 6 Aug 2026 21:09:48 +0530 Subject: [PATCH 5/6] chore: retrigger CI after transient GitHub Actions infra failure From d7df92ab03cd9ab0b525dd575769e840ac8e13b7 Mon Sep 17 00:00:00 2001 From: Deep Santoshwar Date: Thu, 6 Aug 2026 21:21:40 +0530 Subject: [PATCH 6/6] chore: retrigger CI again after GitHub Actions outage