Repository: getmoto/moto

Issue:
S3 copy_object fails for keys with embedded percent encoding
While attempting to use [localstack](https://github.com/localstack/localstack) recently, my team found an issue where copying existing S3 objects to a new bucket and/or key would fail if the source object's key contained some embedded percent encoding (or url encoding). For example, we have a number of keys that use `%2F` within the key name to prevent S3's UIs and APIs from treating forward slash characters within that part of the key specially. These keys can be created using `put_object` and can be retrieved using `get_object`, but when attempting to copy them using `copy_object`, we received a 404 Not Found error when targeting localstack. The same operations work without issue against the real S3 apis.

Looking into the error, the root issue seems to be a bug in moto's handling of the copy operation where the source key is being url decoded multiple times. This results in a copy request for a key like `some/key/with%2Furl%2Fencoding` attempting to search for and copy the key `some/key/with/url/encoding` instead and thus throws a 404 Not Found since that source key does not exist.

## How to Reproduce the Issue

Below is a sample test that reproduces the problem

```
import unittest
import boto3
import pytest
import sure # noqa # pylint: disable=unused-import
from moto import mock_s3


@pytest.mark.parametrize(
    "key", ["key/without/urlencoding", "key/with/some%2Furl%2Fencoding"]
)
@mock_s3
def test_copy_key(key):
    client = boto3.client("s3", region_name="us-east-1")

    src_bucket = "src-bucket"
    dest_bucket = "dest-bucket"
    value = b"value"

    client.create_bucket(Bucket=src_bucket)
    client.put_object(Bucket=src_bucket, Key=key, Body=value)
    src_response = client.get_object(Bucket=src_bucket, Key=key)
    src_response["Body"].read().should.equal(value)

    client.create_bucket(Bucket=dest_bucket)
    client.copy_object(
        CopySource={"Bucket": src_bucket, "Key": key}, Bucket=dest_bucket, Key=key
    )
    dest_response = client.get_object(Bucket=dest_bucket, Key=key)
    dest_response["Body"].read().should.equal(value)
```

## What I expect to happen
The above test to pass for both inputs

## What actually happens

The above test fails for the input with embedded percent / url encodings with the following output

```
 pytest tests/test.py
======================================================================================= test session starts ========================================================================================
platform linux -- Python 3.8.10, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /home/bodnarbm/scratch/moto-test
collected 2 items                                                                                                                                                                                  

tests/test.py .F                                                                                                                                                                             [100%]

============================================================================================= FAILURES =============================================================================================
__________________________________________________________________________ test_copy_key[key/with/some%2Furl%2Fencoding] ___________________________________________________________________________

key = 'key/with/some%2Furl%2Fencoding'

    @pytest.mark.parametrize(
        "key", ["key/without/urlencoding", "key/with/some%2Furl%2Fencoding"]
    )
    @mock_s3
    def test_copy_key(key):
        client = boto3.client("s3", region_name="us-east-1")
    
        src_bucket = "src-bucket"
        dest_bucket = "dest-bucket"
        value = b"value"
    
        client.create_bucket(Bucket=src_bucket)
        client.put_object(Bucket=src_bucket, Key=key, Body=value)
        src_response = client.get_object(Bucket=src_bucket, Key=key)
        src_response["Body"].read().should.equal(value)
    
        client.create_bucket(Bucket=dest_bucket)
>       client.copy_object(
            CopySource={"Bucket": src_bucket, "Key": key}, Bucket=dest_bucket, Key=key
        )

tests/test.py:25: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../.pyenv/versions/moto-test/lib/python3.8/site-packages/botocore/client.py:388: in _api_call
    return self._make_api_call(operation_name, kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <botocore.client.S3 object at 0x7fcb7cc13f40>, operation_name = 'CopyObject'
api_params = {'Bucket': 'dest-bucket', 'CopySource': 'src-bucket/key/with/some%252Furl%252Fencoding', 'Key': 'key/with/some%2Furl%2Fencoding'}

    def _make_api_call(self, operation_name, api_params):
        operation_model = self._service_model.operation_model(operation_name)
        service_name = self._service_model.service_name
        history_recorder.record('API_CALL', {
            'service': service_name,
            'operation': operation_name,
            'params': api_params,
        })
        if operation_model.deprecated:
            logger.debug('Warning: %s.%s() is deprecated',
                         service_name, operation_name)
        request_context = {
            'client_region': self.meta.region_name,
            'client_config': self.meta.config,
            'has_streaming_input': operation_model.has_streaming_input,
            'auth_type': operation_model.auth_type,
        }
        request_dict = self._convert_to_request_dict(
            api_params, operation_model, context=request_context)
    
        service_id = self._service_model.service_id.hyphenize()
        handler, event_response = self.meta.events.emit_until_response(
            'before-call.{service_id}.{operation_name}'.format(
                service_id=service_id,
                operation_name=operation_name),
            model=operation_model, params=request_dict,
            request_signer=self._request_signer, context=request_context)
    
        if event_response is not None:
            http, parsed_response = event_response
        else:
            http, parsed_response = self._make_request(
                operation_model, request_dict, request_context)
    
        self.meta.events.emit(
            'after-call.{service_id}.{operation_name}'.format(
                service_id=service_id,
                operation_name=operation_name),
            http_response=http, parsed=parsed_response,
            model=operation_model, context=request_context
        )
    
        if http.status_code >= 300:
            error_code = parsed_response.get("Error", {}).get("Code")
            error_class = self.exceptions.from_code(error_code)
>           raise error_class(parsed_response, operation_name)
E           botocore.exceptions.ClientError: An error occurred (404) when calling the CopyObject operation: Not Found

../../.pyenv/versions/moto-test/lib/python3.8/site-packages/botocore/client.py:708: ClientError
===================================================================================== short test summary info ======================================================================================
FAILED tests/test.py::test_copy_key[key/with/some%2Furl%2Fencoding] - botocore.exceptions.ClientError: An error occurred (404) when calling the CopyObject operation: Not Found
=================================================================================== 1 failed, 1 passed in 0.49s ====================================================================================
```

## Versions being used

The above was ran in a python 3.8.10 virtual environment with the following installed
```
boto3==1.19.7
moto[s3]==2.2.12
pytest==6.2.5
sure==2.0.0
```


Discussion:

Resolving patch:
diff --git a/moto/s3/models.py b/moto/s3/models.py
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -1638,8 +1638,6 @@ def put_object_retention(self, bucket_name, key_name, version_id, retention):
         key.lock_until = retention[1]
 
     def append_to_key(self, bucket_name, key_name, value):
-        key_name = clean_key_name(key_name)
-
         key = self.get_object(bucket_name, key_name)
         key.append_to_value(value)
         return key
@@ -2014,7 +2012,6 @@ def copy_object(
         acl=None,
         src_version_id=None,
     ):
-        src_key_name = clean_key_name(src_key_name)
         dest_key_name = clean_key_name(dest_key_name)
         dest_bucket = self.get_bucket(dest_bucket_name)
         key = self.get_object(src_bucket_name, src_key_name, version_id=src_version_id)
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -62,7 +62,6 @@
 )
 from .utils import (
     bucket_name_from_url,
-    clean_key_name,
     metadata_from_headers,
     parse_region_from_url,
 )
