diff --git a/src/sentry/api/endpoints/api_application_details.py b/src/sentry/api/endpoints/api_application_details.py index 8b83edaff192af..52d7a79be2ad24 100644 --- a/src/sentry/api/endpoints/api_application_details.py +++ b/src/sentry/api/endpoints/api_application_details.py @@ -1,3 +1,5 @@ +from urllib.parse import urlparse + from django.db import router, transaction from rest_framework import serializers from rest_framework.authentication import SessionAuthentication @@ -14,9 +16,37 @@ from sentry.models.apiapplication import ApiApplication, ApiApplicationStatus +class CustomSchemeURLField(serializers.CharField): + """URLField that accepts any valid scheme, not just HTTP/HTTPS.""" + + default_error_messages = { + "invalid": "Enter a valid URL.", + } + + def run_validation(self, data): + # First run the standard CharField validations + data = super().run_validation(data) + + if data is None: + return data + + if data == "": + self.fail("invalid") + + # Basic URL structure validation + try: + parsed = urlparse(data) + if not parsed.scheme or not parsed.netloc: + self.fail("invalid") + except Exception: + self.fail("invalid") + + return data + + class ApiApplicationSerializer(serializers.Serializer): name = serializers.CharField(max_length=64) - redirectUris = ListField(child=serializers.URLField(max_length=255), required=False) + redirectUris = ListField(child=CustomSchemeURLField(max_length=255), required=False) allowedOrigins = ListField( # TODO(dcramer): make this validate origins child=serializers.CharField(max_length=255), diff --git a/tests/sentry/api/endpoints/test_api_application_details.py b/tests/sentry/api/endpoints/test_api_application_details.py index 12227be9aabd9d..a549e3b700f565 100644 --- a/tests/sentry/api/endpoints/test_api_application_details.py +++ b/tests/sentry/api/endpoints/test_api_application_details.py @@ -32,6 +32,51 @@ def test_simple(self) -> None: app = ApiApplication.objects.get(id=app.id) assert app.name == "foobaz" + def test_redirect_uris_with_custom_schemes(self) -> None: + app = ApiApplication.objects.create(owner=self.user, name="a") + + self.login_as(self.user) + url = reverse("sentry-api-0-api-application-details", args=[app.client_id]) + + # Test various custom schemes + custom_uris = [ + "myapp://callback", + "custom-scheme://auth/callback", + "app123://redirect", + "http://example.com/callback", # Standard HTTP still works + "https://example.com/callback", # Standard HTTPS still works + ] + + response = self.client.put(url, data={"redirectUris": custom_uris}) + assert response.status_code == 200, (response.status_code, response.content) + + app = ApiApplication.objects.get(id=app.id) + saved_uris = app.get_redirect_uris() + assert len(saved_uris) == 5 + assert "myapp://callback" in saved_uris + assert "custom-scheme://auth/callback" in saved_uris + assert "app123://redirect" in saved_uris + assert "http://example.com/callback" in saved_uris + assert "https://example.com/callback" in saved_uris + + def test_invalid_redirect_uris_rejected(self) -> None: + app = ApiApplication.objects.create(owner=self.user, name="a") + + self.login_as(self.user) + url = reverse("sentry-api-0-api-application-details", args=[app.client_id]) + + # Test invalid URIs + invalid_uris = [ + "not-a-url", # No scheme or netloc + "://missing-scheme.com", # No scheme + "scheme-only://", # No netloc + "", # Empty string should be rejected + ] + + response = self.client.put(url, data={"redirectUris": invalid_uris}) + assert response.status_code == 400, (response.status_code, response.content) + assert "redirectUris" in response.data + @control_silo_test class ApiApplicationDeleteTest(APITestCase):