Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref(toolbar): check allowed origins in iframe view #77756

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added src/sentry/toolbar/__init__.py
Empty file.
36 changes: 0 additions & 36 deletions src/sentry/toolbar/iframe_view.py

This file was deleted.

49 changes: 49 additions & 0 deletions src/sentry/toolbar/utils/url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from urllib.parse import urlparse

from django.http import HttpRequest

REFERRER_HEADER = "HTTP_REFERER" # 1 R is the spelling used here: https://docs.djangoproject.com/en/5.1/ref/request-response/


def url_matches(referrer_url: str, target_url: str) -> bool:
"""
Matches a referrer url with a user-provided one. Checks 3 fields:
* hostname: must equal target.hostname. The first subdomain in target may be a wildcard "*".
* port: must equal target.port, unless it is excluded from target.
* scheme: must equal target.scheme, unless it is excluded from target.
Note both url's path is ignored.

@param referrer_url: Must have a valid scheme and hostname.
@param target_url: May exclude scheme. THe first subdomain may be a wildcard "*".
"""
referrer = urlparse(referrer_url) # Always has scheme and hostname
target = urlparse(target_url)
if not target.scheme: # urlparse doesn't work well if scheme is missing
target = urlparse(referrer.scheme + "://" + target_url)

ref_hostname, target_hostname = referrer.hostname, target.hostname
if target_hostname.startswith("*."):
ref_hostname = ref_hostname.split(".", 1)[1]
target_hostname = target_hostname.split(".", 1)[1]

return all(
[
ref_hostname == target_hostname,
not target.port or referrer.port == target.port,
referrer.scheme == target.scheme,
]
)


def check_origin(request: HttpRequest, allowed_origins: list[str]) -> tuple[bool, str]:
referrer: str | None = request.META.get(REFERRER_HEADER)
if referrer:
if not urlparse(referrer).scheme:
referrer = "http://" + referrer

for origin in allowed_origins:
if url_matches(referrer, origin):
return True, f"Matched allowed origin: {origin}"
return False, f"Referrer {referrer} does not match allowed origins."

return False, f"Could not validate origin, missing {REFERRER_HEADER} header."
Empty file.
57 changes: 57 additions & 0 deletions src/sentry/toolbar/views/iframe_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from html import escape
from typing import Any

from django.http import HttpRequest, HttpResponse

from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.toolbar.utils.url import check_origin
from sentry.web.frontend.base import OrganizationView, region_silo_view


@region_silo_view
class IframeView(OrganizationView):
def respond(self, template: str, context: dict[str, Any] | None = None, status: int = 200):
response = super().respond(template, context=context, status=status)
response["X-Frame-Options"] = "ALLOWALL" # allows response to be embedded in an iframe.
return response

def handle_auth_required(self, request: HttpRequest, *args, **kwargs):
# Override redirects to /auth/login
return HttpResponse(status=401)

def handle_permission_required(self, request: HttpRequest, *args, **kwargs):
# Override redirects to /auth/login
return HttpResponse(status=403)

def convert_args(self, request: HttpRequest, organization_slug: str, project_id_or_slug: int | str, *args: Any, **kwargs: Any) -> tuple[tuple[Any, ...], dict[str, Any]]: # type: ignore[override]
args, kwargs = super().convert_args(request, organization_slug, *args, **kwargs)
organization: Organization | None = kwargs["organization"]
active_project: Project | None = (
self.get_active_project(
request=request,
organization=organization, # type: ignore[arg-type]
project_id_or_slug=project_id_or_slug,
)
if organization
else None
)
kwargs["project"] = active_project
return args, kwargs

def get(
self, request: HttpRequest, organization: Organization, project: Project, *args, **kwargs
):
if not project:
return HttpResponse(
"Project does not exist.", status=404
) # TODO: replace with 200 response and template var for "project doesn't exist"

allowed_origins: list[str] = project.get_option("sentry:toolbar_allowed_origins")
origin_allowed, info_msg = check_origin(request, allowed_origins)
if not origin_allowed:
return HttpResponse(
escape(info_msg), status=403
) # TODO: replace with 200 response and template var for "project not configured"

