Skip to content

Commit e39bc28

Browse files
Yashika KhuranaYashika Khurana
authored andcommitted
feat(nimbus): simplify GrafanaProxyView to accept only slug, construct URL from settings
Addresses security review: removes path parameter injection risk by validating slug against DB and building the Grafana URL entirely from settings constants.
1 parent 060a61d commit e39bc28

5 files changed

Lines changed: 53 additions & 111 deletions

File tree

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ <h5 class="fw-semibold mb-0 d-flex align-items-center">
545545
</div>
546546
<div class="card shadow-sm border-0 rounded-3 mb-3">
547547
<div class="card-body p-0">
548-
<iframe src="/nimbus/grafana-proxy/?slug={{ selected_feature_config.slug }}&kiosk"
548+
<iframe src="/nimbus/grafana-proxy/?slug={{ selected_feature_config.slug }}"
549549
width="100%"
550550
height="800"
551551
frameborder="0"

experimenter/experimenter/nimbus_ui/tests/test_views.py

Lines changed: 9 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5842,69 +5842,27 @@ def test_post_clears_all_tags_when_none_selected(self):
58425842

58435843
class TestGrafanaProxyView(AuthTestCase):
58445844
def _dashboard_url(self, slug):
5845-
return reverse("nimbus-ui-grafana-proxy", kwargs={"path": ""}) + f"?slug={slug}"
5845+
return reverse("nimbus-ui-grafana-proxy") + f"?slug={slug}"
58465846

58475847
@patch("experimenter.nimbus_ui.views.requests.get")
5848-
def test_proxy_returns_grafana_html(self, mock_get):
5848+
def test_proxy_returns_grafana_response(self, mock_get):
58495849
feature = NimbusFeatureConfigFactory.create(
58505850
slug="my-feature",
58515851
application=NimbusExperiment.Application.DESKTOP,
58525852
)
58535853
mock_get.return_value.status_code = 200
58545854
mock_get.return_value.headers = {"Content-Type": "text/html; charset=utf-8"}
5855-
mock_get.return_value.text = (
5856-
'<html><head><base href="/"></head><body></body></html>'
5857-
)
5855+
mock_get.return_value.content = b"<html></html>"
58585856

58595857
response = self.client.get(self._dashboard_url(feature.slug))
58605858

58615859
self.assertEqual(response.status_code, 200)
58625860
mock_get.assert_called_once()
58635861
call_url = mock_get.call_args[0][0]
58645862
self.assertIn("nimbus-feature-monitoring", call_url)
5865-
5866-
@patch("experimenter.nimbus_ui.views.requests.get")
5867-
def test_proxy_rewrites_base_href(self, mock_get):
5868-
feature = NimbusFeatureConfigFactory.create(
5869-
slug="my-feature",
5870-
application=NimbusExperiment.Application.DESKTOP,
5871-
)
5872-
mock_get.return_value.status_code = 200
5873-
mock_get.return_value.headers = {"Content-Type": "text/html"}
5874-
mock_get.return_value.text = '<base href="/">'
5875-
5876-
response = self.client.get(self._dashboard_url(feature.slug))
5877-
5878-
self.assertIn(b"/nimbus/grafana-proxy/", response.content)
5879-
self.assertNotIn(b'base href="/"', response.content)
5880-
5881-
@patch("experimenter.nimbus_ui.views.requests.get")
5882-
def test_proxy_rewrites_app_url_in_boot_data(self, mock_get):
5883-
feature = NimbusFeatureConfigFactory.create(
5884-
slug="my-feature",
5885-
application=NimbusExperiment.Application.DESKTOP,
5886-
)
5887-
mock_get.return_value.status_code = 200
5888-
mock_get.return_value.headers = {"Content-Type": "text/html"}
5889-
mock_get.return_value.text = '"appUrl":"https://yardstick.mozilla.org/"'
5890-
5891-
response = self.client.get(self._dashboard_url(feature.slug))
5892-
5893-
self.assertIn(b"/nimbus/grafana-proxy/", response.content)
5894-
self.assertNotIn(b"yardstick.mozilla.org", response.content)
5895-
5896-
@patch("experimenter.nimbus_ui.views.requests.get")
5897-
def test_proxy_returns_binary_content_unchanged(self, mock_get):
5898-
mock_get.return_value.status_code = 200
5899-
mock_get.return_value.headers = {"Content-Type": "application/javascript"}
5900-
mock_get.return_value.content = b"console.log('hello');"
5901-
5902-
response = self.client.get(
5903-
reverse("nimbus-ui-grafana-proxy", kwargs={"path": "public/build/app.js"}),
5904-
)
5905-
5906-
self.assertEqual(response.status_code, 200)
5907-
self.assertEqual(response.content, b"console.log('hello');")
5863+
call_params = mock_get.call_args[1]["params"]
5864+
self.assertEqual(call_params["var-feature"], "my-feature")
5865+
self.assertEqual(call_params["var-application"], "firefox_desktop")
59085866

