Repository: getmoto/moto

Issue:
Handling DeleteObjectsRequest broken after JS SDK update
I'm running some integration tests against a localstack S3 container using AWS SDK v3 for JavaScript.
With the recently published version v3.197.0 of the SDK, i'm getting errors when running a DeleteObjects request.

I compared the requests that are sent by the SDK, and can see the following difference (from the localstack logs):
Before:
```
POST localhost/3ee3499c-208f-43f3-aadd-b667f3b2ea6f?delete=&x-id=DeleteObjects
```
Afterwards:
```
POST localhost/89e28fce-e5f2-427a-ab59-5229b83a15ee/?delete=&x-id=DeleteObjects
```

Stacktrace of the error:
```
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: 2022-10-27T09:35:28.605  INFO --- [   asgi_gw_0] localstack.request.aws     : AWS s3.DeleteObjects => 500 (InternalError); DeleteObjectsRequest({'Bucket': '89e28fce-e5f2-427a-ab59-5229b83a15ee', 'Delete': {'Objects': [{'Key': 'sameDirectory/316b663c-663f-4881-bdd8-73ec94d62260'}, {'Key': 'sameDirectory/77680bae-2e2c-4230-af2c-165680c8057d'}]}, 'MFA': None, 'RequestPayer': None, 'BypassGovernanceRetention': None, 'ExpectedBucketOwner': None, 'ChecksumAlgorithm': None}, headers={'content-type': 'application/xml', 'content-length': '259', 'Expect': '100-continue', 'content-md5': 'SeDvWL0dJ6G0h+yQ476qfg==', 'host': 'localhost', 'x-amz-user-agent': 'aws-sdk-js/3.197.0', 'user-agent': 'aws-sdk-js/3.197.0 os/darwin/22.1.0 lang/js md/nodejs/16.18.0 api/s3/3.197.0', 'amz-sdk-invocation-id': 'bbd8ffc8-d792-4298-b7bd-68deca0c3d07', 'amz-sdk-request': 'attempt=1; max=3', 'x-amz-date': '20221027T093528Z', 'x-amz-content-sha256': 'f52fc7ca91861bfc5dac677a383afa8e05c00d54b61593ddd96502aff8711057', 'authorization': 'AWS4-HMAC-SHA256 Credential=accessKeyId/20221027/eu-central-1/s3/aws4_request, SignedHeaders=amz-sdk-invocation-id;amz-sdk-request;content-length;content-md5;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-user-agent, Signature=62c6fb145a2d09eca53b1083ec3b0b75490ffd7cd2a22d572087784bd0599536', 'Connection': 'keep-alive', 'x-localstack-tgt-api': 's3', 'x-moto-account-id': '000000000000', 'x-localstack-edge': 'http://localhost', 'X-Forwarded-For': '127.0.0.1, localhost'}); InternalError(exception while calling s3.DeleteObjects: Traceback (most recent call last): +4ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/aws/chain.py", line 90, in handle +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: handler(self, self.context, response) +1ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/aws/handlers/service.py", line 122, in __call__ +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: handler(chain, context, response) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/aws/handlers/legacy.py", line 81, in __call__ +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: result = self.forward_request( +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/aws/handlers/legacy.py", line 141, in forward_request +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return do_forward_request( +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/services/edge.py", line 225, in do_forward_request +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: result = do_forward_request_inmem(api, method, path, data, headers, port=port) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/services/edge.py", line 249, in do_forward_request_inmem +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: response = modify_and_forward( +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/services/generic_proxy.py", line 601, in wrapper +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: value = func(*args, **kwargs) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/services/generic_proxy.py", line 774, in modify_and_forward +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: updated_response = listener.return_response( +1ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/services/s3/s3_listener.py", line 1644, in return_response +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: object_path = parts[1] if parts[1][0] == "/" else "/+0ms" % parts[1]
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: IndexError: string index out of range +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: , headers={'Content-Type': 'application/xml', 'Content-Length': '1525', 'x-amz-request-id': 'FF7XCYIT7RZKAU1DEAH2YKXFG4F37LYNDJ07Z942D2LEXHLG1AOJ', 'x-amz-id-2': 'MzRISOwyjmnupFF7XCYIT7RZKAU1DEAH2YKXFG4F37LYNDJ07Z942D2LEXHLG1AOJ7/JypPGXLh0OVFGcJaaO3KW/hRAqKOpIEEp'}) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: 2022-10-27T09:35:28.619 ERROR --- [uest_thread)] werkzeug                   : Error on request: +14ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: Traceback (most recent call last): +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/werkzeug/serving.py", line 335, in run_wsgi +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: execute(self.server.app) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/werkzeug/serving.py", line 322, in execute +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: application_iter = app(environ, start_response) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/moto_server/werkzeug_app.py", line 249, in __call__ +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return backend_app(environ, start_response) +2ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask/app.py", line 2091, in __call__ +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return self.wsgi_app(environ, start_response) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask/app.py", line 2076, in wsgi_app +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: response = self.handle_exception(e) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask_cors/extension.py", line 165, in wrapped_function +1ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return cors_after_request(app.make_response(f(*args, **kwargs))) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask/app.py", line 2073, in wsgi_app +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: response = self.full_dispatch_request() +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask/app.py", line 1519, in full_dispatch_request +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: rv = self.handle_user_exception(e) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask_cors/extension.py", line 165, in wrapped_function +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return cors_after_request(app.make_response(f(*args, **kwargs))) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask/app.py", line 1517, in full_dispatch_request +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: rv = self.dispatch_request() +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/flask/app.py", line 1503, in dispatch_request +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return self.ensure_sync(self.view_functions[rule.endpoint])(**req.view_args) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/utils/patch.py", line 38, in proxy +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return new(target, *args, **kwargs) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/localstack/utils/aws/request_context.py", line 174, in convert_to_flask_response_call +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return fn(*args, **kwargs) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/core/utils.py", line 110, in __call__ +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: result = self.callback(request, request.url, dict(request.headers)) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/s3/responses.py", line 254, in ambiguous_response +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return self.bucket_response(request, full_url, headers) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/utilities/aws_headers.py", line 60, in _wrapper +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: response = f(*args, **kwargs) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/s3/responses.py", line 260, in bucket_response +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: response = self._bucket_response(request, full_url) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/s3/responses.py", line 305, in _bucket_response +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: return self._bucket_response_post(request, bucket_name) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/moto/s3/responses.py", line 983, in _bucket_response_post +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: key = form["key"] +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: File "/opt/code/localstack/.venv/lib/python3.10/site-packages/werkzeug/datastructures.py", line 375, in __getitem__ +1ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: raise exceptions.BadRequestKeyError(key) +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: werkzeug.exceptions.BadRequestKeyError: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. +0ms
  testcontainers:containers TRACE a06d147a600e3a0d915f0ed0923d716f17491e2406353bc3a7829fd26c34b1a2: KeyError: 'key' +0ms
```

What I understand is that the POST request for deletion is not determined as a delete request by the `self.is_delete_keys` method [here](https://github.com/spulec/moto/blob/03f551870325fdce1c789bc43c286486cdddb13d/moto/s3/responses.py#L967), and thus interpreted as a PutObject request afterwards.

From what I can see, the [method for determining the delete request](https://github.com/spulec/moto/blob/03f551870325fdce1c789bc43c286486cdddb13d/moto/s3bucket_path/utils.py#L17) currently does not account for this changed URL, where we now have `/?delete=` instead of `?delete=`.

Could it already be the fix to include `/?delete=` in the check? 


Discussion:
Here's the change on SDK side that introduced this trailing slash, I think:
https://github.com/aws/aws-sdk-js-v3/commit/6e4371eade7c2da3e3a930e1a14a52c88af8ad54#diff-9d7ad385ccde527e58f7e5d7fd519e8a270e02e7c4bc54d55b0662eafa2c71ffR1092
Sounds like that is indeed the fix, @j-beyer. Would you like to submit a PR with this change?
I don't have a lot of experience with Python and it's frameworks/ecosystem, so I don't feel very confident in making (& especially testing) this change 😅
But I can try to dig into it tomorrow if you want me to

Resolving patch:
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -20,7 +20,6 @@
 from moto.s3bucket_path.utils import (
     bucket_name_from_url as bucketpath_bucket_name_from_url,
     parse_key_name as bucketpath_parse_key_name,
-    is_delete_keys as bucketpath_is_delete_keys,
 )
 from moto.utilities.aws_headers import amzn_request_id
 
@@ -142,17 +141,6 @@ def parse_key_name(pth):
     return pth[1:] if pth.startswith("/") else pth
 
 
-def is_delete_keys(request, path):
-    # GOlang sends a request as url/?delete= (treating it as a normal key=value, even if the value is empty)
-    # Python sends a request as url/?delete (treating it as a flag)
-    # https://github.com/spulec/moto/issues/2937
-    return (
-        path == "/?delete"
-        or path == "/?delete="
-        or (path == "/" and getattr(request, "query_string", "") == "delete")
-    )
-
-
 class S3Response(BaseResponse):
     def __init__(self):
         super().__init__(service_name="s3")
@@ -226,11 +214,9 @@ def subdomain_based_buckets(self, request):
         )
         return not path_based
 
-    def is_delete_keys(self, request, path, bucket_name):
-        if self.subdomain_based_buckets(request):
-            return is_delete_keys(request, path)
-        else:
-            return bucketpath_is_delete_keys(request, path, bucket_name)
+    def is_delete_keys(self):
+        qs = parse_qs(urlparse(self.path).query, keep_blank_values=True)
+        return "delete" in qs
 
     def parse_bucket_name_from_url(self, request, url):
         if self.subdomain_based_buckets(request):
@@ -964,7 +950,7 @@ def _bucket_response_post(self, request, bucket_name):
 
         self.path = self._get_path(request)
 
-        if self.is_delete_keys(request, self.path, bucket_name):
+        if self.is_delete_keys():
             self.data["Action"] = "DeleteObject"
             try:
                 self._authenticate_and_authorize_s3_action()
diff --git a/moto/s3bucket_path/utils.py b/moto/s3bucket_path/utils.py
--- a/moto/s3bucket_path/utils.py
+++ b/moto/s3bucket_path/utils.py
@@ -12,15 +12,3 @@ def bucket_name_from_url(url):
 
 def parse_key_name(path):
     return "/".join(path.split("/")[2:])
-
-
-def is_delete_keys(request, path, bucket_name):
-    return (
-        path == "/" + bucket_name + "/?delete"
-        or path == "/" + bucket_name + "?delete"
-        or path == "/" + bucket_name + "?delete="
-        or (
-            path == "/" + bucket_name
-            and getattr(request, "query_string", "") == "delete"
-        )
-    )


Test patch: