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

mozilla / fx-private-relay / d3128616-238d-446e-82c5-ab66cd38ceaf

09 May 2024 06:22PM CUT coverage: 84.07% (-0.6%) from 84.64%
d3128616-238d-446e-82c5-ab66cd38ceaf

push

circleci

web-flow
Merge pull request #4684 from mozilla/enable-flak8-bandit-checks-mpp-3802

fix MPP-3802: stop ignoring bandit security checks

3601 of 4734 branches covered (76.07%)

Branch coverage included in aggregate %.

74 of 158 new or added lines in 24 files covered. (46.84%)

5 existing lines in 5 files now uncovered.

14686 of 17018 relevant lines covered (86.3%)

10.86 hits per line

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

75.23
/privaterelay/views.py
1
import json
1✔
2
import logging
1✔
3
import threading
1✔
4
from collections.abc import Iterable
1✔
5
from datetime import UTC, datetime
1✔
6
from functools import cache
1✔
7
from hashlib import sha256
1✔
8
from typing import Any, TypedDict
1✔
9

10
from django.apps import apps
1✔
11
from django.conf import settings
1✔
12
from django.db import IntegrityError, transaction
1✔
13
from django.http import HttpRequest, HttpResponse, JsonResponse
1✔
14
from django.shortcuts import redirect
1✔
15
from django.urls import reverse
1✔
16
from django.views.decorators.csrf import csrf_exempt
1✔
17
from django.views.decorators.http import require_http_methods
1✔
18

19
import jwt
1✔
20
import sentry_sdk
1✔
21
from allauth.socialaccount.models import SocialAccount, SocialApp
1✔
22
from allauth.socialaccount.providers.fxa.views import FirefoxAccountsOAuth2Adapter
1✔
23
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
1✔
24
from google_measurement_protocol import event, report
1✔
25
from oauthlib.oauth2.rfc6749.errors import CustomOAuth2Error
1✔
26
from rest_framework.decorators import api_view, schema
1✔
27

28
# from silk.profiling.profiler import silk_profile
29
from emails.models import (
1✔
30
    CannotMakeSubdomainException,
31
    DomainAddress,
32
    RelayAddress,
33
    valid_available_subdomain,
34
)
35
from emails.utils import incr_if_enabled
1✔
36

37
from .apps import PrivateRelayConfig
1✔
38
from .fxa_utils import NoSocialToken, _get_oauth2_session
1✔
39

40
FXA_PROFILE_CHANGE_EVENT = "https://schemas.accounts.firefox.com/event/profile-change"
1✔
41
FXA_SUBSCRIPTION_CHANGE_EVENT = (
1✔
42
    "https://schemas.accounts.firefox.com/event/subscription-state-change"
43
)
44
FXA_DELETE_EVENT = "https://schemas.accounts.firefox.com/event/delete-user"
1✔
45
PROFILE_EVENTS = [FXA_PROFILE_CHANGE_EVENT, FXA_SUBSCRIPTION_CHANGE_EVENT]
1✔
46

47
logger = logging.getLogger("events")
1✔
48
info_logger = logging.getLogger("eventsinfo")
1✔
49

50

51
@cache
1✔
52
def _get_fxa(request):
1✔
53
    return request.user.socialaccount_set.filter(provider="fxa").first()
×
54

55

56
@api_view()
1✔
57
@schema(None)
1✔
58
@require_http_methods(["GET"])
1✔
59
def profile_refresh(request):
1✔
60
    if not request.user or request.user.is_anonymous:
×
61
        return redirect(reverse("fxa_login"))
×
62
    profile = request.user.profile
×
63

64
    fxa = _get_fxa(request)
×
65
    update_fxa(fxa)
×
66
    if "clicked-purchase" in request.COOKIES and profile.has_premium:
×
67
        event = "user_purchased_premium"
×
68
        incr_if_enabled(event, 1)
×
69

70
    return JsonResponse({})
×
71

72

73
@api_view(["POST", "GET"])
1✔
74
@schema(None)
1✔
75
@require_http_methods(["POST", "GET"])
1✔
76
def profile_subdomain(request):
1✔
77
    if not request.user or request.user.is_anonymous:
×
78
        return redirect(reverse("fxa_login"))
×
79
    profile = request.user.profile
×
80
    if not profile.has_premium:
×
81
        raise CannotMakeSubdomainException("error-premium-check-subdomain")
×
82
    try:
×
83
        if request.method == "GET":
×
84
            subdomain = request.GET.get("subdomain", None)
×
85
            available = valid_available_subdomain(subdomain)
