Browse Source

feat(uptime): Enforce per-domain limits (#77857)

Need to fix the test, but this will make it so you can no longer create
more monitors once the per domain limit is reached
Evan Purkhiser 5 months ago
parent
commit
89de4097a6

+ 7 - 0
src/sentry/uptime/detectors/url_extraction.py

@@ -6,6 +6,7 @@ from urllib.parse import urlsplit
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import URLValidator, validate_ipv46_address
 from django.core.validators import URLValidator, validate_ipv46_address
 from tldextract import TLDExtract
 from tldextract import TLDExtract
+from tldextract.tldextract import ExtractResult
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     pass
     pass
@@ -51,3 +52,9 @@ def extract_base_url(url: str | None) -> str | None:
     extracted_url = extractor.extract_urllib(split_url)
     extracted_url = extractor.extract_urllib(split_url)
     fqdn = extracted_url.fqdn
     fqdn = extracted_url.fqdn
     return f"{split_url.scheme}://{fqdn}" if fqdn else None
     return f"{split_url.scheme}://{fqdn}" if fqdn else None
+
+
+def extract_domain_parts(url: str) -> ExtractResult:
+    # We enable private PSL domains so that hosting services that use
+    # subdomains are treated as suffixes for the purposes of monitoring.
+    return extractor.extract_str(url, include_psl_private_domains=True)

+ 28 - 1
src/sentry/uptime/endpoints/validators.py

@@ -8,13 +8,27 @@ from sentry import audit_log
 from sentry.api.fields import ActorField
 from sentry.api.fields import ActorField
 from sentry.api.serializers.rest_framework import CamelSnakeSerializer
 from sentry.api.serializers.rest_framework import CamelSnakeSerializer
 from sentry.auth.superuser import is_active_superuser
 from sentry.auth.superuser import is_active_superuser
-from sentry.uptime.models import ProjectUptimeSubscriptionMode
+from sentry.uptime.detectors.url_extraction import extract_domain_parts
+from sentry.uptime.models import ProjectUptimeSubscription, ProjectUptimeSubscriptionMode
 from sentry.uptime.subscriptions.subscriptions import (
 from sentry.uptime.subscriptions.subscriptions import (
     create_project_uptime_subscription,
     create_project_uptime_subscription,
     create_uptime_subscription,
     create_uptime_subscription,
 )
 )
 from sentry.utils.audit import create_audit_entry
 from sentry.utils.audit import create_audit_entry
 
 
+MAX_MONITORS_PER_DOMAIN = 100
+"""
+The bounding upper limit on how many ProjectUptimeSubscription's can exist for
+a single domain + suffix.
+
+This takes into accunt subdomains by including them in the count. For example,
+for the domain `sentry.io` both the hosts `subdomain-one.sentry.io` and
+`subdomain-2.sentry.io` will both count towards the limit
+
+Importantly domains like `vercel.dev` are considered TLDs as defined by the
+public suffix list (PSL). See `extract_domain_parts` fo more details
+"""
+
 
 
 @extend_schema_serializer()
 @extend_schema_serializer()
 class UptimeMonitorValidator(CamelSnakeSerializer):
 class UptimeMonitorValidator(CamelSnakeSerializer):
@@ -34,6 +48,19 @@ class UptimeMonitorValidator(CamelSnakeSerializer):
     )
     )
     mode = serializers.IntegerField(required=False)
     mode = serializers.IntegerField(required=False)
 
 
