Repository: paperless-ngx/paperless-ngx

Issue:
[Security] Open Redirect on Login page
### Description

The application currently has a security vulnerability known as an "Open Redirect" on its login page. This issue arises because the application fails to properly check and validate the URLs provided in the next parameter during the login process. As a result, attackers can exploit this by inserting an external URL into the next parameter. When a user logs in, instead of being redirected to an internal page, they are unwittingly sent to a potentially malicious external website. This vulnerability can be used for phishing attacks, where users are redirected to fake websites that steal their personal information, or for spreading malware. It's crucial to address this vulnerability to protect users from being redirected to unsafe and untrusted websites.

### Steps to reproduce

1. In the browser's address bar, modify the URL by adding a next parameter with a value pointing to an external website. For example, change it to /accounts/login/?next=//evil.com
2. Log in using valid credentials.
3. Observe that after a successful login, instead of being directed to an internal page of the application, you are redirected to the external website specified in the next parameter.

### Webserver logs

```bash
n/a
```


### Browser logs

_No response_

### Paperless-ngx version

v 2.5.2

### Host OS

Ubuntu 22.04.3 

### Installation method

Docker - official image

### Browser

_No response_

### Configuration changes

_No response_

### Other

_No response_

### Please confirm the following

- [X] I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
- [X] I have already searched for relevant existing issues and discussions before opening this report.
- [X] I have updated the title field above with a concise description.


Discussion:
Mitigation could be using Django’s is_safe_url Function to  check if the URL is a safe redirection (i.e., it doesn't point to a different host).
Feel free to open a PR
Actually Im thinking this was introduced by #5190, as I cant reproduce it in eg 2.4.3. So the question remains if its an issue in the allauth package.

All we do is

https://github.com/paperless-ngx/paperless-ngx/blob/8d664fad568516369a9e86de4b0f4cbd467aaacb/src/paperless/urls.py#L205

Resolving patch:
diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py
--- a/src/paperless/adapter.py
+++ b/src/paperless/adapter.py
@@ -1,4 +1,5 @@
 from allauth.account.adapter import DefaultAccountAdapter
+from allauth.core import context
 from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
 from django.conf import settings
 from django.urls import reverse
@@ -10,6 +11,21 @@ def is_open_for_signup(self, request):
         # Override with setting, otherwise default to super.
         return getattr(settings, "ACCOUNT_ALLOW_SIGNUPS", allow_signups)
 
+    def is_safe_url(self, url):
+        # see https://github.com/paperless-ngx/paperless-ngx/issues/5780
+        from django.utils.http import url_has_allowed_host_and_scheme
+
+        # get_host already validates the given host, so no need to check it again
+        allowed_hosts = {context.request.get_host()} | set(settings.ALLOWED_HOSTS)
+
+        if "*" in allowed_hosts:
+            # dont allow wildcard to allow urls from any host
+            allowed_hosts.remove("*")
+            allowed_hosts.add(context.request.get_host())
+            return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_hosts)
+
+        return url_has_allowed_host_and_scheme(url, allowed_hosts=allowed_hosts)
+
 
 class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
     def is_open_for_signup(self, request, sociallogin):
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -193,6 +193,7 @@
         RedirectView.as_view(
             url=settings.STATIC_URL + "frontend/en-US/assets/%(path)s",
         ),
+        # TODO: with localization, this is even worse! :/
     ),
     # App logo
     re_path(
@@ -200,7 +201,6 @@
         serve,
         kwargs={"document_root": os.path.join(settings.MEDIA_ROOT, "logo")},
     ),
-    # TODO: with localization, this is even worse! :/
     # login, logout
     path("accounts/", include("allauth.urls")),
     # Root of the Frontend


Test patch:
diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py
--- a/src/paperless/tests/test_adapter.py
+++ b/src/paperless/tests/test_adapter.py
@@ -1,7 +1,12 @@
+from unittest import mock
+
 from allauth.account.adapter import get_adapter
+from allauth.core import context
 from allauth.socialaccount.adapter import get_adapter as get_social_adapter
 from django.conf import settings
+from django.http import HttpRequest
 from django.test import TestCase
+from django.test import override_settings
 from django.urls import reverse
 
 
@@ -17,6 +22,31 @@ def test_is_open_for_signup(self):
         settings.ACCOUNT_ALLOW_SIGNUPS = False
         self.assertFalse(adapter.is_open_for_signup(None))
 
+    def test_is_safe_url(self):
+        request = HttpRequest()
+        request.get_host = mock.Mock(return_value="example.com")
+        with context.request_context(request):
+            adapter = get_adapter()
+            with override_settings(ALLOWED_HOSTS=["*"]):
+
+                # True because request host is same
+                url = "https://example.com"
+                self.assertTrue(adapter.is_safe_url(url))
+
+            url = "https://evil.com"
+            # False despite wildcard because request host is different
+            self.assertFalse(adapter.is_safe_url(url))
+
+            settings.ALLOWED_HOSTS = ["example.com"]
+            url = "https://example.com"
+            # True because request host is same
+            self.assertTrue(adapter.is_safe_url(url))
+
+            settings.ALLOWED_HOSTS = ["*", "example.com"]
+            url = "//evil.com"
+            # False because request host is not in allowed hosts
+            self.assertFalse(adapter.is_safe_url(url))
+
 
 class TestCustomSocialAccountAdapter(TestCase):
     def test_is_open_for_signup(self):