@@ -1396,14 +1395,14 @@ def _key_response_put(self, request, body, bucket_name, query, key_name, headers
             upload_id = query["uploadId"][0]
             part_number = int(query["partNumber"][0])
             if "x-amz-copy-source" in request.headers:
-                src = unquote(request.headers.get("x-amz-copy-source")).lstrip("/")
-                src_bucket, src_key = src.split("/", 1)
-
-                src_key, src_version_id = (
-                    src_key.split("?versionId=")
-                    if "?versionId=" in src_key
-                    else (src_key, None)
-                )
+                copy_source = request.headers.get("x-amz-copy-source")
+                if isinstance(copy_source, bytes):
+                    copy_source = copy_source.decode("utf-8")
+                copy_source_parsed = urlparse(copy_source)
+                src_bucket, src_key = copy_source_parsed.path.lstrip("/").split("/", 1)
+                src_version_id = parse_qs(copy_source_parsed.query).get(
+                    "versionId", [None]
+                )[0]
                 src_range = request.headers.get("x-amz-copy-source-range", "").split(
                     "bytes="
                 )[-1]
@@ -1513,14 +1512,14 @@ def _key_response_put(self, request, body, bucket_name, query, key_name, headers
             # Copy key
             # you can have a quoted ?version=abc with a version Id, so work on
             # we need to parse the unquoted string first
-            src_key = request.headers.get("x-amz-copy-source")
-            if isinstance(src_key, bytes):
-                src_key = src_key.decode("utf-8")
-            src_key_parsed = urlparse(src_key)
-            src_bucket, src_key = (
-                clean_key_name(src_key_parsed.path).lstrip("/").split("/", 1)
-            )
-            src_version_id = parse_qs(src_key_parsed.query).get("versionId", [None])[0]
+            copy_source = request.headers.get("x-amz-copy-source")
+            if isinstance(copy_source, bytes):
+                copy_source = copy_source.decode("utf-8")
+            copy_source_parsed = urlparse(copy_source)
+            src_bucket, src_key = copy_source_parsed.path.lstrip("/").split("/", 1)
+            src_version_id = parse_qs(copy_source_parsed.query).get(
+                "versionId", [None]
+            )[0]
 
             key = self.backend.get_object(
                 src_bucket, src_key, version_id=src_version_id


Test patch:
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -380,29 +380,47 @@ def test_multipart_upload_with_headers_boto3():
 
 
 # Has boto3 equivalent
+@pytest.mark.parametrize(
+    "original_key_name",
+    [
+        "original-key",
+        "the-unicode-💩-key",
+        "key-with?question-mark",
+        "key-with%2Fembedded%2Furl%2Fencoding",
+    ],
+)
 @mock_s3_deprecated
 @reduced_min_part_size
-def test_multipart_upload_with_copy_key():
+def test_multipart_upload_with_copy_key(original_key_name):
     conn = boto.connect_s3("the_key", "the_secret")
     bucket = conn.create_bucket("foobar")
     key = Key(bucket)
-    key.key = "original-key"
+    key.key = original_key_name
     key.set_contents_from_string("key_value")
 
     multipart = bucket.initiate_multipart_upload("the-key")
     part1 = b"0" * REDUCED_PART_SIZE
     multipart.upload_part_from_file(BytesIO(part1), 1)
-    multipart.copy_part_from_key("foobar", "original-key", 2, 0, 3)
+    multipart.copy_part_from_key("foobar", original_key_name, 2, 0, 3)
     multipart.complete_upload()
     bucket.get_key("the-key").get_contents_as_string().should.equal(part1 + b"key_")
 
 
+@pytest.mark.parametrize(
+    "original_key_name",
+    [
+        "original-key",
+        "the-unicode-💩-key",
+        "key-with?question-mark",
+        "key-with%2Fembedded%2Furl%2Fencoding",
+    ],
+)
 @mock_s3
 @reduced_min_part_size
-def test_multipart_upload_with_copy_key_boto3():
+def test_multipart_upload_with_copy_key_boto3(original_key_name):
     s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
     s3.create_bucket(Bucket="foobar")
-    s3.put_object(Bucket="foobar", Key="original-key", Body="key_value")
+    s3.put_object(Bucket="foobar", Key=original_key_name, Body="key_value")
 
     mpu = s3.create_multipart_upload(Bucket="foobar", Key="the-key")
     part1 = b"0" * REDUCED_PART_SIZE
@@ -416,7 +434,7 @@ def test_multipart_upload_with_copy_key_boto3():
     up2 = s3.upload_part_copy(
         Bucket="foobar",
         Key="the-key",
-        CopySource={"Bucket": "foobar", "Key": "original-key"},
+        CopySource={"Bucket": "foobar", "Key": original_key_name},
         CopySourceRange="0-3",
         PartNumber=2,
         UploadId=mpu["UploadId"],
@@ -884,7 +902,14 @@ def test_copy_key():
 
 
 # Has boto3 equivalent
-@pytest.mark.parametrize("key_name", ["the-unicode-💩-key", "key-with?question-mark"])
+@pytest.mark.parametrize(
+    "key_name",
+    [
+        "the-unicode-💩-key",
+        "key-with?question-mark",
+        "key-with%2Fembedded%2Furl%2Fencoding",
+    ],
+)
 @mock_s3_deprecated
 def test_copy_key_with_special_chars(key_name):
     conn = boto.connect_s3("the_key", "the_secret")
@@ -900,7 +925,13 @@ def test_copy_key_with_special_chars(key_name):
 
 
 @pytest.mark.parametrize(
-    "key_name", ["the-key", "the-unicode-💩-key", "key-with?question-mark"]
+    "key_name",
+    [
+        "the-key",
+        "the-unicode-💩-key",
+        "key-with?question-mark",
+        "key-with%2Fembedded%2Furl%2Fencoding",
+    ],
 )
 @mock_s3
 def test_copy_key_boto3(key_name):