59095867
@patch(
59105868
"experimenter.nimbus_ui.views.requests.get",
@@ -5921,9 +5879,7 @@ def test_proxy_returns_503_when_grafana_unavailable(self, mock_get):
59215879

59225880
def test_proxy_requires_login(self):
59235881
self.client.defaults.pop(settings.OPENIDC_EMAIL_HEADER)
5924-
response = self.client.get(
5925-
reverse("nimbus-ui-grafana-proxy", kwargs={"path": "d/abc/my-dashboard"}),
5926-
)
5882+
response = self.client.get(reverse("nimbus-ui-grafana-proxy"))
59275883
self.assertNotEqual(response.status_code, 200)
59285884

59295885
@override_settings(GRAFANA_SERVICE_ACCOUNT_TOKEN="test-token")
@@ -5935,17 +5891,15 @@ def test_proxy_sends_service_account_token(self, mock_get):
59355891
)
59365892
mock_get.return_value.status_code = 200
59375893
mock_get.return_value.headers = {"Content-Type": "text/html"}
5938-
mock_get.return_value.text = ""
5894+
mock_get.return_value.content = b""
59395895

59405896
self.client.get(self._dashboard_url(feature.slug))
59415897

59425898
call_headers = mock_get.call_args[1]["headers"]
59435899
self.assertEqual(call_headers["Authorization"], "Bearer test-token")
59445900

59455901
def test_proxy_returns_400_when_slug_missing(self):
5946-
response = self.client.get(
5947-
reverse("nimbus-ui-grafana-proxy", kwargs={"path": ""}),
5948-
)
5902+
response = self.client.get(reverse("nimbus-ui-grafana-proxy"))
59495903
self.assertEqual(response.status_code, 400)
59505904

59515905
def test_proxy_returns_404_when_slug_invalid(self):

experimenter/experimenter/nimbus_ui/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
DraftToReviewView,
2424
EditOutcomeSummaryView,
2525
FeatureSubscribersUpdateView,
26+
GrafanaProxyView,
2627
LiveToCompleteView,
2728
LiveToEndEnrollmentView,
2829
LiveToUpdateRolloutView,
@@ -79,6 +80,11 @@
7980
NimbusFeaturesView.as_view(),
8081
name="nimbus-ui-features",
8182
),
83+
re_path(
84+
r"^grafana-proxy/$",
85+
GrafanaProxyView.as_view(),
86+
name="nimbus-ui-grafana-proxy",
87+
),
8288
re_path(
8389
r"^tags/manage/$",
8490
TagsManageView.as_view(),

experimenter/experimenter/nimbus_ui/views.py

Lines changed: 33 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import json
2-
import re
3-
from urllib.parse import parse_qs, urlparse
2+
from django.shortcuts import get_object_or_404
43

54
import requests
65
from deepdiff import DeepDiff
@@ -1171,35 +1170,35 @@ def get_context_data(self, **kwargs):
11711170
# Grafana is behind Google IAP, which blocks third-party cookies in iframes.
11721171
# Both Experimenter and Grafana run in the same k8s cluster, so the backend
11731172
# can reach Grafana via internal DNS (bypassing IAP) and proxy it to the browser.
1174-
_GRAFANA_PROXY_PREFIX = "/nimbus/grafana-proxy/"
11751173
_SAFE_RESPONSE_HEADERS = ("Cache-Control", "ETag", "Last-Modified", "Content-Type")
11761174

11771175

11781176
class GrafanaProxyView(LoginRequiredMixin, View):
1179-
def get(self, request, path=""):
1180-
if not path:
1181-
# Dashboard request: validate slug against the DB and construct the
1182-
# Grafana URL server-side so user-supplied input never reaches the
1183-
# upstream target directly.
1184-
slug = request.GET.get("slug")
1185-
if not slug:
1186-
return HttpResponse("Missing slug parameter", status=400)
1187-
try:
1188-
feature_config = NimbusFeatureConfig.objects.get(slug=slug)
1189-
except NimbusFeatureConfig.DoesNotExist:
1190-
return HttpResponse("Feature not found", status=404)
1191-
parsed = urlparse(feature_config.feature_monitoring_url)
1192-
path = parsed.path.lstrip("/")
1193-
# Merge the monitoring URL's own query params with any extra params
1194-
# from the request (e.g. kiosk), excluding the slug itself.
1195-
params = parse_qs(parsed.query, keep_blank_values=True)
1196-
for key, value in request.GET.items():
1197-
if key != "slug":
1198-
params[key] = [value]
1199-
else:
1200-
params = request.GET
1201-
1202-
target = f"{settings.GRAFANA_INTERNAL_URL.rstrip('/')}/{path}"
1177+
def get(self, request):
1178+
slug = request.GET.get("slug")
1179+
if not slug:
1180+
return HttpResponse("Missing slug parameter", status=400)
1181+
1182+
feature_config = get_object_or_404(NimbusFeatureConfig, slug=slug)
1183+
1184+
# Construct the Grafana URL entirely from known-safe components.
1185+
# User input (slug) is validated against the DB before use;
1186+
# the host and dashboard path come from settings only.
1187+
application = (feature_config.application or "").replace("-", "_")
1188+
target = "{base}/{path}".format(
1189+
base=settings.GRAFANA_INTERNAL_URL.rstrip("/"),
1190+
path=settings.GRAFANA_FEATURE_MONITORING_DASHBOARD_PATH,
1191+
)
1192+
params = {
1193+
"orgId": "1",
1194+
"from": "now-7d",
1195+
"to": "now",
1196+
"timezone": "utc",
1197+
"var-application": application,
1198+
"var-feature": feature_config.slug,
1199+
"kiosk": "",
1200+
}
1201+
12031202
headers = {}
12041203
if settings.GRAFANA_SERVICE_ACCOUNT_TOKEN:
12051204
headers["Authorization"] = (
@@ -1216,17 +1215,13 @@ def get(self, request, path=""):
12161215
except requests.exceptions.RequestException:
12171216
return HttpResponse("Grafana is unavailable", status=503)
12181217

1219-
content_type = upstream.headers.get("Content-Type", "application/octet-stream")
1220-
1221-
if "text/html" in content_type:
1222-
body = self._rewrite_html(upstream.text)
1223-
response = HttpResponse(
1224-
body, content_type=content_type, status=upstream.status_code
1225-
)
1226-
else:
1227-
response = HttpResponse(
1228-
upstream.content, content_type=content_type, status=upstream.status_code
1229-
)
1218+
response = HttpResponse(
1219+
upstream.content,
1220+
content_type=upstream.headers.get(
1221+
"Content-Type", "application/octet-stream"
1222+
),
1223+
status=upstream.status_code,
1224+
)
12301225

12311226
for header in _SAFE_RESPONSE_HEADERS:
12321227
if value := upstream.headers.get(header):
@@ -1235,23 +1230,6 @@ def get(self, request, path=""):
12351230
response["X-Frame-Options"] = "SAMEORIGIN"
12361231
return response
12371232

1238-
def _rewrite_html(self, html):
1239-
# Rewrite <base href> so relative asset paths (JS, CSS, fonts) resolve
1240-
# through our proxy instead of directly to Grafana's public URL.
1241-
html = re.sub(
1242-
r'(<base\s+href=")[^"]*(")',
1243-
rf"\g<1>{_GRAFANA_PROXY_PREFIX}\2",
1244-
html,
1245-
)
1246-
# Rewrite appUrl in window.grafanaBootData so Grafana's JS routes
1247-
# API calls (datasources, dashboards) through our proxy too.
1248-
html = re.sub(
1249-
r'("appUrl"\s*:\s*")[^"]*(")',
1250-
rf"\g<1>{_GRAFANA_PROXY_PREFIX}\2",
1251-
html,
1252-
)
1253-
return html
1254-
12551233

12561234
class NimbusExperimentsHomeView(FilterView):
12571235
template_name = "nimbus_experiments/home.html"

experimenter/experimenter/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,10 @@
444444
"GRAFANA_INTERNAL_URL",
445445
default="http://grafana.grafana-prod.svc.cluster.local:8080",
446446
)
447+
GRAFANA_FEATURE_MONITORING_DASHBOARD_PATH = config(
448+
"GRAFANA_FEATURE_MONITORING_DASHBOARD_PATH",
449+
default="d/dtfz7xv/nimbus-feature-monitoring",
450+
)
447451
GRAFANA_SERVICE_ACCOUNT_TOKEN = config("GRAFANA_SERVICE_ACCOUNT_TOKEN", default="")
448452

449453
# Statsd via Markus

0 commit comments

Comments
 (0)