return self.respond("sentry/toolbar/iframe.html", status=200)
4 changes: 2 additions & 2 deletions src/sentry/web/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from sentry.charts.endpoints import serve_chartcuterie_config
from sentry.integrations.web.doc_integration_avatar import DocIntegrationAvatarPhotoView
from sentry.integrations.web.organization_integration_setup import OrganizationIntegrationSetupView
from sentry.toolbar.iframe_view import IframeView
from sentry.toolbar.login_success_view import LoginSuccessView
from sentry.toolbar.views.iframe_view import IframeView
from sentry.toolbar.views.login_success_view import LoginSuccessView
from sentry.users.web import accounts
from sentry.users.web.account_identity import AccountIdentityAssociateView
from sentry.users.web.user_avatar import UserAvatarPhotoView
Expand Down
29 changes: 29 additions & 0 deletions tests/sentry/toolbar/utils/test_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from sentry.toolbar.utils.url import url_matches


def test_url_matches_basic():
assert url_matches("http://abc.net", "http://abc.net")
assert not url_matches("http://pocketpair.io", "http://sentry.io")
assert url_matches("http://sentry.io/issues/", "http://sentry.io")
assert url_matches("http://sentry.io", "http://sentry.io/issues/")
assert not url_matches("https://cmu.edu", "https://cmu.com")
assert url_matches("https://youtube.com:443/watch?v=3xhb", "https://youtube.com/profile")


def test_url_matches_wildcard():
assert url_matches("https://nugettrends.sentry.io", "https://*.sentry.io")
assert not url_matches("https://nugettrends.sentry.com", "https://*.sentry.io")
assert not url_matches("https://nugettrends.sentry.io", "https://*.io")
assert not url_matches("https://sentry.io", "https://*.sentry.io")


def test_url_matches_port():
assert url_matches("https://sentry.io:42", "https://sentry.io:42")
assert not url_matches("https://sentry.io", "https://sentry.io:42")
assert url_matches("https://sentry.io:42", "https://sentry.io")


def test_url_matches_scheme():
assert url_matches("https://sentry.io", "sentry.io")
assert url_matches("http://sentry.io", "sentry.io")
assert not url_matches("http://sentry.io", "https://sentry.io")
65 changes: 65 additions & 0 deletions tests/sentry/toolbar/views/test_iframe_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from unittest.mock import Mock, patch

from django.urls import reverse

from sentry.testutils.cases import APITestCase
from sentry.toolbar.utils.url import REFERRER_HEADER

# TODO: instead of status codes, test the response content and/or template variable passed to iframe.html


class IframeViewTest(APITestCase):
view_name = "sentry-toolbar-iframe"

def setUp(self):
super().setUp()
self.login_as(user=self.user)
self.url = reverse(self.view_name, args=(self.organization.slug, self.project.slug))

def test_missing_project(self):
url = reverse(self.view_name, args=(self.organization.slug, "abc123xyz"))
res = self.client.get(url)
assert res.status_code == 404

def test_default_no_allowed_origins(self):
res = self.client.get(self.url, **{REFERRER_HEADER: "https://example.com"})
assert res.status_code == 403

def test_allowed_origins_basic(self):
self.project.update_option("sentry:toolbar_allowed_origins", ["sentry.io"])
for referrer in ["https://sentry.io/issues/?a=b/", "https://sentry.io:127/replays/"]:
res = self.client.get(self.url, **{REFERRER_HEADER: referrer})
assert res.status_code == 200

def test_allowed_origins_wildcard_subdomain(self):
self.project.update_option("sentry:toolbar_allowed_origins", ["*.nugettrends.com"])
res = self.client.get(self.url, **{REFERRER_HEADER: "https://bruno.nugettrends.com"})
assert res.status_code == 200
res = self.client.get(self.url, **{REFERRER_HEADER: "https://andrew.ryan.nugettrends.com"})
assert res.status_code == 403

def test_no_referrer(self):
self.project.update_option("sentry:toolbar_allowed_origins", ["sentry.io"])
res = self.client.get(self.url)
assert res.status_code == 403

def test_calls_url_matches(self):
"""
The `url_matches` helper fx has more in-depth unit test coverage.
"""
mock_url_matches = Mock(return_value=False)
allowed_origins = ["sentry.io", "abc.com"]
referrer = "https://example.com"
self.project.update_option("sentry:toolbar_allowed_origins", allowed_origins)
with patch("sentry.toolbar.utils.url.url_matches", mock_url_matches):
self.client.get(self.url, **{REFERRER_HEADER: referrer})

assert mock_url_matches.call_count == 2
for (i, (args, _)) in enumerate(mock_url_matches.call_args_list):
assert args[0] == referrer
assert args[1] == allowed_origins[i]

def test_x_frame_options(self):
self.project.update_option("sentry:toolbar_allowed_origins", ["https://sentry.io"])
res = self.client.get(self.url, **{REFERRER_HEADER: "https://sentry.io"})
assert res.headers.get("X-Frame-Options") == "ALLOWALL"
Loading