Repository: pulp/pulpcore

Issue:
preauthenticated urls should expire
The `ContentRedirectingContentGuard` is able to emit preauthenticated urls. The should have an expiry.


Discussion:

Resolving patch:
diff --git a/pulpcore/app/models/publication.py b/pulpcore/app/models/publication.py
--- a/pulpcore/app/models/publication.py
+++ b/pulpcore/app/models/publication.py
@@ -1,12 +1,14 @@
 import hashlib
 import os
 import re
+from datetime import timedelta
 from url_normalize import url_normalize
 from urllib.parse import urlparse, urljoin
 
 from aiohttp.web_exceptions import HTTPNotFound
 
 from django.db import IntegrityError, models, transaction
+from django.utils import timezone
 from django_lifecycle import hook, AFTER_UPDATE, BEFORE_DELETE
 
 from .base import MasterModel, BaseModel
@@ -385,6 +387,8 @@ class ContentRedirectContentGuard(ContentGuard, AutoAddObjPermsMixin):
 
     TYPE = "content_redirect"
 
+    EXPIRETIME = timedelta(hours=1)
+
     shared_secret = models.BinaryField(max_length=32, default=_gen_secret)
 
     def permit(self, request):
@@ -392,12 +396,15 @@ def permit(self, request):
         Permit preauthenticated redirects from pulp-api.
         """
         try:
+            expires = request.query["expires"]
+            if int(timezone.now().timestamp()) > int(expires):
+                raise PermissionError("Authentication expired")
             signed_url = request.url
             validate_token = request.query["validate_token"]
             hex_salt, hex_digest = validate_token.split(":", 1)
             salt = bytes.fromhex(hex_salt)
             digest = bytes.fromhex(hex_digest)
-            url = re.sub(r"\?validate_token=.*$", "", str(signed_url))
+            url = re.sub(r"\&validate_token=.*$", "", str(signed_url))
             if not digest == self._get_digest(salt, url):
                 raise PermissionError("Access not authenticated")
         except (KeyError, ValueError):
@@ -410,9 +417,14 @@ def preauthenticate_url(self, url, salt=None):
         if not salt:
             salt = _gen_secret()
         hex_salt = salt.hex()
-        digest = self._get_digest(salt, url).hex()
-        url = url + f"?validate_token={hex_salt}:{digest}"
-        return url
+        expiretime = int((timezone.now() + self.EXPIRETIME).timestamp())
+        if "?" in url:
+            separator = "&"
+        else:
+            separator = "?"
+        url_to_sign = url + separator + f"expires={expiretime}"
+        digest = self._get_digest(salt, url_to_sign).hex()
+        return url_to_sign + f"&validate_token={hex_salt}:{digest}"
 
     def _get_digest(self, salt, url):
         url_parts = urlparse(url_normalize(url))


Test patch:
diff --git a/pulpcore/tests/unit/test_content_guard.py b/pulpcore/tests/unit/test_content_guard.py
--- a/pulpcore/tests/unit/test_content_guard.py
+++ b/pulpcore/tests/unit/test_content_guard.py
@@ -1,69 +1,75 @@
+import pytest
 import re
 
-from unittest import TestCase
 from unittest.mock import Mock
 
 from pulpcore.app.models import ContentRedirectContentGuard
 
 
-class RedirectingContentGuardTestCase(TestCase):
-    """Tests that the redirecting content guard can produce url to the content app."""
+def test_preauthenticate_urls():
+    """Test that the redirecting content guard can produce url to the content app."""
 
-    def test_preauthenticate_urls(self):
-        """Test that the redirecting content guard can produce url to the content app."""
+    original_url = "http://localhost:8080/pulp/content/dist/"
+    content_guard = ContentRedirectContentGuard(name="test")
+    content_guard2 = ContentRedirectContentGuard(name="test2")
+    signed_url = content_guard.preauthenticate_url(original_url)
+    signed_url2 = content_guard2.preauthenticate_url(original_url)
 
-        original_url = "http://localhost:8080/pulp/content/dist/"
-        content_guard = ContentRedirectContentGuard(name="test")
-        content_guard2 = ContentRedirectContentGuard(name="test2")
-        signed_url = content_guard.preauthenticate_url(original_url)
-        signed_url2 = content_guard2.preauthenticate_url(original_url)
+    # analyse signed url
+    pattern = re.compile(
+        r"^(?P<url>.*)\?expires=(?P<expires>\d*)&validate_token=(?P<salt>.*):(?P<digest>.*)$"
+    )
+    url_match = pattern.match(signed_url)
+    assert bool(url_match)
+    assert url_match.group("url") == original_url
+    salt = url_match.group("salt")
+    digest = url_match.group("digest")
+    expires = url_match.group("expires")
 
-        # analyse signed url
-        pattern = re.compile(r"^(?P<url>.*)\?validate_token=(?P<salt>.*):(?P<digest>.*)$")
-        url_match = pattern.match(signed_url)
-        self.assertTrue(bool(url_match))
-        self.assertEqual(url_match.group("url"), original_url)
-        salt = url_match.group("salt")
-        digest = url_match.group("digest")
+    url_match2 = pattern.match(signed_url2)
+    assert bool(url_match2)
+    assert url_match2.group("url") == original_url
+    salt2 = url_match2.group("salt")
+    digest2 = url_match2.group("digest")
 
-        url_match2 = pattern.match(signed_url2)
-        self.assertTrue(bool(url_match2))
-        self.assertEqual(url_match2.group("url"), original_url)
-        salt2 = url_match2.group("salt")
-        digest2 = url_match2.group("digest")
+    request = Mock()
 
-        request = Mock()
+    # Try unsigned url
+    request.url = original_url
+    request.query = {}
+    with pytest.raises(PermissionError):
+        content_guard.permit(request)
 
-        # Try unsigned url
-        request.url = original_url
-        request.query = {}
-        with self.assertRaises(PermissionError):
-            content_guard.permit(request)
+    # Try valid url
+    request.url = signed_url
+    request.query = {"expires": expires, "validate_token": ":".join((salt, digest))}
+    content_guard.permit(request)
 
-        # Try valid url
-        request.url = signed_url
-        request.query = {"validate_token": ":".join((salt, digest))}
-        content_guard.permit(request)
+    # Try changed hostname
+    request.url = signed_url.replace("localhost", "localnest")
+    request.query = {"expires": expires, "validate_token": ":".join((salt, digest))}
+    content_guard.permit(request)
 
-        # Try changed hostname
-        request.url = signed_url.replace("localhost", "localnest")
-        request.query = {"validate_token": ":".join((salt, digest))}
+    # Try changed distribution
+    request.url = signed_url.replace("dist", "publication")
+    request.query = {"expires": expires, "validate_token": ":".join((salt, digest))}
+    with pytest.raises(PermissionError):
         content_guard.permit(request)
 
-        # Try changed distribution
-        request.url = signed_url.replace("dist", "publication")
-        request.query = {"validate_token": ":".join((salt, digest))}
-        with self.assertRaises(PermissionError):
-            content_guard.permit(request)
+    # Try tempered salt
+    request.url = signed_url.replace(salt, salt2)
+    request.query = {"expires": expires, "validate_token": ":".join((salt2, digest))}
+    with pytest.raises(PermissionError):
+        content_guard.permit(request)
 
-        # Try tempered salt
-        request.url = signed_url.replace(salt, salt2)
-        request.query = {"validate_token": ":".join((salt2, digest))}
-        with self.assertRaises(PermissionError):
-            content_guard.permit(request)
+    # Try tempered digest
+    request.url = signed_url.replace(digest, digest2)
+    request.query = {"expires": expires, "validate_token": ":".join((salt, digest2))}
+    with pytest.raises(PermissionError):
+        content_guard.permit(request)
 
-        # Try tempered digest
-        request.url = signed_url.replace(digest, digest2)
-        request.query = {"validate_token": ":".join((salt, digest2))}
-        with self.assertRaises(PermissionError):
-            content_guard.permit(request)
+    # Try tempered expiry
+    request.url = signed_url.replace(digest, digest2)
+    request.query = {"expires": str(int(expires) + 1), "validate_token": ":".join((salt, digest2))}
+    with pytest.raises(PermissionError):
+        content_guard.permit(request)