Skip to content

Add server-side redirect for /dashboard to learn.mit.edu#3356

Merged
ChristopherChudzicki merged 3 commits into
mainfrom
cc/server-side-dashboard-redirect
Mar 10, 2026
Merged

Add server-side redirect for /dashboard to learn.mit.edu#3356
ChristopherChudzicki merged 3 commits into
mainfrom
cc/server-side-dashboard-redirect

Conversation

@ChristopherChudzicki
Copy link
Copy Markdown
Contributor

@ChristopherChudzicki ChristopherChudzicki commented Mar 5, 2026

What are the relevant tickets?

Followup to #3233, helpful for https://github.com/mitodl/hq/issues/10400

Description (What does it do?)

Adds a server-side redirect at /dashboard for users with the redirect-to-learn-dashboard PostHog feature flag enabled.

Also forwards query params, which will likely be useful for

Previously, the redirect would only happen after being routed to legacy frontend, downloading bundle, checking feature flag, etc. Now the redirect happens on the Django server in almost all cases.

The client-side redirect in DashboardPage is kept as-is to handle in-SPA navigation (e.g. clicking the Dashboard link from the homepage,where React Router handles the transition without a full page load and
Django never sees the request). This shouldn't actually be relevant once we switch the flag on, but could be, if there's a stray link to the dashboard in the ecommerce cart or something.

Both the server-side and client-side redirects now forward query parameters to the destination URL.