×
86
            return JsonResponse({"available": available})
×
87
        else:
88
            subdomain = request.POST.get("subdomain", None)
×
89
            profile.add_subdomain(subdomain)
×
90
            return JsonResponse(
×
91
                {"status": "Accepted", "message": "success-subdomain-registered"},
92
                status=202,
93
            )
94
    except CannotMakeSubdomainException as e:
×
95
        return JsonResponse({"message": e.message, "subdomain": subdomain}, status=400)
×
96

97

98
def send_ga_ping(ga_id: str, ga_uuid: str, data: Any) -> None:
1✔
99
    try:
1✔
100
        report(ga_id, ga_uuid, data)
1✔
101
    except Exception as e:
×
102
        logger.error("metrics_event", extra={"error": e})
×
103

104

105
@csrf_exempt
1✔
106
@require_http_methods(["POST"])
1✔
107
def metrics_event(request: HttpRequest) -> JsonResponse:
1✔
108
    try:
1✔
109
        request_data = json.loads(request.body)
1✔
110
    except json.JSONDecodeError:
1✔
111
        return JsonResponse({"msg": "Could not decode JSON"}, status=415)
1✔
112
    if "ga_uuid" not in request_data:
1✔
113
        return JsonResponse({"msg": "No GA uuid found"}, status=404)
1✔
114
    # "dimension5" is a Google Analytics-specific variable to track a custom dimension,
115
    # used to determine which browser vendor the add-on is using: Firefox or Chrome
116
    # "dimension7" is a Google Analytics-specific variable to track a custom dimension,
117
    # used to determine where the ping is coming from: website (default), add-on or app
118
    event_data = event(
1✔
119
        request_data.get("category", None),
120
        request_data.get("action", None),
121
        request_data.get("label", None),
122
        request_data.get("value", None),
123
        dimension5=request_data.get("dimension5", None),
124
        dimension7=request_data.get("dimension7", "website"),
125
    )
126
    t = threading.Thread(
1✔
127
        target=send_ga_ping,
128
        args=[settings.GOOGLE_ANALYTICS_ID, request_data.get("ga_uuid"), event_data],
129
        daemon=True,
130
    )
131
    t.start()
1✔
132
    return JsonResponse({"msg": "OK"}, status=200)
1✔
133

134

135
@csrf_exempt
1✔
136
def fxa_rp_events(request: HttpRequest) -> HttpResponse:
1✔
137
    req_jwt = _parse_jwt_from_request(request)
1✔
138
    authentic_jwt = _authenticate_fxa_jwt(req_jwt)
1✔
139
    event_keys = _get_event_keys_from_jwt(authentic_jwt)
1✔
140
    try:
1✔
141
        social_account = _get_account_from_jwt(authentic_jwt)
1✔
142
    except SocialAccount.DoesNotExist as e:
×
143
        # capture an exception in sentry, but don't error, or FXA will retry
144
        sentry_sdk.capture_exception(e)
×
145
        return HttpResponse("202 Accepted", status=202)
×
146

147
    for event_key in event_keys:
1✔
148
        if event_key in PROFILE_EVENTS:
1✔
149
            if settings.DEBUG:
1!
150
                info_logger.info(
×
151
                    "fxa_profile_update",
152
                    extra={
153
                        "jwt": authentic_jwt,
154
                        "event_key": event_key,
155
                    },
156
                )
157
            update_fxa(social_account, authentic_jwt, event_key)
1✔
158
        if event_key == FXA_DELETE_EVENT:
1✔
159
            _handle_fxa_delete(authentic_jwt, social_account, event_key)
1✔
160
    return HttpResponse("200 OK", status=200)
1✔
161

162

163
def _parse_jwt_from_request(request: HttpRequest) -> str:
1✔
164
    request_auth = request.headers["Authorization"]
1✔
165
    return request_auth.split("Bearer ")[1]
1✔
166

167

168
def fxa_verifying_keys(reload: bool = False) -> list[dict[str, Any]]:
1✔
169
    """Get list of FxA verifying (public) keys."""
170
    private_relay_config = apps.get_app_config("privaterelay")
1✔
171
    if not isinstance(private_relay_config, PrivateRelayConfig):
1!
NEW
172
        raise TypeError("private_relay_config must be PrivateRelayConfig")
×
173
    if reload:
1✔
174
        private_relay_config.ready()
1✔
175
    return private_relay_config.fxa_verifying_keys
1✔
176

177

178
def fxa_social_app(reload: bool = False) -> SocialApp:
1✔
179
    """Get FxA SocialApp from app config or DB."""
180
    private_relay_config = apps.get_app_config("privaterelay")
1✔
181
    if not isinstance(private_relay_config, PrivateRelayConfig):
1!
NEW
182
        raise TypeError("private_relay_config must be PrivateRelayConfig")
×
183
    if reload:
1!
184
        private_relay_config.ready()
×
185
    return private_relay_config.fxa_social_app
1✔
186

187

188
class FxAEvent(TypedDict):
1✔
189
    """
190
    FxA Security Event Token (SET) payload, sent to relying parties.
191

192
    See:
193
    https://github.com/mozilla/fxa/tree/main/packages/fxa-event-broker
194
    https://www.rfc-editor.org/rfc/rfc8417 (Security Event Token)
195
    """
196

197
    iss: str  # Issuer, https://accounts.firefox.com/
1✔
198
    sub: str  # Subject, FxA user ID
1✔
199
    aud: str  # Audience, Relay's client ID
1✔
200
    iat: int  # Creation time, timestamp
1✔
201
    jti: str  # JWT ID, unique for this SET
1✔
202
    events: dict[str, dict[str, Any]]  # Event data
1✔
203

204

205
def _authenticate_fxa_jwt(req_jwt: str) -> FxAEvent:
1✔
206
    authentic_jwt = _verify_jwt_with_fxa_key(req_jwt, fxa_verifying_keys())
1✔
207

208
    if not authentic_jwt:
1!
209
        # FXA key may be old? re-fetch FXA keys and try again
210
        authentic_jwt = _verify_jwt_with_fxa_key(
×
211
            req_jwt, fxa_verifying_keys(reload=True)
212
        )
213
        if not authentic_jwt:
×
214
            raise Exception("Could not authenticate JWT with FXA key.")
×
215

216
    return authentic_jwt
1✔
217

218

219
def _verify_jwt_with_fxa_key(
1✔
220
    req_jwt: str, verifying_keys: list[dict[str, Any]]
221
) -> FxAEvent | None:
222
    if not verifying_keys:
1!
223
        raise Exception("FXA verifying keys are not available.")
×
224
    social_app = fxa_social_app()
1✔
225
    if not social_app:
1!
226
        raise Exception("FXA SocialApp is not available.")
×
227
    if not isinstance(social_app, SocialApp):
1!
NEW
228
        raise TypeError("social_app must be SocialApp")
×
229
    for verifying_key in verifying_keys:
1!
230
        if verifying_key["alg"] == "RS256":
1!
231
            public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(verifying_key))
1✔
232
            if not isinstance(public_key, RSAPublicKey):
1!
NEW
233
                raise TypeError("public_key must be RSAPublicKey")
×
234
            try:
1✔
235
                security_event = jwt.decode(
1✔
236
                    req_jwt,
237
                    public_key,
238
                    audience=social_app.client_id,
239
                    algorithms=["RS256"],
240
                    leeway=5,  # allow iat to be slightly in future, for clock skew
241
                )
242
            except jwt.ImmatureSignatureError:
1✔
243
                # Issue 2738: Log age of iat, if present
244
                claims = jwt.decode(
1✔
245
                    req_jwt,
246
                    public_key,
247
                    algorithms=["RS256"],
248
                    options={"verify_signature": False},
249
                )
250
                iat = claims.get("iat")
1✔
251
                iat_age = None
1✔
252
                if iat:
1!
253
                    iat_age = round(datetime.now(tz=UTC).timestamp() - iat, 3)
1✔
254
                info_logger.warning(
1✔
255
                    "fxa_rp_event.future_iat", extra={"iat": iat, "iat_age_s": iat_age}
256
                )
257
                raise
1✔
258
            return FxAEvent(
1✔
259
                iss=security_event["iss"],
260
                sub=security_event["sub"],
261
                aud=security_event["aud"],
262
                iat=security_event["iat"],
263
                jti=security_event["jti"],
264
                events=security_event["events"],
265
            )
266
    return None
×
267

268

269
def _get_account_from_jwt(authentic_jwt: FxAEvent) -> SocialAccount:
1✔
270
    social_account_uid = authentic_jwt["sub"]
1✔
271
    return SocialAccount.objects.get(uid=social_account_uid, provider="fxa")
1✔
272

273

274
def _get_event_keys_from_jwt(authentic_jwt: FxAEvent) -> Iterable[str]:
1✔
275
    return authentic_jwt["events"].keys()
1✔
276

277

278
def update_fxa(
1✔
279
    social_account: SocialAccount,
280
    authentic_jwt: FxAEvent | None = None,
281
    event_key: str | None = None,
282
) -> HttpResponse:
283
    try:
1✔
284
        client = _get_oauth2_session(social_account)
1✔
285
    except NoSocialToken as e:
×
286
        sentry_sdk.capture_exception(e)
×
287
        return HttpResponse("202 Accepted", status=202)
×
288

289
    # TODO: more graceful handling of profile fetch failures
290
    try:
1✔
291
        resp = client.get(FirefoxAccountsOAuth2Adapter.profile_url)
1✔
292
    except CustomOAuth2Error as e:
×
293
        sentry_sdk.capture_exception(e)
×
294
        return HttpResponse("202 Accepted", status=202)
×
295

296
    extra_data = resp.json()
1✔
297

298
    try:
1✔
299
        new_email = extra_data["email"]
1✔
300
    except KeyError as e:
×
301
        sentry_sdk.capture_exception(e)
×
302
        return HttpResponse("202 Accepted", status=202)
×
303

304
    if authentic_jwt and event_key:
1!
305
        info_logger.info(
1✔
306
            "fxa_rp_event",
307
            extra={
308
                "fxa_uid": authentic_jwt["sub"],
309
                "event_key": event_key,
310
                "real_address": sha256(new_email.encode("utf-8")).hexdigest(),
311
            },
312
        )
313

314
    return _update_all_data(social_account, extra_data, new_email)
1✔
315

316

317
def _update_all_data(
1✔
318
    social_account: SocialAccount, extra_data: dict[str, Any], new_email: str
319
) -> HttpResponse:
320
    try:
1✔
321
        profile = social_account.user.profile
1✔
322
        had_premium = profile.has_premium
1✔
323
        had_phone = profile.has_phone
1✔
324
        with transaction.atomic():
1✔
325
            social_account.extra_data = extra_data
1✔
326
            social_account.save()
1✔
327
            profile = social_account.user.profile
1✔
328
            now_has_premium = profile.has_premium
1✔
329
            newly_premium = not had_premium and now_has_premium
1✔
330
            no_longer_premium = had_premium and not now_has_premium
1✔
331
            if newly_premium:
1✔
332
                incr_if_enabled("user_purchased_premium", 1)
1✔
333
                profile.date_subscribed = datetime.now(UTC)
1✔
334
                profile.save()
1✔
335
            if no_longer_premium:
1!
336
                incr_if_enabled("user_has_downgraded", 1)
×
337
            now_has_phone = profile.has_phone
1✔
338
            newly_phone = not had_phone and now_has_phone
1✔
339
            no_longer_phone = had_phone and not now_has_phone
1✔
340
            if newly_phone:
1✔
341
                incr_if_enabled("user_purchased_phone", 1)
1✔
342
                profile.date_subscribed_phone = datetime.now(UTC)
1✔
343
                profile.date_phone_subscription_reset = datetime.now(UTC)
1✔
344
                profile.save()
1✔
345
            if no_longer_phone:
1!
346
                incr_if_enabled("user_has_dropped_phone", 1)
×
347
            social_account.user.email = new_email
1✔
348
            social_account.user.save()
1✔
349
            email_address_record = social_account.user.emailaddress_set.first()
1✔
350
            if email_address_record:
1✔
351
                email_address_record.email = new_email
1✔
352
                email_address_record.save()
1✔
353
            else:
354
                social_account.user.emailaddress_set.create(email=new_email)
1✔
355
            return HttpResponse("202 Accepted", status=202)
1✔
356
    except IntegrityError as e:
1✔
357
        sentry_sdk.capture_exception(e)
1✔
358
        return HttpResponse("Conflict", status=409)
1✔
359

360

361
def _handle_fxa_delete(
1✔
362
    authentic_jwt: FxAEvent, social_account: SocialAccount, event_key: str
363
) -> None:
364
    # Using for loops here because QuerySet.delete() does a bulk delete which does
365
    # not call the model delete() methods that create DeletedAddress records
366
    for relay_address in RelayAddress.objects.filter(user=social_account.user):
1✔
367
        relay_address.delete()
1✔
368
    for domain_address in DomainAddress.objects.filter(user=social_account.user):
1✔
369
        domain_address.delete()
1✔
370

371
    social_account.user.delete()
1✔
372
    info_logger.info(
1✔
373
        "fxa_rp_event",
374
        extra={
375
            "fxa_uid": authentic_jwt["sub"],
376
            "event_key": event_key,
377
        },
378
    )
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