• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 909940e6-3138-49cd-bd40-d0148071c1ba

20 Oct 2025 04:47PM UTC coverage: 88.866% (+0.008%) from 88.858%
909940e6-3138-49cd-bd40-d0148071c1ba

push

circleci

jwhitlock
Revert "fix: Keep querystring when redirecting for slash"

This reverts commit 7d6acbd38.

2910 of 3921 branches covered (74.22%)

Branch coverage included in aggregate %.

18066 of 19683 relevant lines covered (91.78%)

11.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.32
/privaterelay/middleware.py
1
import binascii
1✔
2
import os
1✔
3
import re
1✔
4
import time
1✔
5
from collections.abc import Callable
1✔
6
from datetime import UTC, datetime
1✔
7

8
from django.conf import settings
1✔
9
from django.http import HttpRequest, HttpResponse
1✔
10
from django.shortcuts import redirect
1✔
11

12
import markus
1✔
13
from csp.middleware import CSPMiddleware
1✔
14
from whitenoise.middleware import WhiteNoiseMiddleware
1✔
15

16
from privaterelay.utils import glean_logger
1✔
17

18
metrics = markus.get_metrics()
1✔
19

20

21
# To find all the URL paths that serve HTML which need the CSP nonce:
22
# python manage.py collectstatic
23
# find staticfiles -type f -name 'index.html'
24
CSP_NONCE_COOKIE_PATHS = [
1✔
25
    "/",
26
    "/contains-tracker-warning/",
27
    "/flags/",
28
    "/faq/",
29
    "/vpn-relay/waitlist/",
30
    "/accounts/settings/",
31
    "/accounts/profile/",
32
    "/accounts/account_inactive/",
33
    "/vpn-relay-welcome/",
34
    "/phone/waitlist/",
35
    "/phone/",
36
    "/404/",
37
    "/tracker-report/",
38
    "/premium/waitlist/",
39
    "/premium/",
40
]
41

42

43
class EagerNonceCSPMiddleware(CSPMiddleware):
1✔
44
    # We need a nonce to use Google Tag Manager with a safe CSP:
45
    # https://developers.google.com/tag-platform/security/guides/csp
46
    # django-csp only includes the nonce value in the CSP header if the csp_nonce
47
    # attribute is accessed:
48
    # https://django-csp.readthedocs.io/en/latest/nonce.html
49
    # That works for urls served by Django views that access the attribute but it
50
    # doesn't work for urls that are served by views which don't access the attribute.
51
    # (e.g., Whitenoise)
52
    # So, to ensure django-csp includes the nonce value in the CSP header of every
53
    # response, we override the default CSPMiddleware with this middleware. If the
54
    # request is for one of the HTML urls, this middleware sets the request.csp_nonce
55
    # attribute and adds a cookie for the React app to get the nonce value for scripts.
56
    def process_request(self, request):
1✔
57
        if request.path in CSP_NONCE_COOKIE_PATHS:
1!
58
            request_nonce = binascii.hexlify(os.urandom(16)).decode("ascii")
×
59
            request._csp_nonce = request_nonce
×
60

61
    def process_response(self, request, response):
1✔
62
        response = super().process_response(request, response)
1✔
63
        if request.path in CSP_NONCE_COOKIE_PATHS:
1!
64
            response.set_cookie(
×
65
                "csp_nonce", request._csp_nonce, secure=True, samesite="Strict"
66
            )
67
        return response
1✔
68

69

70
class RedirectRootIfLoggedIn:
1✔
71
    def __init__(self, get_response):
1✔
72
        self.get_response = get_response
1✔
73

74
    def __call__(self, request):
1✔
75
        # To prevent showing a flash of the landing page when a user is logged
76
        # in, use a server-side redirect to send them to the dashboard,
77
        # rather than handling that on the client-side:
78
        if request.path == "/" and settings.SESSION_COOKIE_NAME in request.COOKIES:
1!
79
            query_string = (
×
80
                "?" + request.META["QUERY_STRING"]
81
                if request.META["QUERY_STRING"]
82
                else ""
83
            )
84
            return redirect("accounts/profile/" + query_string)
×
85

86
        response = self.get_response(request)
1✔
87
        return response
1✔
88

89

90
class AddDetectedCountryToRequestAndResponseHeaders:
1✔
91
    def __init__(self, get_response):
1✔
92
        self.get_response = get_response
1✔
93

94
    def __call__(self, request):
1✔
95
        region_key = "X-Client-Region"
1✔
96
        region_dict = None
1✔
97
        if region_key in request.headers:
1✔
98
            region_dict = request.headers
1✔
99
        if region_key in request.GET:
1!
100
            region_dict = request.GET
×
101
        if not region_dict:
1✔
102
            return self.get_response(request)
1✔
103

104
        country = region_dict.get(region_key)
1✔
105
        request.country = country
1✔
106
        response = self.get_response(request)
1✔
107
        response.country = country
1✔
108
        return response
1✔
109

110

111
class ResponseMetrics:
1✔
112
    re_dockerflow = re.compile(r"/__(version|heartbeat|lbheartbeat)__/?$")
1✔
113

114
    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
1✔
115
        self.get_response = get_response
1✔
116
        self.middleware = RelayStaticFilesMiddleware()
1✔
117

118
    def __call__(self, request: HttpRequest) -> HttpResponse:
1✔
119
        if not settings.STATSD_ENABLED:
1✔
120
            return self.get_response(request)
1✔
121

122
        start_time = time.time()
1✔
123
        response = self.get_response(request)
1✔
124
        delta = time.time() - start_time
1✔
125
        view_name = self._get_metric_view_name(request)
1✔
126
        metrics.timing(
1✔
127
            "response",
128
            value=delta * 1000.0,
129
            tags=[
130
                f"status:{response.status_code}",
131
                f"view:{view_name}",
132
                f"method:{request.method}",
133
            ],
134
        )
135
        return response
1✔
136

137
    def _get_metric_view_name(self, request: HttpRequest) -> str:
1✔
138
        if request.resolver_match:
1✔
139
            view = request.resolver_match.func
1✔
140
            if hasattr(view, "view_class"):
1✔
141
                # Wrapped with rest_framework.decorators.api_view
142
                return f"{view.__module__}.{view.view_class.__name__}"
1✔
143
            return f"{view.__module__}.{view.__name__}"
1✔
144
        if match := self.re_dockerflow.match(request.path_info):
1✔
145
            return f"dockerflow.django.views.{match[1]}"
1✔
146
        if self.middleware.is_staticfile(request.path_info):
1!
147
            return "<static_file>"
1✔
148
        return "<unknown_view>"
×
149

150

151
class StoreFirstVisit:
1✔
152
    def __init__(self, get_response):
1✔
153
        self.get_response = get_response
1✔
154

155
    def __call__(self, request):
1✔
156
        response = self.get_response(request)
1✔
157
        first_visit = request.COOKIES.get("first_visit")
1✔
158
        if first_visit is None and not request.user.is_anonymous:
1✔
159
            response.set_cookie("first_visit", datetime.now(UTC))
1✔
160
        return response
1✔
161

162

163
class RelayStaticFilesMiddleware(WhiteNoiseMiddleware):
1✔
164
    """Customize WhiteNoiseMiddleware for Relay.
165

166
    The WhiteNoiseMiddleware serves static files and sets headers. In
167
    production, the files are read from staticfiles/staticfiles.json,
168
    and files with hashes in the name are treated as immutable with
169
    10-year cache timeouts.
170

171
    This class also treats Next.js output files (already hashed) as immutable.
172
    """
173

174
    def immutable_file_test(self, path, url):
1✔
175
        """
176
        Determine whether given URL represents an immutable file (i.e. a
177
        file with a hash of its contents as part of its name) which can
178
        therefore be cached forever.
179

180
        All files outputted by next.js are hashed and immutable
181
        """
182
        if not url.startswith(self.static_prefix):
1!
183
            return False
×
184
        name = url[len(self.static_prefix) :]
1✔
185
        if name.startswith("_next/static/"):
1✔
186
            return True
1✔
187
        else:
188
            return super().immutable_file_test(path, url)
1✔
189

190
    def is_staticfile(self, path_info: str) -> bool:
1✔
191
        """
192
        Returns True if this file is served by the middleware.
193

194
        This uses the logic from whitenoise.middleware.WhiteNoiseMiddleware.__call__:
195
        https://github.com/evansd/whitenoise/blob/220a98894495d407424e80d85d49227a5cf97e1b/src/whitenoise/middleware.py#L117-L124
196
        """
197
        if self.autorefresh:
1!
198
            static_file = self.find_file(path_info)
×
199
        else:
200
            static_file = self.files.get(path_info)
1✔
201
        return static_file is not None
1✔
202

203

204
class GleanApiAccessMiddleware:
1✔
205
    def __init__(self, get_response):
1✔
206
        self.get_response = get_response
1✔
207

208
    def __call__(self, request):
1✔
209
        if request.path.startswith("/api/"):
1✔
210
            glean_logger().log_api_accessed(request)
1✔
211
        return self.get_response(request)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc