Skip to content

Commit

Permalink
Merge pull request #276 from uploadcare/bugfix/275-akamai_escape_spec…
Browse files Browse the repository at this point in the history
…ial_chars

Revision of the secure_url module
  • Loading branch information
evgkirov authored Dec 24, 2023
2 parents 9128375 + 01f2f60 commit a897f59
Show file tree
Hide file tree
Showing 16 changed files with 1,106 additions and 59 deletions.
12 changes: 11 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.0.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.2.3](https://github.com/uploadcare/pyuploadcare/compare/v4.2.2...v4.2.3) - unreleased
## [4.3.0](https://github.com/uploadcare/pyuploadcare/compare/v4.2.2...v4.3.0) - 2023-12-24

### Fixed

- For `AkamaiSecureUrlBuilderWithAclToken` and `AkamaiSecureUrlBuilderWithUrlToken`:
- Special characters that were not previously escaped are now properly handled, as detailed in issue [#275](https://github.com/uploadcare/pyuploadcare/issues/275).

### Changed

- For `AkamaiSecureUrlBuilderWithAclToken` and `AkamaiSecureUrlBuilderWithUrlToken`:
- Both classes have been made more consistent and now accept a full URL, URL path, or just the UUID of a file – whichever is more convenient for you.

### Deprecated

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "pyuploadcare"
version = "4.2.2"
version = "4.3.0"
description = "Python library for Uploadcare.com"
authors = ["Uploadcare Inc <[email protected]>"]
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion pyuploadcare/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# isort: skip_file
__version__ = "4.2.2"
__version__ = "4.2.3"

from pyuploadcare.resources.file import File # noqa: F401
from pyuploadcare.resources.file_group import FileGroup # noqa: F401
Expand Down
26 changes: 16 additions & 10 deletions pyuploadcare/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,25 +826,31 @@ def generate_upload_signature(self) -> Tuple[int, str]:
return expire, signature

def generate_secure_url(
self, uuid: Union[str, UUID], wildcard: bool = False
self, handle: Union[str, UUID], wildcard: bool = False
) -> str:
"""Generate authenticated URL."""
if isinstance(uuid, UUID):
uuid = str(uuid)
"""
Generate authenticated URL.
:param handle: Can be one of the following: UUID, UUID with transformations, full URL.
"""
if isinstance(handle, UUID):
handle = str(handle)

if not self.secure_url_builder:
raise ValueError("secure_url_builder must be set")

return self.secure_url_builder.build(uuid, wildcard=wildcard)
return self.secure_url_builder.build(handle, wildcard=wildcard)

def generate_secure_url_token(
self, uuid: Union[str, UUID], wildcard: bool = False
self, handle: Union[str, UUID], wildcard: bool = False
) -> str:
"""Generate token for authenticated URL."""
if isinstance(uuid, UUID):
uuid = str(uuid)
"""
Generate token for authenticated URL.
:param handle: Can be one of the following: UUID, UUID with transformations, full URL.
"""
if isinstance(handle, UUID):
handle = str(handle)

if not self.secure_url_builder:
raise ValueError("secure_url_builder must be set")

return self.secure_url_builder.get_token(uuid, wildcard=wildcard)
return self.secure_url_builder.get_token(handle, wildcard=wildcard)
119 changes: 75 additions & 44 deletions pyuploadcare/secure_url.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import binascii
import hashlib
import hmac
import re
import time
import warnings
from abc import ABC, abstractmethod
from typing import Optional
from urllib.parse import quote_plus, urlparse


class BaseSecureUrlBuilder(ABC):
@abstractmethod
def build(self, uuid: str, wildcard: bool = False) -> str:
def build(self, handle: str, wildcard: bool = False) -> str:
"""
:param handle: Can be one of the following: UUID, UUID with transformations, full URL.
"""
raise NotImplementedError

def get_token(self, uuid: str, wildcard: bool = False) -> str:
def get_token(self, handle: str, wildcard: bool = False) -> str:
"""
:param handle: Can be one of the following: UUID, UUID with transformations, full URL.
"""
raise NotImplementedError(
f"{self.__class__} doesn't provide get_token()"
)
Expand All @@ -25,6 +33,7 @@ class BaseAkamaiSecureUrlBuilder(BaseSecureUrlBuilder):
for more details.
"""

base_template = "https://{cdn}/{path}/"
template = "{base}?token={token}"
field_delimeter = "~"

Expand All @@ -40,30 +49,46 @@ def __init__(
self.window = window
self.hash_algo = hash_algo

def build(self, uuid: str, wildcard: bool = False) -> str:
uuid_or_url = self._format_uuid_or_url(uuid)
token = self.get_token(uuid_or_url, wildcard=wildcard)
secure_url = self._build_url(uuid_or_url, token)
def build(self, handle: str, wildcard: bool = False) -> str:
token = self.get_token(handle, wildcard=wildcard)
secure_url = self._build_url(handle, token)
return secure_url

def get_token(self, uuid: str, wildcard: bool = False) -> str:
uuid_or_url = self._format_uuid_or_url(uuid)
def get_token(self, handle: str, wildcard: bool = False) -> str:
path = self._get_path(handle)
expire = self._build_expire_time()
acl = self._format_acl(uuid_or_url, wildcard=wildcard)
signature = self._build_signature(uuid_or_url, expire, acl)
acl = self._format_acl(path, wildcard=wildcard)
signature = self._build_signature(handle, expire, acl)
token = self._build_token(expire, acl, signature)
return token

def _prepare_path_for_url(self, path: str) -> str:
path = re.sub(
r"(%..)",
lambda match: match.group(1).lower(),
quote_plus(path, safe=","),
)
path = self._prepare_path_for_acl(path)
return path

def _prepare_path_for_acl(self, path: str) -> str:
for escape_char in "~":
path = path.replace(
escape_char, "%" + hex(ord(escape_char)).lower()[2:]
)
return path

def _build_expire_time(self) -> int:
return int(time.time()) + self.window

def _build_signature(
self, uuid_or_url: str, expire: int, acl: Optional[str]
self, handle: str, expire: int, acl: Optional[str]
) -> str:

path = self._get_path(handle)
path = self._prepare_path_for_url(path)
hash_source = [
f"exp={expire}",
f"acl={acl}" if acl else f"url={uuid_or_url}",
f"acl={acl}" if acl else f"url={path}",
]

signature = hmac.new(
Expand All @@ -75,7 +100,6 @@ def _build_signature(
return signature

def _build_token(self, expire: int, acl: Optional[str], signature: str):

token_parts = [
f"exp={expire}",
f"acl={acl}" if acl else None,
Expand All @@ -86,61 +110,68 @@ def _build_token(self, expire: int, acl: Optional[str], signature: str):
part for part in token_parts if part is not None
)

@abstractmethod
def _build_base_url(self, uuid_or_url: str):
raise NotImplementedError

def _build_url(
self,
uuid_or_url: str,
handle: str,
token: str,
) -> str:
base_url = self._build_base_url(uuid_or_url)
base_url = self._build_base_url(handle)
return self.template.format(
base=base_url,
token=token,
)

@abstractmethod
def _format_acl(self, uuid_or_url: str, wildcard: bool) -> Optional[str]:
raise NotImplementedError
def _get_path(self, handle: str) -> str:
"""
>>> builder._get_path("fake-uuid")
/fake-uuid/
>>> builder._get_path("https://sectest.ucarecdn.com/fake-uuid/-/resize/20x20/")
/fake-uuid/-/resize/20x20/
"""
path = handle
parsed = urlparse(path)
if parsed.netloc:
# extract uuid with transformations from url
path = parsed.path
if not path.startswith("/"):
path = f"/{path}"
return path

def _build_base_url(self, handle: str):
"""
>>> builder._build_base_url("fake-uuid")
https://sectest.ucarecdn.com/fake-uuid/
>>> builder._get_path("https://sectest.ucarecdn.com/fake-uuid/-/resize/20x20/")
https://sectest.ucarecdn.com/fake-uuid/-/resize/20x20/
"""
path = self._get_path(handle)
path = path.lstrip("/").rstrip("/")
base_url = self.base_template.format(cdn=self.cdn_url, path=path)
return base_url

@abstractmethod
def _format_uuid_or_url(self, uuid_or_url: str) -> str:
def _format_acl(self, handle: str, wildcard: bool) -> Optional[str]:
raise NotImplementedError


class AkamaiSecureUrlBuilderWithAclToken(BaseAkamaiSecureUrlBuilder):
base_template = "https://{cdn}/{uuid}/"

def _build_base_url(self, uuid_or_url: str):
return self.base_template.format(cdn=self.cdn_url, uuid=uuid_or_url)

def _format_acl(self, uuid_or_url: str, wildcard: bool) -> str:
def _format_acl(self, handle: str, wildcard: bool) -> str:
path = self._get_path(handle)
path = path.lstrip("/").rstrip("/")
path = self._prepare_path_for_acl(path)
if wildcard:
return f"/{uuid_or_url}/*"
return f"/{uuid_or_url}/"

def _format_uuid_or_url(self, uuid_or_url: str) -> str:
return uuid_or_url.lstrip("/").rstrip("/")
return f"/{path}/*"
return f"/{path}/"


class AkamaiSecureUrlBuilderWithUrlToken(BaseAkamaiSecureUrlBuilder):
def _build_base_url(self, uuid_or_url: str):
return uuid_or_url

def _format_acl(self, uuid_or_url: str, wildcard: bool) -> None:
def _format_acl(self, handle: str, wildcard: bool) -> None:
if wildcard:
raise ValueError(
"Wildcards are not supported in AkamaiSecureUrlBuilderWithUrlToken."
)
return None

def _format_uuid_or_url(self, uuid_or_url: str) -> str:
if "://" not in uuid_or_url:
raise ValueError(f"{uuid_or_url} doesn't look like a URL")
return uuid_or_url


class AkamaiSecureUrlBuilder(AkamaiSecureUrlBuilderWithAclToken):
def __init__(
Expand Down
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import sys
from io import BytesIO
from tempfile import TemporaryDirectory
from typing import Tuple
Expand All @@ -9,6 +10,23 @@
from pyuploadcare.client import Uploadcare


# XXX workaround for vcrpy incompability with Python 3.12
# https://github.com/mgorny/vcrpy/commit/5f2623886dda2fd8bb6035370251fe07cec958ca
# Remove this when an updated version of vcrpy is released.
if sys.version_info >= (3, 12):
from vcr.stubs import (
HTTPConnection,
HTTPSConnection,
VCRHTTPConnection,
VCRHTTPSConnection,
)

VCRHTTPConnection.debuglevel = HTTPConnection.debuglevel
VCRHTTPConnection._http_vsn = HTTPConnection._http_vsn
VCRHTTPSConnection.debuglevel = HTTPSConnection.debuglevel
VCRHTTPSConnection._http_vsn = HTTPSConnection._http_vsn


@pytest.fixture()
def temp_directory():
return TemporaryDirectory()
Expand Down
48 changes: 48 additions & 0 deletions tests/functional/cassettes/test_acl_token_bare_uuid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
interactions:
- request:
body: null
headers:
Connection:
- close
Host:
- sectest.ucarecdn.com
User-Agent:
- Python-urllib/3.11
method: GET
uri: https://sectest.ucarecdn.com/1bd27101-6f40-460a-9358-d44c282e9d16/?token=exp=2015971200~acl=/1bd27101-6f40-460a-9358-d44c282e9d16/~hmac=0e466b666443108e92a8b8255b95f7473073c8ea14aa074de5647d092c683e1f
response:
body:
string: 'hello
'
headers:
Access-Control-Allow-Methods:
- HEAD, GET, OPTIONS
Access-Control-Allow-Origin:
- '*'
Cache-Control:
- public, max-age=31309321
Connection:
- close
Content-Disposition:
- inline; filename=hello.txt
Content-Length:
- '6'
Content-Type:
- text/plain
Date:
- Fri, 24 Nov 2023 21:55:28 GMT
ETag:
- '"b1946ac92492d2347c6235b4d2611184"'
Last-Modified:
- Wed, 22 Nov 2023 00:53:42 GMT
Server:
- Uploadcare
Vary:
- Accept-Encoding
X-Robots-Tag:
- noindex, nofollow, nosnippet, noarchive
status:
code: 200
message: OK
version: 1
Loading

0 comments on commit a897f59

Please sign in to comment.