+    def validate_url(self, url):
+        url_parts = extract_domain_parts(url)
+        existing_count = ProjectUptimeSubscription.objects.filter(
+            uptime_subscription__url_domain=url_parts.domain,
+            uptime_subscription__url_domain_suffix=url_parts.suffix,
+        ).count()
+
+        if existing_count >= MAX_MONITORS_PER_DOMAIN:
+            raise serializers.ValidationError(
+                f"The domain *.{url_parts.domain}.{url_parts.suffix} has already been used in {MAX_MONITORS_PER_DOMAIN} uptime monitoring alerts, which is the limit. You cannot create any additional alerts for this domain."
+            )
+        return url
+
     def validate_mode(self, mode):
     def validate_mode(self, mode):
         if not is_active_superuser(self.context["request"]):
         if not is_active_superuser(self.context["request"]):
             raise serializers.ValidationError("Only superusers can modify `mode`")
             raise serializers.ValidationError("Only superusers can modify `mode`")

+ 2 - 4
src/sentry/uptime/subscriptions/subscriptions.py

@@ -3,7 +3,7 @@ from typing import Any
 
 
 from sentry.models.project import Project
 from sentry.models.project import Project
 from sentry.types.actor import Actor
 from sentry.types.actor import Actor
-from sentry.uptime.detectors.url_extraction import extractor
+from sentry.uptime.detectors.url_extraction import extract_domain_parts
 from sentry.uptime.models import (
 from sentry.uptime.models import (
     ProjectUptimeSubscription,
     ProjectUptimeSubscription,
     ProjectUptimeSubscriptionMode,
     ProjectUptimeSubscriptionMode,
@@ -32,9 +32,7 @@ def create_uptime_subscription(
     """
     """
     # We extract the domain and suffix of the url here. This is used to prevent there being too many checks to a single
     # We extract the domain and suffix of the url here. This is used to prevent there being too many checks to a single
     # domain.
     # domain.
-    # We enable private PSL domains so that hosting services that use subdomains are treated as suffixes for the
-    # purposes of monitoring.
-    result = extractor.extract_str(url, include_psl_private_domains=True)
+    result = extract_domain_parts(url)
     subscription, created = UptimeSubscription.objects.get_or_create(
     subscription, created = UptimeSubscription.objects.get_or_create(
         url=url,
         url=url,
         interval_seconds=interval_seconds,
         interval_seconds=interval_seconds,

+ 28 - 0
tests/sentry/uptime/endpoints/test_project_uptime_alert_details.py

@@ -1,3 +1,5 @@
+from unittest import mock
+
 import pytest
 import pytest
 from rest_framework.exceptions import ErrorDetail
 from rest_framework.exceptions import ErrorDetail
 
 
@@ -92,6 +94,32 @@ class ProjectUptimeAlertDetailsPutEndpointTest(ProjectUptimeAlertDetailsBaseEndp
         resp = self.get_error_response(self.organization.slug, self.project.slug, 3)
         resp = self.get_error_response(self.organization.slug, self.project.slug, 3)
         assert resp.status_code == 404
         assert resp.status_code == 404
 
 
+    @mock.patch("sentry.uptime.endpoints.validators.MAX_MONITORS_PER_DOMAIN", 1)
+    def test_domain_limit(self):
+        # First monitor is for test-one.example.com
+        self.create_project_uptime_subscription(
+            uptime_subscription=self.create_uptime_subscription(
+                url="test-one.example.com",
+                url_domain="example",
+                url_domain_suffix="com",
+            )
+        )
+
+        # Update second monitor to use the same domain. This will fail with a
+        # validation error
+        uptime_subscription = self.create_project_uptime_subscription()
+        resp = self.get_error_response(
+            self.organization.slug,
+            uptime_subscription.project.slug,
+            uptime_subscription.id,
+            status_code=400,
+            url="https://test-two.example.com",
+        )
+        assert (
+            resp.data["url"][0]
+            == "The domain *.example.com has already been used in 1 uptime monitoring alerts, which is the limit. You cannot create any additional alerts for this domain."
+        )
+
 
 
 class ProjectUptimeAlertDetailsDeleteEndpointTest(ProjectUptimeAlertDetailsBaseEndpointTest):
 class ProjectUptimeAlertDetailsDeleteEndpointTest(ProjectUptimeAlertDetailsBaseEndpointTest):
     method = "delete"
     method = "delete"