1

I've got a Django app that uses graphene to implement GraphQL and I've got everything setup and working but I now have an error in the console which has popped up suddenly and although it doesn't break anything, at least from as far as what I can tell, it does keep showing up in the console and I'd like to fix it.

I'm quite new to Django so I'm not able to figure out where this is coming from. It looks like it's coming from the channels package.

This is the error in its entirety that happens immediately after the server runs and then again after every request is made.

Django version 3.2.3, using settings 'shuddhi.settings'
Starting ASGI/Channels version 3.0.3 development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
WebSocket HANDSHAKING /graphql/ [172.28.0.1:60078]

Exception inside application: 'NoneType' object has no attribute 'replace'
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/site-packages/channels/staticfiles.py", line 44, in __call__
    return await self.application(scope, receive, send)
  File "/usr/local/lib/python3.8/site-packages/channels/routing.py", line 71, in __call__
    return await application(scope, receive, send)
  File "/usr/local/lib/python3.8/site-packages/channels/security/websocket.py", line 35, in __call__
    if self.valid_origin(parsed_origin):
  File "/usr/local/lib/python3.8/site-packages/channels/security/websocket.py", line 54, in valid_origin
    return self.validate_origin(parsed_origin)
  File "/usr/local/lib/python3.8/site-packages/channels/security/websocket.py", line 73, in validate_origin
    return any(
  File "/usr/local/lib/python3.8/site-packages/channels/security/websocket.py", line 74, in <genexpr>
    pattern == "*" or self.match_allowed_origin(parsed_origin, pattern)
  File "/usr/local/lib/python3.8/site-packages/channels/security/websocket.py", line 98, in match_allowed_origin
    parsed_pattern = urlparse(pattern.lower(), scheme=None)
  File "/usr/local/lib/python3.8/urllib/parse.py", line 376, in urlparse
    splitresult = urlsplit(url, scheme, allow_fragments)
  File "/usr/local/lib/python3.8/urllib/parse.py", line 433, in urlsplit
    scheme = _remove_unsafe_bytes_from_url(scheme)
  File "/usr/local/lib/python3.8/urllib/parse.py", line 422, in _remove_unsafe_bytes_from_url
    url = url.replace(b, "")
AttributeError: 'NoneType' object has no attribute 'replace'

This is my Settings.py file:-

"""
Django settings for main_project project.

Generated by 'django-admin startproject' using Django 3.2.3.

For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
from pathlib import Path
import os
import dj_database_url
from environ import Env              

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# BASE_DIR = Path(__file__).resolve().parent.parent

# This is to import the environment variables in the .env file
env = Env()                      

env.read_env(os.path.join(BASE_DIR, '.env'))  # This reads the environment variables from the .env file

# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/

# # # # # # # # # 
# Loading all environemtn Variables
# # # # # # # # 

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('DJANGO_SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool('DJANGO_DEBUG', default=False)
# Authorized origins
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
# Whether or not requests from other origins are allowed
CORS_ORIGIN_ALLOW_ALL = env.bool('DJANGO_CORS_ORIGIN_ALLOW_ALL')
# Twilio Sendgrid API key
SENDGRID_API_KEY = env('SENDGRID_API_KEY')
# setting default email for sending email through sendgrid
DEFAULT_FROM_EMAIL = env('FROM_EMAIL_ID')

SENDGRID_SANDBOX_MODE_IN_DEBUG= env.bool('SENDGRID_SANDBOX_MODE_IN_DEBUG')
# Application definition


# Sendgrid Mail Settings
EMAIL_HOST = 'smtp.sendgrid.net'
EMAIL_HOST_USER = 'apikey' # this is exactly the value 'apikey'
EMAIL_HOST_PASSWORD = SENDGRID_API_KEY
EMAIL_PORT = 587
EMAIL_USE_TLS = True

INSTALLED_APPS = [
    'corsheaders',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'app_name',
    'graphene_django',
    'graphql_jwt.refresh_token.apps.RefreshTokenConfig',
    'graphql_auth',
    'rest_framework',
    'django_filters',
    'channels'
]

GRAPHENE = {
    'SCHEMA': 'main_project.schema.schema',
    'MIDDLEWARE': [
        'graphql_jwt.middleware.JSONWebTokenMiddleware',
    ],
}

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'whitenoise.middleware.WhiteNoiseMiddleware',    
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'common.utils.UpdateLastActivityMiddleware'
]

AUTHENTICATION_BACKENDS = [
    'graphql_auth.backends.GraphQLAuthBackend',
    'django.contrib.auth.backends.ModelBackend',
]

GRAPHQL_AUTH = {
    "ALLOW_LOGIN_NOT_VERIFIED": False
}

GRAPHQL_JWT = {
    "JWT_ALLOW_ANY_CLASSES": [
        "graphql_auth.mutations.Register",
        "graphql_auth.mutations.VerifyAccount",
        "graphql_auth.mutations.ResendActivationEmail",
        "graphql_auth.mutations.SendPasswordResetEmail",
        "graphql_auth.mutations.PasswordReset",
        "graphql_auth.mutations.ObtainJSONWebToken",
        "graphql_auth.mutations.VerifyToken",
        "graphql_auth.mutations.RefreshToken",
        "graphql_auth.mutations.RevokeToken",
    ],
    'JWT_PAYLOAD_HANDLER': 'common.utils.jwt_payload',
    "JWT_VERIFY_EXPIRATION": True,
    "JWT_LONG_RUNNING_REFRESH_TOKEN": True
}

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

ROOT_URLCONF = 'main_project.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, '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',
            ],
        },
    },
]

WSGI_APPLICATION = 'main_project.wsgi.application'

ASGI_APPLICATION = 'main_project.router.application'


# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases

# DATABASES = {
#     'default': {
#         'ENGINE': 'django.db.backends.sqlite3',
#         'NAME': BASE_DIR / 'db.sqlite3',
#     }
# }

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'main_projectdb',
        'USER': 'main_projectadmin',
        'PASSWORD': 'password',
        'HOST': 'db',
        'PORT': '5432',
    }
}

DATABASE_URL = os.environ.get('DATABASE_URL')
db_from_env = dj_database_url.config(default=DATABASE_URL, conn_max_age=500, ssl_require=True)
DATABASES['default'].update(db_from_env)

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("redis", 6379)],
        },
    },
}


# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_L10N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/



STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

STATIC_URL = '/static/'

STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)

STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# STATICFILES_STORAGE =  'django.contrib.staticfiles.storage.StaticFilesStorage' 

# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, "media")

# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

# This is here because we are using a custom User model
# https://docs.djangoproject.com/en/2.2/topics/auth/customizing/#substituting-a-custom-user-model
AUTH_USER_MODEL = "app_name.User"

urls.py in the main_project folder:-

from django.contrib import admin
from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import GraphQLView
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('', include('app_name.urls')),
    path('admin/', admin.site.urls),
    path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True))),
]

if settings.DEBUG:
    urlpatterns += (
        static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
        )

urls.py in the app:-

from . import views
from django.urls import path
from .views import *

urlpatterns = [
    path('', views.index, name='index'),
]

Router.py file where I've specified stuff needed for subscriptions:-

from base64 import decode
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
from django.urls import path
from .schema import MyGraphqlWsConsumer
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth import get_user_model
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
import jwt
from .settings import SECRET_KEY
# from user.models import Token


@database_sync_to_async
def get_user(token_key):
    try:
        decodedPayload = jwt.decode(
            token_key, key=SECRET_KEY, algorithms=['HS256'])
        user_id = decodedPayload.get('sub')
        User = get_user_model()
        user = User.objects.get(pk=user_id)
        return user
    except Exception as e:
        return AnonymousUser()

# This is to enable authentication via websockets
# Source - https://stackoverflow.com/a/65437244/7981162


class TokenAuthMiddleware(BaseMiddleware):

    def __init__(self, inner):
        self.inner = inner

    async def __call__(self, scope, receive, send):
        query = dict((x.split("=")
                     for x in scope["query_string"].decode().split("&")))
        token_key = query.get("token")
        print('token from subscription request =>', token_key)
        scope["user"] = await get_user(token_key)
        print('user subscribing ', scope["user"])
        scope["session"] = scope["user"] if scope["user"] else None
        return await super().__call__(scope, receive, send)


application = ProtocolTypeRouter(
    {
        "http": get_asgi_application(),
        "websocket": AllowedHostsOriginValidator(TokenAuthMiddleware(
            URLRouter(
                [path("graphql/", MyGraphqlWsConsumer.as_asgi())]
            )
        )),
    }
)

Not sure what else is necessary here, but this is all I can think of. It would be great to know how to troubleshoot and get rid of this exception.

Update:-

As I continue to troubleshoot this, I am discovering that I'm starting to see this after I moved the ALLOWED_HOSTS variable to the env file and if I set ALLOWED_HOSTS = ['*'] in the settings.py file, the error goes away. And it shows up only when the subscription from the UI happens. I definitely want to have the ALLOWED_HOSTS getting the value from the environment variable because it's going to be different for prod and dev and it will need to be set from the env variables.

Right now the .env file has this - DJANGO_ALLOWED_HOSTS=localhost,0.0.0.0 and it results in the ALLOWED_HOSTS being rendered as ['localhost', '0.0.0.0']

Ragav Y
  • 1,662
  • 1
  • 18
  • 32

1 Answers1

4

TlDR - Upgrade the channels package to 3.0.4.

Details:-

I've narrowed down the issue to it being caused by ALLOWED_HOSTS having anything other than ['*']. So if I had a specific list of allowed domains such as ['localhost', '0.0.0.0'], it throws an exception and it actually prevents subscriptions from working.

The culprit is the use of 'AllowedHostsOriginValidator' of the channels package that somehow breaks when using Python 3.8. The issue is documented here

Fix has been added to version 3.0.4 of the channels package. Just upgrade the package it should work just fine.

Ragav Y
  • 1,662
  • 1
  • 18
  • 32