privaterelay/settings.py (731 lines of code) (raw):

""" Django settings for privaterelay project. Generated by 'django-admin startproject' using Django 2.2.2. For more information on this file, see https://docs.djangoproject.com/en/2.2/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/2.2/ref/settings/ """ from __future__ import annotations import base64 import ipaddress import os import sys from hashlib import sha256 from pathlib import Path from typing import TYPE_CHECKING, Any, cast, get_args from django.conf.global_settings import LANGUAGES as DEFAULT_LANGUAGES import dj_database_url import django_stubs_ext import sentry_sdk from csp.constants import NONCE, NONE, SELF, UNSAFE_INLINE from decouple import Choices, Csv, config from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import ignore_logger from .types import CONTENT_SECURITY_POLICY_T, RELAY_CHANNEL_NAME if TYPE_CHECKING: import wsgiref.headers try: # Silk is a live profiling and inspection tool for the Django framework # https://github.com/jazzband/django-silk import silk # noqa: F401 HAS_SILK = True except ImportError: HAS_SILK = False try: from privaterelay.glean.server_events import GLEAN_EVENT_MOZLOG_TYPE except ImportError: # File may not be generated yet. Will be checked at initialization GLEAN_EVENT_MOZLOG_TYPE = "glean-server-event" # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TMP_DIR = os.path.join(BASE_DIR, "tmp") STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ # defaulting to blank to be production-broken by default SECRET_KEY = config("SECRET_KEY", None) SECRET_KEY_FALLBACKS = config("SECRET_KEY_FALLBACKS", "", cast=Csv()) SITE_ORIGIN: str | None = config("SITE_ORIGIN", None) ORIGIN_CHANNEL_MAP: dict[str, RELAY_CHANNEL_NAME] = { "http://127.0.0.1:8000": "local", "https://dev.fxprivaterelay.nonprod.cloudops.mozgcp.net": "dev", "https://stage.fxprivaterelay.nonprod.cloudops.mozgcp.net": "stage", "https://relay.firefox.com": "prod", } RELAY_CHANNEL: RELAY_CHANNEL_NAME = cast( RELAY_CHANNEL_NAME, config( "RELAY_CHANNEL", default=ORIGIN_CHANNEL_MAP.get(SITE_ORIGIN or "", "local"), cast=Choices(get_args(RELAY_CHANNEL_NAME), cast=str), ), ) DEBUG = config("DEBUG", False, cast=bool) if DEBUG: INTERNAL_IPS = config("DJANGO_INTERNAL_IPS", default="", cast=Csv()) IN_PYTEST: bool = "pytest" in sys.modules USE_SILK = DEBUG and HAS_SILK and not IN_PYTEST DEFAULT_EXCEPTION_REPORTER_FILTER = ( "privaterelay.debug.RelaySaferExceptionReporterFilter" ) # Honor the 'X-Forwarded-Proto' header for request.is_secure() SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_SSL_HOST = config("DJANGO_SECURE_SSL_HOST", None) SECURE_SSL_REDIRECT = config("DJANGO_SECURE_SSL_REDIRECT", False, cast=bool) SECURE_REDIRECT_EXEMPT = [ r"^__version__", r"^__heartbeat__", r"^__lbheartbeat__", ] SECURE_HSTS_INCLUDE_SUBDOMAINS = config( "DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", False, cast=bool ) SECURE_HSTS_PRELOAD = config("DJANGO_SECURE_HSTS_PRELOAD", False, cast=bool) SECURE_HSTS_SECONDS = config("DJANGO_SECURE_HSTS_SECONDS", None) SECURE_BROWSER_XSS_FILTER = config("DJANGO_SECURE_BROWSER_XSS_FILTER", True) SESSION_COOKIE_SECURE = config("DJANGO_SESSION_COOKIE_SECURE", False, cast=bool) CSRF_COOKIE_SECURE = config("DJANGO_CSRF_COOKIE_SECURE", False, cast=bool) # # Setup CSP # BASKET_ORIGIN = config("BASKET_ORIGIN", "https://basket.mozilla.org") # maps FxA / Mozilla account profile hosts to respective hosts for CSP FXA_BASE_ORIGIN: str = config("FXA_BASE_ORIGIN", "https://accounts.firefox.com") if FXA_BASE_ORIGIN == "https://accounts.firefox.com": _AVATAR_IMG_SRC = [ "firefoxusercontent.com", "https://profile.accounts.firefox.com", ] _ACCOUNT_CONNECT_SRC = [FXA_BASE_ORIGIN] else: if not FXA_BASE_ORIGIN == "https://accounts.stage.mozaws.net": raise ValueError( "FXA_BASE_ORIGIN must be either https://accounts.firefox.com or https://accounts.stage.mozaws.net" ) _AVATAR_IMG_SRC = [ "mozillausercontent.com", "https://profile.stage.mozaws.net", ] _ACCOUNT_CONNECT_SRC = [ FXA_BASE_ORIGIN, # fxaFlowTracker.ts will try this if runtimeData is slow "https://accounts.firefox.com", ] API_DOCS_ENABLED = config("API_DOCS_ENABLED", False, cast=bool) or DEBUG _CSP_SCRIPT_INLINE = USE_SILK # When running locally, styles might get refreshed while the server is running, so their # hashes would get oudated. Hence, we just allow all of them. _CSP_STYLE_INLINE = API_DOCS_ENABLED or RELAY_CHANNEL == "local" if API_DOCS_ENABLED: _API_DOCS_CSP_IMG_SRC = ["data:", "https://cdn.redoc.ly"] _API_DOCS_CSP_STYLE_SRC = ["https://fonts.googleapis.com"] _API_DOCS_CSP_FONT_SRC = ["https://fonts.gstatic.com"] _API_DOCS_CSP_WORKER_SRC = ["blob:"] else: _API_DOCS_CSP_IMG_SRC = [] _API_DOCS_CSP_STYLE_SRC = [] _API_DOCS_CSP_FONT_SRC = [] _API_DOCS_CSP_WORKER_SRC = [] # Next.js dynamically inserts the relevant styles when switching pages, # by injecting them as inline styles. We need to explicitly allow those styles # in our Content Security Policy. _CSP_STYLE_HASHES: list[str] = [] if _CSP_STYLE_INLINE: # 'unsafe-inline' is not compatible with hash sources _CSP_STYLE_HASHES = [] else: # When running in production, we want to disallow inline styles that are # not set by us, so we use an explicit allowlist with the hashes of the # styles generated by Next.js. _next_css_path = Path(STATIC_ROOT) / "_next" / "static" / "css" for path in _next_css_path.glob("*.css"): # Use sha256 hashes, to keep in sync with Chrome. # When CSP rules fail in Chrome, it provides the sha256 hash that would # have matched, useful for debugging. content = open(path, "rb").read() the_hash = base64.b64encode(sha256(content).digest()).decode() _CSP_STYLE_HASHES.append(f"'sha256-{the_hash}'") _CSP_STYLE_HASHES.sort() # Add the hash for an empty string (sha256-47DEQp...) # next,js injects an empty style element and then adds the content. # This hash avoids a spurious CSP error. empty_hash = base64.b64encode(sha256().digest()).decode() _CSP_STYLE_HASHES.append(f"'sha256-{empty_hash}'") CONTENT_SECURITY_POLICY: CONTENT_SECURITY_POLICY_T = { "DIRECTIVES": { "default-src": [SELF], "connect-src": [ SELF, "https://*.google-analytics.com", "https://*.analytics.google.com", "https://*.googletagmanager.com", "https://location.services.mozilla.com", "https://api.stripe.com", BASKET_ORIGIN, ], "font-src": [SELF, "https://relay.firefox.com/"], "frame-src": ["https://js.stripe.com", "https://hooks.stripe.com"], "img-src": [ SELF, "https://*.google-analytics.com", "https://*.googletagmanager.com", ], "object-src": [NONE], "script-src": [ SELF, NONCE, "https://www.google-analytics.com/", "https://*.googletagmanager.com", "https://js.stripe.com/", ], "style-src": [SELF], "worker-src": [SELF, "blob:"], # TODO: remove blob: temporary fix for GA4 } } CONTENT_SECURITY_POLICY["DIRECTIVES"]["connect-src"].extend(_ACCOUNT_CONNECT_SRC) CONTENT_SECURITY_POLICY["DIRECTIVES"]["font-src"].extend(_API_DOCS_CSP_FONT_SRC) CONTENT_SECURITY_POLICY["DIRECTIVES"]["img-src"].extend(_AVATAR_IMG_SRC) CONTENT_SECURITY_POLICY["DIRECTIVES"]["img-src"].extend(_API_DOCS_CSP_IMG_SRC) CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].extend(_API_DOCS_CSP_STYLE_SRC) CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].extend(_CSP_STYLE_HASHES) if _CSP_SCRIPT_INLINE: CONTENT_SECURITY_POLICY["DIRECTIVES"]["script-src"].append(UNSAFE_INLINE) if _CSP_STYLE_INLINE: CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].append(UNSAFE_INLINE) if _API_DOCS_CSP_WORKER_SRC: CONTENT_SECURITY_POLICY["DIRECTIVES"]["worker-src"].extend(_API_DOCS_CSP_WORKER_SRC) if _CSP_REPORT_URI := config("CSP_REPORT_URI", ""): CONTENT_SECURITY_POLICY["DIRECTIVES"]["report-uri"] = _CSP_REPORT_URI REFERRER_POLICY = "strict-origin-when-cross-origin" ALLOWED_HOSTS: list[str] = [] DJANGO_ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOST", "", cast=Csv()) if DJANGO_ALLOWED_HOSTS: ALLOWED_HOSTS += DJANGO_ALLOWED_HOSTS DJANGO_ALLOWED_SUBNET = config("DJANGO_ALLOWED_SUBNET", None) if DJANGO_ALLOWED_SUBNET: ALLOWED_HOSTS += [str(ip) for ip in ipaddress.IPv4Network(DJANGO_ALLOWED_SUBNET)] # Get our backing resource configs to check if we should install the app ADMIN_ENABLED = config("ADMIN_ENABLED", False, cast=bool) AWS_REGION: str | None = config("AWS_REGION", None) AWS_ACCESS_KEY_ID = config("AWS_ACCESS_KEY_ID", None) AWS_SECRET_ACCESS_KEY = config("AWS_SECRET_ACCESS_KEY", None) AWS_SNS_TOPIC = set(config("AWS_SNS_TOPIC", "", cast=Csv())) AWS_SNS_KEY_CACHE = config("AWS_SNS_KEY_CACHE", "default") AWS_SES_CONFIGSET: str | None = config("AWS_SES_CONFIGSET", None) AWS_SQS_EMAIL_QUEUE_URL = config("AWS_SQS_EMAIL_QUEUE_URL", None) AWS_SQS_EMAIL_DLQ_URL = config("AWS_SQS_EMAIL_DLQ_URL", None) # Dead-Letter Queue (DLQ) for SNS push subscription AWS_SQS_QUEUE_URL = config("AWS_SQS_QUEUE_URL", None) RELAY_FROM_ADDRESS: str = config("RELAY_FROM_ADDRESS", "") GOOGLE_ANALYTICS_ID = config("GOOGLE_ANALYTICS_ID", None) GA4_MEASUREMENT_ID = config("GA4_MEASUREMENT_ID", None) GOOGLE_APPLICATION_CREDENTIALS: str = config("GOOGLE_APPLICATION_CREDENTIALS", "") GOOGLE_CLOUD_PROFILER_CREDENTIALS_B64: str = config( "GOOGLE_CLOUD_PROFILER_CREDENTIALS_B64", "" ) INCLUDE_VPN_BANNER = config("INCLUDE_VPN_BANNER", False, cast=bool) RECRUITMENT_BANNER_LINK = config("RECRUITMENT_BANNER_LINK", None) RECRUITMENT_BANNER_TEXT = config("RECRUITMENT_BANNER_TEXT", None) RECRUITMENT_EMAIL_BANNER_TEXT = config("RECRUITMENT_EMAIL_BANNER_TEXT", None) RECRUITMENT_EMAIL_BANNER_LINK = config("RECRUITMENT_EMAIL_BANNER_LINK", None) PHONES_ENABLED: bool = config("PHONES_ENABLED", False, cast=bool) PHONES_NO_CLIENT_CALLS_IN_TEST = False # Override in tests that do not test clients TWILIO_ACCOUNT_SID: str | None = config("TWILIO_ACCOUNT_SID", None) TWILIO_AUTH_TOKEN: str | None = config("TWILIO_AUTH_TOKEN", None) TWILIO_MAIN_NUMBER: str | None = config("TWILIO_MAIN_NUMBER", None) TWILIO_SMS_APPLICATION_SID: str | None = config("TWILIO_SMS_APPLICATION_SID", None) TWILIO_MESSAGING_SERVICE_SID: list[str] = config( "TWILIO_MESSAGING_SERVICE_SID", "", cast=Csv() ) TWILIO_TEST_ACCOUNT_SID: str | None = config("TWILIO_TEST_ACCOUNT_SID", None) TWILIO_TEST_AUTH_TOKEN: str | None = config("TWILIO_TEST_AUTH_TOKEN", None) TWILIO_ALLOWED_COUNTRY_CODES = { code.upper() for code in config("TWILIO_ALLOWED_COUNTRY_CODES", "US,CA,PR", cast=Csv()) } TWILIO_NEEDS_10DLC_CAMPAIGN = { code.upper() for code in config("TWILIO_NEEDS_10DLC_CAMPAIGN", "US,PR", cast=Csv()) } MAX_MINUTES_TO_VERIFY_REAL_PHONE: int = config( "MAX_MINUTES_TO_VERIFY_REAL_PHONE", 5, cast=int ) MAX_TEXTS_PER_BILLING_CYCLE: int = config("MAX_TEXTS_PER_BILLING_CYCLE", 75, cast=int) MAX_MINUTES_PER_BILLING_CYCLE: int = config( "MAX_MINUTES_PER_BILLING_CYCLE", 50, cast=int ) DAYS_PER_BILLING_CYCLE = config("DAYS_PER_BILLING_CYCLE", 30, cast=int) MAX_DAYS_IN_MONTH = 31 IQ_ENABLED = config("IQ_ENABLED", False, cast=bool) IQ_FOR_VERIFICATION: bool = config("IQ_FOR_VERIFICATION", False, cast=bool) IQ_FOR_NEW_NUMBERS = config("IQ_FOR_NEW_NUMBERS", False, cast=bool) IQ_MAIN_NUMBER: str = config("IQ_MAIN_NUMBER", "") IQ_OUTBOUND_API_KEY: str = config("IQ_OUTBOUND_API_KEY", "") IQ_INBOUND_API_KEY = config("IQ_INBOUND_API_KEY", "") IQ_MESSAGE_API_ORIGIN = config( "IQ_MESSAGE_API_ORIGIN", "https://messagebroker.inteliquent.com" ) IQ_MESSAGE_PATH = "/msgbroker/rest/publishMessages" IQ_PUBLISH_MESSAGE_URL: str = f"{IQ_MESSAGE_API_ORIGIN}{IQ_MESSAGE_PATH}" DJANGO_STATSD_ENABLED = config("DJANGO_STATSD_ENABLED", False, cast=bool) STATSD_DEBUG = config("STATSD_DEBUG", False, cast=bool) STATSD_ENABLED: bool = DJANGO_STATSD_ENABLED or STATSD_DEBUG STATSD_HOST = config("DJANGO_STATSD_HOST", "127.0.0.1") STATSD_PORT = config("DJANGO_STATSD_PORT", "8125") STATSD_PREFIX = config("DJANGO_STATSD_PREFIX", "firefox_relay") SERVE_ADDON = config("SERVE_ADDON", None) # Application definition INSTALLED_APPS = [ "whitenoise.runserver_nostatic", "django.contrib.staticfiles", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.sites", "django_filters", "django_ftl.apps.DjangoFtlConfig", "dockerflow.django", "allauth", "allauth.account", "allauth.socialaccount", "allauth.socialaccount.providers.fxa", "rest_framework", "rest_framework.authtoken", "corsheaders", "csp", "waffle", "privaterelay.apps.PrivateRelayConfig", "api.apps.ApiConfig", ] if API_DOCS_ENABLED: INSTALLED_APPS += [ "drf_spectacular", "drf_spectacular_sidecar", ] if DEBUG: INSTALLED_APPS += [ "debug_toolbar", ] if USE_SILK: INSTALLED_APPS.append("silk") if ADMIN_ENABLED: INSTALLED_APPS += [ "django.contrib.admin", ] if AWS_SES_CONFIGSET and AWS_SNS_TOPIC: INSTALLED_APPS += [ "emails.apps.EmailsConfig", ] if PHONES_ENABLED: INSTALLED_APPS += [ "phones.apps.PhonesConfig", ] MIDDLEWARE = ["privaterelay.middleware.ResponseMetrics"] if USE_SILK: MIDDLEWARE.append("silk.middleware.SilkyMiddleware") if DEBUG: MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") MIDDLEWARE += [ "django.middleware.security.SecurityMiddleware", "privaterelay.middleware.EagerNonceCSPMiddleware", "privaterelay.middleware.RedirectRootIfLoggedIn", "privaterelay.middleware.RelayStaticFilesMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.locale.LocaleMiddleware", "allauth.account.middleware.AccountMiddleware", "django_ftl.middleware.activate_from_request_language_code", "django_referrer_policy.middleware.ReferrerPolicyMiddleware", "dockerflow.django.middleware.DockerflowMiddleware", "waffle.middleware.WaffleMiddleware", "privaterelay.middleware.AddDetectedCountryToRequestAndResponseHeaders", "privaterelay.middleware.StoreFirstVisit", "privaterelay.middleware.GleanApiAccessMiddleware", ] ROOT_URLCONF = "privaterelay.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [ os.path.join(BASE_DIR, "privaterelay", "templates"), ], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] RELAY_FIREFOX_DOMAIN: str = config("RELAY_FIREFOX_DOMAIN", "relay.firefox.com") MOZMAIL_DOMAIN: str = config("MOZMAIL_DOMAIN", "mozmail.com") MAX_NUM_FREE_ALIASES: int = config("MAX_NUM_FREE_ALIASES", 5, cast=int) PERIODICAL_PREMIUM_PROD_ID: str = config("PERIODICAL_PREMIUM_PROD_ID", "") PREMIUM_PLAN_ID_US_MONTHLY: str = config( "PREMIUM_PLAN_ID_US_MONTHLY", "price_1LXUcnJNcmPzuWtRpbNOajYS" ) PREMIUM_PLAN_ID_US_YEARLY: str = config( "PREMIUM_PLAN_ID_US_YEARLY", "price_1LXUdlJNcmPzuWtRKTYg7mpZ" ) PHONE_PROD_ID = config("PHONE_PROD_ID", "") PHONE_PLAN_ID_US_MONTHLY: str = config( "PHONE_PLAN_ID_US_MONTHLY", "price_1Li0w8JNcmPzuWtR2rGU80P3" ) PHONE_PLAN_ID_US_YEARLY: str = config( "PHONE_PLAN_ID_US_YEARLY", "price_1Li15WJNcmPzuWtRIh0F4VwP" ) BUNDLE_PROD_ID = config("BUNDLE_PROD_ID", "") BUNDLE_PLAN_ID_US: str = config("BUNDLE_PLAN_ID_US", "price_1LwoSDJNcmPzuWtR6wPJZeoh") SUBSCRIPTIONS_WITH_UNLIMITED: list[str] = config( "SUBSCRIPTIONS_WITH_UNLIMITED", default="", cast=Csv() ) SUBSCRIPTIONS_WITH_PHONE: list[str] = config( "SUBSCRIPTIONS_WITH_PHONE", default="", cast=Csv() ) SUBSCRIPTIONS_WITH_VPN: list[str] = config( "SUBSCRIPTIONS_WITH_VPN", default="", cast=Csv() ) MAX_ONBOARDING_AVAILABLE = config("MAX_ONBOARDING_AVAILABLE", 0, cast=int) MAX_ONBOARDING_FREE_AVAILABLE = config("MAX_ONBOARDING_FREE_AVAILABLE", 3, cast=int) MAX_ADDRESS_CREATION_PER_DAY: int = config( "MAX_ADDRESS_CREATION_PER_DAY", 100, cast=int ) MAX_REPLIES_PER_DAY: int = config("MAX_REPLIES_PER_DAY", 100, cast=int) MAX_FORWARDED_PER_DAY: int = config("MAX_FORWARDED_PER_DAY", 1000, cast=int) MAX_FORWARDED_EMAIL_SIZE_PER_DAY: int = config( "MAX_FORWARDED_EMAIL_SIZE_PER_DAY", 1_000_000_000, cast=int ) PREMIUM_FEATURE_PAUSED_DAYS: int = config( "ACCOUNT_PREMIUM_FEATURE_PAUSED_DAYS", 1, cast=int ) SOFT_BOUNCE_ALLOWED_DAYS: int = config("SOFT_BOUNCE_ALLOWED_DAYS", 1, cast=int) HARD_BOUNCE_ALLOWED_DAYS: int = config("HARD_BOUNCE_ALLOWED_DAYS", 30, cast=int) WSGI_APPLICATION = "privaterelay.wsgi.application" # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases DATABASE_URL = config( "DATABASE_URL", default="sqlite:///{}".format(os.path.join(BASE_DIR, "db.sqlite3")) ) DATABASES = {"default": dj_database_url.parse(DATABASE_URL)} # Optionally set a test database name. # This is useful for forcing an on-disk database for SQLite. TEST_DB_NAME = config("TEST_DB_NAME", "") if TEST_DB_NAME: DATABASES["default"]["TEST"] = {"NAME": TEST_DB_NAME} REDIS_URL = config("REDIS_URL", "") REDIS_SELF_SIGNED_CERT = config("REDIS_SELF_SIGNED_CERT", False, bool) if REDIS_URL: _redis_options: dict[str, Any] = { "CLIENT_CLASS": "django_redis.client.DefaultClient" } # Heroku mini uses self-signed certificates if REDIS_SELF_SIGNED_CERT: _redis_options["CONNECTION_POOL_KWARGS"] = { "ssl_cert_reqs": None, "ssl_check_hostname": False, } CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": REDIS_URL, "OPTIONS": _redis_options, } } SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_CACHE_ALIAS = "default" elif RELAY_CHANNEL == "local": CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", } } # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators # only needed when admin UI is enabled if ADMIN_ENABLED: _DJANGO_PWD_VALIDATION = "django.contrib.auth.password_validation" # noqa: E501, S105 (long line, possible password) AUTH_PASSWORD_VALIDATORS = [ {"NAME": _DJANGO_PWD_VALIDATION + ".UserAttributeSimilarityValidator"}, {"NAME": _DJANGO_PWD_VALIDATION + ".MinimumLengthValidator"}, {"NAME": _DJANGO_PWD_VALIDATION + ".CommonPasswordValidator"}, {"NAME": _DJANGO_PWD_VALIDATION + ".NumericPasswordValidator"}, ] # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = "en" # Mozilla l10n directories use lang-locale language codes, # so we need to add those to LANGUAGES so Django's LocaleMiddleware # can find them. LANGUAGES = DEFAULT_LANGUAGES + [ ("zh-tw", "Chinese"), ("zh-cn", "Chinese"), ("es-es", "Spanish"), ("pt-pt", "Portuguese"), ("skr", "Saraiki"), ] TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True STATICFILES_DIRS = [ os.path.join(BASE_DIR, "frontend/out"), ] # Static files (the front-end in /frontend/) # https://whitenoise.evans.io/en/stable/django.html#using-whitenoise-with-webpack-browserify-latest-js-thing STATIC_URL = "/" if DEBUG: # In production, we run collectstatic to index all static files. # However, when running locally, we want to automatically pick up # all files spewed out by `npm run watch` in /frontend/out, # and we're fine with the performance impact of that. WHITENOISE_ROOT = os.path.join(BASE_DIR, "frontend/out") STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { "BACKEND": "privaterelay.storage.RelayStaticFilesStorage", }, } # Relay does not support user-uploaded files MEDIA_ROOT = None MEDIA_URL = None WHITENOISE_INDEX_FILE = True # See # https://whitenoise.evans.io/en/stable/django.html#WHITENOISE_ADD_HEADERS_FUNCTION # Intended to ensure that the homepage does not get cached in our CDN, # so that the `RedirectRootIfLoggedIn` middleware can kick in for logged-in # users. def set_index_cache_control_headers( headers: wsgiref.headers.Headers, path: str, url: str ) -> None: if DEBUG: home_path = os.path.join(BASE_DIR, "frontend/out", "index.html") else: home_path = os.path.join(STATIC_ROOT, "index.html") if path == home_path: headers["Cache-Control"] = "no-cache, public" WHITENOISE_ADD_HEADERS_FUNCTION = set_index_cache_control_headers SITE_ID = 1 AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", "allauth.account.auth_backends.AuthenticationBackend", ) SOCIALACCOUNT_PROVIDERS = { "fxa": { # Note: to request "profile" scope, must be a trusted Mozilla client "SCOPE": ["profile", "https://identity.mozilla.com/account/subscriptions"], "AUTH_PARAMS": {"access_type": "offline"}, "OAUTH_ENDPOINT": config( "FXA_OAUTH_ENDPOINT", "https://oauth.accounts.firefox.com/v1" ), "PROFILE_ENDPOINT": config( "FXA_PROFILE_ENDPOINT", "https://profile.accounts.firefox.com/v1" ), "VERIFIED_EMAIL": True, # Assume FxA primary email is verified } } SOCIALACCOUNT_EMAIL_VERIFICATION = "none" SOCIALACCOUNT_AUTO_SIGNUP = True SOCIALACCOUNT_LOGIN_ON_GET = True SOCIALACCOUNT_STORE_TOKENS = True ACCOUNT_ADAPTER = "privaterelay.allauth.AccountAdapter" ACCOUNT_PRESERVE_USERNAME_CASING = False FXA_REQUESTS_TIMEOUT_SECONDS = config("FXA_REQUESTS_TIMEOUT_SECONDS", 1, cast=int) FXA_SETTINGS_URL = config("FXA_SETTINGS_URL", f"{FXA_BASE_ORIGIN}/settings") FXA_SUBSCRIPTIONS_URL = config( "FXA_SUBSCRIPTIONS_URL", f"{FXA_BASE_ORIGIN}/subscriptions" ) # check https://mozilla.github.io/ecosystem-platform/api#tag/Subscriptions/operation/getOauthMozillasubscriptionsCustomerBillingandsubscriptions # noqa: E501 (line too long) FXA_ACCOUNTS_ENDPOINT = config( "FXA_ACCOUNTS_ENDPOINT", "https://api.accounts.firefox.com/v1", ) FXA_SUPPORT_URL = config("FXA_SUPPORT_URL", f"{FXA_BASE_ORIGIN}/support/") USE_SUBPLAT3 = config("USE_SUBPLAT3", False, cast=bool) SUBPLAT3_HOST = ( "https://payments.firefox.com" if FXA_BASE_ORIGIN == "https://accounts.firefox.com" else "https://payments-next.stage.fxa.nonprod.webservices.mozgcp.net" ) SUBPLAT3_PREMIUM_PRODUCT_KEY = config( "SUBPLAT3_PREMIUM_PRODUCT_KEY", "relay-premium-127", cast=str ) SUBPLAT3_PHONES_PRODUCT_KEY = config( "SUBPLAT3_PHONES_PRODUCT_KEY", "relay-premium-127-phone", cast=str ) SUBPLAT3_BUNDLE_PRODUCT_KEY = config( "SUBPLAT3_BUNDLE_PRODUCT_KEY", "bundle-relay-vpn-dev", cast=str ) LOGGING = { "version": 1, "filters": { "request_id": { "()": "dockerflow.logging.RequestIdLogFilter", }, }, "formatters": { "json": { "()": "dockerflow.logging.JsonLogFormatter", "logger_name": "fx-private-relay", } }, "handlers": { "console_out": { "level": "DEBUG", "class": "logging.StreamHandler", "stream": sys.stdout, "formatter": "json", "filters": ["request_id"], }, "console_err": { "level": "DEBUG", "class": "logging.StreamHandler", "formatter": "json", "filters": ["request_id"], }, }, "loggers": { "root": { "handlers": ["console_err"], "level": "WARNING", }, "request.summary": { "handlers": ["console_out"], "level": "DEBUG", # pytest's caplog fixture requires propagate=True # outside of pytest, use propagate=False to avoid double logs "propagate": IN_PYTEST, }, "events": { "handlers": ["console_err"], "level": "WARNING", "propagate": IN_PYTEST, }, "eventsinfo": { "handlers": ["console_out"], "level": "INFO", "propagate": IN_PYTEST, }, "abusemetrics": { "handlers": ["console_out"], "level": "INFO", "propagate": IN_PYTEST, }, "studymetrics": { "handlers": ["console_out"], "level": "INFO", "propagate": IN_PYTEST, }, "markus": { "handlers": ["console_out"], "level": "DEBUG", "propagate": IN_PYTEST, }, GLEAN_EVENT_MOZLOG_TYPE: { "handlers": ["console_out"], "level": "DEBUG", "propagate": IN_PYTEST, }, "dockerflow": { "handlers": ["console_err"], "level": "WARNING", "propagate": IN_PYTEST, }, }, } DRF_RENDERERS = ["rest_framework.renderers.JSONRenderer"] if DEBUG and not IN_PYTEST: DRF_RENDERERS += [ "rest_framework.renderers.BrowsableAPIRenderer", ] FIRST_EMAIL_RATE_LIMIT = config("FIRST_EMAIL_RATE_LIMIT", "5/minute") if IN_PYTEST or RELAY_CHANNEL in ["local", "dev"]: FIRST_EMAIL_RATE_LIMIT = "1000/minute" REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "api.authentication.FxaTokenAuthentication", "rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], "DEFAULT_RENDERER_CLASSES": DRF_RENDERERS, "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], "EXCEPTION_HANDLER": "api.views.relay_exception_handler", } if API_DOCS_ENABLED: REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema" SPECTACULAR_SETTINGS = { "SWAGGER_UI_DIST": "SIDECAR", "SWAGGER_UI_FAVICON_HREF": "SIDECAR", "REDOC_DIST": "SIDECAR", "TITLE": "Firefox Relay API", "DESCRIPTION": ( "Keep your email safe from hackers and trackers. This API is built with" " Django REST Framework and powers the Relay website UI, add-on," " Firefox browser, and 3rd-party app integrations." ), "VERSION": "1.0", "SERVE_INCLUDE_SCHEMA": False, "PREPROCESSING_HOOKS": ["api.schema.preprocess_ignore_deprecated_paths"], "SORT_OPERATIONS": "api.schema.sort_by_tag", } if IN_PYTEST or RELAY_CHANNEL in ["local", "dev"]: _DEFAULT_PHONE_RATE_LIMIT = "1000/minute" else: _DEFAULT_PHONE_RATE_LIMIT = "5/minute" PHONE_RATE_LIMIT = config("PHONE_RATE_LIMIT", _DEFAULT_PHONE_RATE_LIMIT) # Turn on logging out on GET in development. # This allows `/mock/logout/` in the front-end to clear the # session cookie. Without this, after switching accounts in dev mode, # then logging out again, API requests continue succeeding even without # an auth token: ACCOUNT_LOGOUT_ON_GET = DEBUG # TODO: introduce an environment variable to control CORS_ALLOWED_ORIGINS # https://mozilla-hub.atlassian.net/browse/MPP-3468 CORS_URLS_REGEX = r"^/api/" CORS_ALLOWED_ORIGINS = [ "https://vault.bitwarden.com", "https://vault.bitwarden.eu", ] if RELAY_CHANNEL in ["dev", "stage"]: CORS_ALLOWED_ORIGINS += [ "https://vault.qa.bitwarden.pw", "https://vault.euqa.bitwarden.pw", ] # Allow origins for each environment to help debug cors headers if RELAY_CHANNEL == "local": # In local dev, next runs on localhost and makes requests to /accounts/ CORS_ALLOWED_ORIGINS += [ "http://localhost:3000", "http://0.0.0.0:3000", "http://127.0.0.1:8000", ] CORS_URLS_REGEX = r"^/(api|accounts)/" if RELAY_CHANNEL == "dev": CORS_ALLOWED_ORIGINS += [ "https://dev.fxprivaterelay.nonprod.cloudops.mozgcp.net", ] if RELAY_CHANNEL == "stage": CORS_ALLOWED_ORIGINS += [ "https://stage.fxprivaterelay.nonprod.cloudops.mozgcp.net", ] CSRF_TRUSTED_ORIGINS = [] if RELAY_CHANNEL == "local": # In local development, the React UI can be served up from a different server # that needs to be allowed to make requests. # In production, the frontend is served by Django, is therefore on the same # origin and thus has access to the same cookies. CORS_ALLOW_CREDENTIALS = True SESSION_COOKIE_SAMESITE = None CSRF_TRUSTED_ORIGINS += [ "http://localhost:3000", "http://0.0.0.0:3000", ] SENTRY_RELEASE = config("SENTRY_RELEASE", "") CIRCLE_SHA1 = config("CIRCLE_SHA1", "") CIRCLE_TAG = config("CIRCLE_TAG", "") CIRCLE_BRANCH = config("CIRCLE_BRANCH", "") sentry_release: str | None = None if SENTRY_RELEASE: sentry_release = SENTRY_RELEASE elif CIRCLE_TAG and CIRCLE_TAG != "unknown": sentry_release = CIRCLE_TAG elif ( CIRCLE_SHA1 and CIRCLE_SHA1 != "unknown" and CIRCLE_BRANCH and CIRCLE_BRANCH != "unknown" ): sentry_release = f"{CIRCLE_BRANCH}:{CIRCLE_SHA1}" SENTRY_DEBUG = config("SENTRY_DEBUG", DEBUG, cast=bool) SENTRY_ENVIRONMENT = config("SENTRY_ENVIRONMENT", RELAY_CHANNEL) # Use "local" as default rather than "prod", to catch ngrok.io URLs if SENTRY_ENVIRONMENT == "prod" and SITE_ORIGIN != "https://relay.firefox.com": SENTRY_ENVIRONMENT = "local" sentry_sdk.init( dsn=config("SENTRY_DSN", None), integrations=[DjangoIntegration(cache_spans=not DEBUG)], debug=SENTRY_DEBUG, include_local_variables=DEBUG, release=sentry_release, environment=SENTRY_ENVIRONMENT, ) # Duplicates events for unhandled exceptions, but without useful tracebacks ignore_logger("request.summary") # Security scanner attempts, no action required # Can be re-enabled when hostname allow list implemented at the load balancer ignore_logger("django.security.DisallowedHost") # Fluent errors, mostly when a translation is unavailable for the locale. # It is more effective to process these from logs using BigQuery than to track # as events in Sentry. ignore_logger("django_ftl.message_errors") # Security scanner attempts on Heroku dev, no action required if RELAY_CHANNEL == "dev": ignore_logger("django.security.SuspiciousFileOperation") if USE_SILK: SILKY_PYTHON_PROFILER = True SILKY_PYTHON_PROFILER_BINARY = True SILKY_PYTHON_PROFILER_RESULT_PATH = ".silk-profiler" # Settings for manage.py process_emails_from_sqs PROCESS_EMAIL_BATCH_SIZE = config( "PROCESS_EMAIL_BATCH_SIZE", 10, cast=Choices(range(1, 11), cast=int) ) PROCESS_EMAIL_DELETE_FAILED_MESSAGES = config( "PROCESS_EMAIL_DELETE_FAILED_MESSAGES", False, cast=bool ) PROCESS_EMAIL_HEALTHCHECK_PATH = config( "PROCESS_EMAIL_HEALTHCHECK_PATH", os.path.join(TMP_DIR, "healthcheck.json") ) PROCESS_EMAIL_MAX_SECONDS = config("PROCESS_EMAIL_MAX_SECONDS", 0, cast=int) or None PROCESS_EMAIL_VERBOSITY = config( "PROCESS_EMAIL_VERBOSITY", 1, cast=Choices(range(0, 4), cast=int) ) PROCESS_EMAIL_VISIBILITY_SECONDS = config( "PROCESS_EMAIL_VISIBILITY_SECONDS", 120, cast=int ) PROCESS_EMAIL_WAIT_SECONDS = config("PROCESS_EMAIL_WAIT_SECONDS", 5, cast=int) PROCESS_EMAIL_HEALTHCHECK_MAX_AGE = config( "PROCESS_EMAIL_HEALTHCHECK_MAX_AGE", 120, cast=int ) PROCESS_EMAIL_MAX_SECONDS_PER_MESSAGE = config( "PROCESS_EMAIL_MAX_SECONDS_PER_MESSAGE", PROCESS_EMAIL_MAX_SECONDS or 120.0, cast=float, ) # Django 3.2 switches default to BigAutoField DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # python-dockerflow settings DOCKERFLOW_VERSION_CALLBACK = "privaterelay.utils.get_version_info" DOCKERFLOW_CHECKS = [ "dockerflow.django.checks.check_database_connected", "dockerflow.django.checks.check_migrations_applied", ] if REDIS_URL: DOCKERFLOW_CHECKS.append("dockerflow.django.checks.check_redis_connected") DOCKERFLOW_REQUEST_ID_HEADER_NAME = config("DOCKERFLOW_REQUEST_ID_HEADER_NAME", None) SILENCED_SYSTEM_CHECKS = sorted( set(config("DJANGO_SILENCED_SYSTEM_CHECKS", default="", cast=Csv())) | { # (models.W040) SQLite does not support indexes with non-key columns. # RelayAddress index idx_ra_created_by_addon uses this for PostgreSQL. "models.W040", } ) # django-ftl settings AUTO_RELOAD_BUNDLES = False # Requires pyinotify # accounts that should not have abuse metrics ALLOWED_ACCOUNTS = ["relay-team+e2e@mozilla.com"] # Patching for django-types django_stubs_ext.monkeypatch()