How can this be tested?

  1. Setup: Have a personal posthog account, set POSTHOG_PROJECT_API_KEY=your-key-here and POSTHOG_ENABLED=True. MIT_LEARN_DASHBOARD_URL=local-learn-url. (You can leave learn url unset, you'll just get sent to prod instead.) Create a flag called redirect-to-learn-dashboard. Assuming you have two users A and B:
    • set the flag to be ON for user A distinct_id = global_uuid (see current_user api response)
    • flag OFF for user B.
  2. Log in as that user and navigate directly to /dashboard/ — you should be redirected to MIT_LEARN_DASHBOARD_URL without the legacy dashboard loading.
  3. Navigate to /dashboard/?foo=bar — the redirect should include ?foo=bar
  4. For user B (flag off), /dashboard/ should load normally
  5. Swap the flags for A and B... A off, B on. Within 5 minutes (depends on durable cache, but ours defaults to 300s) the redirection behavior should swap for users A and B.

Checklist

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 5, 2026

OpenAPI Changes

Show/hide ## Changes for v0.yaml:
## Changes for v0.yaml:


## Changes for v1.yaml:


## Changes for v2.yaml:


Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Comment thread main/views.py
"""
if request.user.is_authenticated:
global_id = request.user.global_id
if global_id and is_enabled(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a little concerned that the is_enabled call will block the dashboard response if posthog has another outage / service degradation, so opened

Comment thread main/views.py
@annagav annagav self-requested a review March 7, 2026 00:59
@annagav annagav self-assigned this Mar 7, 2026
Copy link
Copy Markdown
Contributor

@annagav annagav left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested with two users one with flag enabled and another not. LGTM 👍

ChristopherChudzicki and others added 3 commits March 9, 2026 20:44
Adds a Django-level redirect at /dashboard so authenticated users with
the PostHog flag enabled are sent to learn.mit.edu/dashboard before the
legacy JS bundle is downloaded and executed. The existing client-side
redirect is retained to cover in-SPA navigation. Both paths preserve
query parameters in the redirect URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ChristopherChudzicki ChristopherChudzicki force-pushed the cc/server-side-dashboard-redirect branch from 38743ed to f55f528 Compare March 10, 2026 00:52
Comment thread main/views.py
redirect_url = settings.MIT_LEARN_DASHBOARD_URL
if qs := request.META.get("QUERY_STRING"):
redirect_url = f"{redirect_url}?{qs}"
return HttpResponseRedirect(redirect_url)

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.

Copilot Autofix

AI 2 months ago

In general, the right way to fix this is to avoid building URLs with naïve string concatenation that incorporates untrusted input. Instead, parse the base URL, safely merge only those query parameters you actually want to allow, and reconstruct the URL using standard URL utilities. This both avoids malformed URLs and allows you to filter the user-provided parameters.

For this specific case in dashboard in main/views.py, we should:

  • Keep the host/path fully controlled by settings.MIT_LEARN_DASHBOARD_URL.
  • Parse any existing query parameters from that base URL.
  • Parse the user-provided query string from request.META["QUERY_STRING"] into key/value pairs.
  • Optionally filter which parameters are allowed through (at minimum, avoid raw string concatenation).
  • Rebuild the final URL via urllib.parse.urlparse / urlencode / urlunparse.

Concretely:

  • Add imports for urlparse, parse_qsl, urlencode, and urlunparse from Python’s standard urllib.parse module at the top of main/views.py (we may add imports, and this is a well-known standard library).
  • Replace lines 58–61 with logic that:
    • Parses settings.MIT_LEARN_DASHBOARD_URL using urlparse.
    • Parses request.META.get("QUERY_STRING", "") using parse_qsl.
    • (Optionally) filters the resulting dict to an allowed subset; since we don’t know the rest of the app, we’ll at least ensure robust parsing and reconstruction, and not allow the query string to affect the scheme/host/path.
    • Merges query parameters and reconstructs the URL with urlunparse.
  • Keep the rest of the behavior identical: still redirects to the same domain and still preserves query parameters, but now in a controlled way.
Suggested changeset 1
main/views.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/main/views.py b/main/views.py
--- a/main/views.py
+++ b/main/views.py
@@ -15,6 +15,7 @@
 from django.views.decorators.cache import never_cache
 from mitol.olposthog.features import is_enabled
 from rest_framework.pagination import LimitOffsetPagination
+from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
 
 from main import features
 
@@ -55,9 +56,19 @@
             default=False,
             opt_unique_id=global_id,
         ):
-            redirect_url = settings.MIT_LEARN_DASHBOARD_URL
-            if qs := request.META.get("QUERY_STRING"):
-                redirect_url = f"{redirect_url}?{qs}"
+            base_url = settings.MIT_LEARN_DASHBOARD_URL
+            parsed = urlparse(base_url)
+            # Start with any existing query parameters on the base URL
+            base_query_params = dict(parse_qsl(parsed.query, keep_blank_values=True))
+            # Parse query parameters supplied by the request
+            request_qs = request.META.get("QUERY_STRING", "")
+            request_query_params = dict(parse_qsl(request_qs, keep_blank_values=True))
+            # Merge, giving precedence to request-supplied parameters while
+            # keeping scheme/host/path from the configured base URL
+            merged_query_params = {**base_query_params, **request_query_params}
+            redirect_url = urlunparse(
+                parsed._replace(query=urlencode(merged_query_params, doseq=True))
+            )
             return HttpResponseRedirect(redirect_url)
     return index(request, **kwargs)
 
EOF
@@ -15,6 +15,7 @@
from django.views.decorators.cache import never_cache
from mitol.olposthog.features import is_enabled
from rest_framework.pagination import LimitOffsetPagination
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse

from main import features

@@ -55,9 +56,19 @@
default=False,
opt_unique_id=global_id,
):
redirect_url = settings.MIT_LEARN_DASHBOARD_URL
if qs := request.META.get("QUERY_STRING"):
redirect_url = f"{redirect_url}?{qs}"
base_url = settings.MIT_LEARN_DASHBOARD_URL
parsed = urlparse(base_url)
# Start with any existing query parameters on the base URL
base_query_params = dict(parse_qsl(parsed.query, keep_blank_values=True))
# Parse query parameters supplied by the request
request_qs = request.META.get("QUERY_STRING", "")
request_query_params = dict(parse_qsl(request_qs, keep_blank_values=True))
# Merge, giving precedence to request-supplied parameters while
# keeping scheme/host/path from the configured base URL
merged_query_params = {**base_query_params, **request_query_params}
redirect_url = urlunparse(
parsed._replace(query=urlencode(merged_query_params, doseq=True))
)
return HttpResponseRedirect(redirect_url)
return index(request, **kwargs)

Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem like a real concern to me. If we were redirecting to a user-provided host, that would be different. As it stands, a malicious person could give you

  • https://mitxonline.mit.edu/dashboard?some-malicious-string redirecting to
  • https://learn.mit.edu/dashboard?some-malicious-string

But (A) they couldve given you the second link to begin with, and (B) appending a query string to our URLs shouldn't let you do anything bad in either case.

I suspect copilot is just not distinguishing between 'user provides host' and 'user only provides query string'.

@ChristopherChudzicki ChristopherChudzicki force-pushed the cc/server-side-dashboard-redirect branch from 6f04f1b to f55f528 Compare March 10, 2026 01:53
@ChristopherChudzicki ChristopherChudzicki merged commit 6eb8d3b into main Mar 10, 2026
13 checks passed
@ChristopherChudzicki ChristopherChudzicki deleted the cc/server-side-dashboard-redirect branch March 10, 2026 15:22
@odlbot odlbot mentioned this pull request Mar 10, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants