Repository: minio/minio (language: go)

Issue:
listing object keys containing control-characters not supported without encoding-type=url
## Expected Behavior

1) listobject result set should not return duplicate keys
2) listobject result set should return the key that was uploaded, not a corrupted version of it

## Current Behavior

After uploading object keys containing control-characters:

1) listobjects will return a corrupted (substituted?) version of the key.  The control-char is replaced with a utf8 encoded 'question mark' icon - byte sequence %EF%BF%BD
2) the substitution is not unique, which creates duplicate keys in the listobjects result set
3) the substituted keys in listobject result set cannot be used since they do not match the actual object keys that were uploaded

## Steps to Reproduce (for bugs)

create two unix file w/ a control-char in the name:

	echo > "X^V^AY"    # depends on your shell...  X ctrl-v ctrl-a Y
	echo > "X^V^BY"    # depends on your shell...  X ctrl-v ctrl-b Y

upload those files.
I'm using a data appliance - the HTML for the object put looks like this:
> PUT /atime5/WIN/X%02Y HTTP/1.1<0D> 
> authorization: AWS4-HMAC-SHA256 Credential=a12345678/20220601/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-meta-parsec-oid,Signature=c48378641f18e1ffaac417f080ec331d910c3d65d43dfcc320b3e49155454dd8<0D> 
> content-length: 5<0D> 
> host: 172.22.15.92:9000<0D> 
> user-agent: Parsec Labs, LS Series Enterprise Datamover (5.2.0)<0D> 
> x-amz-content-sha256: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3<0D> 
> x-amz-date: 20220601T205523Z<0D> 

use listobjectsv2 and inspect the results

keys in the result should be (in hex):
	%58%01%59
	%58%02%59

keys in result will actually be:
	%58%EF%BF%BD%59
	%58%EF%BF%BD%59

The keys returned are not correct, and they duplicate one another.
	
## Regression

unknown

## Your Environment

linux - redhat 7.5
minio.RELEASE.2022-05-26T05-48-41Z

I logged into the server and looked at the file name it stored in the file system - it made two files with the correct names.




Discussion / hints:
@jt121dot5 we need to the exact request and response please provide `mc admin trace -v` output when you run the application - from what I can tell right now. 

The returned value is actually the URL encoded form of the key because that is what the client asked for we should see if these control characters are somehow handled properly by the Go's XML parser as there are some known limitations in XML regarding certain characters. 
FWIW here is what I did

```
~ echo "1" > "X^V^AY"
~ echo "1" > "X^V^BY"
~ mc cp "X^V^BY" play/testbucket/
~ mc cp "X^V^AY" play/testbucket/
~ mc ls play/testbucket/
[2022-06-01 17:13:21 PDT]     2B STANDARD X^V^AY
[2022-06-01 17:13:51 PDT]     2B STANDARD X^V^BY
```

No duplicates here. 
The URL encoded form for that control sequences should be 

```
mc: <DEBUG> PUT /testbucket/X%5EV%5EBY HTTP/1.1
```

For `X^V^BY`

From what it looks like the application is doing is

```
PUT /atime5/WIN/X%02Y HTTP/1.1<0D>
```

Something completely wrong.
The ^ symbols represent control characters, not the actual ^ character

echo "1" > "X control+A Y"
echo "1" > "X control+B Y"

The shell I use runs w/ an cmdline editor enabled, and so to type a control char, the control-a and control-b need to be preceded by a control-v .

I'm not using MC .

Please try again using control-characters in the file names.


echo 1 > `printf 'X\\001Y'`     # backquotes around the printf and args
echo 1 > `printf 'X\\002Y'`

will make two files w/ the control-chars in them without any special keyboard effort.

If you run ls - the output will vary based on your environment.
If you run ls | less     then it might look like this:
X^AY
X^BY

where the ^A and the ^B are control chars.

I'll figure out in more detail where the issue is and make a more succinct ticket - closing this one.
> echo 1 > `printf 'X\\001Y'` # backquotes around the printf and args echo 1 > `printf 'X\\002Y'`
> 
> will make two files w/ the control-chars in them without any special keyboard effort.
> 
> If you run ls - the output will vary based on your environment. If you run ls | less then it might look like this: X^AY X^BY
> 
> where the ^A and the ^B are control chars.

Okay let me try those ..
I did try w/ MC last night, and it worked, but it worked because MC requests a special encoding for the listobject results - encoding-type=url

S3 consumers that use the std encoding will receive corrupted results - due to the substitutions that occur.   The keys in the result-set may also no longer be unique, as the same substitution might be applied to multiple keys.

The substitutions leave the consumer unable to access the actual stored keys (since the reported ones don't exist), and the duplicate keys in the result-set make for an invalid reply.

This breaks "S3 Browser" for instance.

AWS returns the special chars as XML encoded special chars, which lets the consumer decode and use the keys.
`
AWS returns:   Contents><Key>X&#x1;Y</Key><LastModified>2022-06-02T15:44:23.000Z</LastModified><ETag>&quot;b026324c6904b2a9cb4b88d6d61c81d1&quot;</ETag><Size>2</Size><StorageClass>STANDARD</StorageClass></Contents>
`

I believe there are two issues -
 * if the encoding of the reply can't be accomplished, that should arguably be an error condition rather than for minio to return incorrect data
 * if AWS can work with a given key, then so should minio.  Our product doesn't get to control what keys folks use - and so we try to test for the edge-cases.
 


> S3 consumers that use the std encoding will receive corrupted results - due to the substitutions that occur. The keys in the result-set may also no longer be unique, as the same substitution might be applied to multiple keys.

STD encodings cannot support control characters in XML @jt121dot5 which is impossible to do Go in XML parser (it's a bit poor). This is why `mc` uses `URL` encoding so do all other AWS SDKs treat `URL` encoding as default. - for example `AWS CLI`

However, I want to know which exact characters lead to this and how to create them. I tries `printf` that didn't work :-). 
> * if AWS can work with a given key, then so should minio.  Our product doesn't get to control what keys folks use - and so we try to test for the edge-cases.

If this is your product you should use URL encoding that will help all situations work properly.
>However, I want to know which exact characters lead to this and how to create them

The web tool here consumes/hides the special chars I type.  I'll attach a text/script file in a few minutes.

>STD encodings cannot support control characters in XML 

They do - but they need to be escaped.  This is what AWS does.  Again - the web tool here is hiding some of the detail in the examples I've typed.

>If this is your product you should use URL encoding that will help all situations work properly.

I'm reluctant to change a commercial product to work around an issue w/ Minio.  It seems better to adjust minio to follow the AWS behavior - that way, other tool sets work properly too.

[New Text Document (14).txt](https://github.com/minio/minio/files/8825998/New.Text.Document.14.txt)

> They do - but they need to be escaped. This is what AWS does. Again - the web tool here is hiding some of the detail in the examples I've typed.
> 

This problem is more so for us because of Go's XML parsing limits. 

> > If this is your product you should use URL encoding that will help all situations work properly.
> 
> I'm reluctant to change a commercial product to work around an issue w/ Minio. It seems better to adjust minio to follow the AWS behavior - that way, other tool sets work properly too.

The reason to change this is beneficial for everyone. URL encoding is simpler for XML and more portable. 
I see from AWS S3 replies 

```
aws s3 ls s3://harshavardhana/special/ --profile s3
2022-06-02 10:18:19          2 XY
2022-06-02 10:18:19          2 XY
2022-06-02 10:18:19          2 XY
```

This is what AWS S3 returns, not sure how you can tell if they are duplicates or not? 
If you pipe the cmd into less, you'll be able to see the control chars more clearly.
However, I just installed aws-cli and it uses boto, which asks for url encoding of the listobjects results - so the test isn't useful.

I did find the golang xml function you were referring to - and yup, it doesn't support sending some chars.

If I were you though, I'd consider making a private version of that routine which would return an error rather than silently corrupting the XML data it's trying to send.  Minio is one of the big dogs w/r/t S3, and so returning an error instead of corrupted data seems like a given.
And then, once you have your own escape function, you could more easily make it more compatible w/ AWS w/r/t XML escaping/encoding.

None of this is a serious problem for me though.  I noticed the deviation in behavior between Minio vs AWS and have been putting effort into this ticket as a courtesy.

Other work calls me away.   If you fix minio, then that's great, otherwise - who puts control chars into file names anyway ...  ;-)

Thanks, -Jeff

Yeah well, the Go's escaping is sort of incomplete here @jt121dot5 the quotes are 

```
<ETag>&#34;b026324c6904b2a9cb4b88d6d61c81d1&#34;</ETag>
```

However 

```
X�Y
```

Control characters are not. 
Okay I found what they are doing - they are basically doing NIO based XML encoding https://github.com/eclipse-ee4j/jaxb-ri/blob/484a01bc0367453fdef3de5cf6b5bde6563bd2a4/jaxb-ri/runtime/impl/src/main/java/org/glassfish/jaxb/runtime/marshaller/NioEscapeHandler.java#L76 

I shall close this out, as URL encoding is the preferred approach for special character objects such as these that cannot be handled in a more portable way. 

Here is my analysis of this.  AWS S3 seems to do some kind of specialized XML extension to XML 1.0 - for the encoding that is not in the published RFC for XML 1.0 parser - this breaks portability guarantees.

AWS approach here is wrong, as this simply breaks the XML unmarshalling as well - when Go XML tries to unmarshal this

I tried with aws-sdk-go, but without URL encoding it doesn't work.
```
./aws 
Failed to list vadmeste, SerializationError: failed to decode REST XML response
	status code: 200, request id: Q1ZGZFFCXAN9RK44
caused by: XML syntax error on line 2: illegal character code U+0001
```

So looks like AWS has broken this and is perhaps not consistent with all their SDKs . 
I understand that you've made your decision, but consider another two-cents' worth of perspective from a guy whose been doing this sort of thing software since the late 70's?

If the server (minio) silently returns corrupted data, then it's a server problem since it's not obvious to the consumer that the data being returned is corrupted/unusable.

If, however, the consumer requests a certain encoding format, and minio does it's best to somehow encode to that, but yet the customer's code then throws an error, it becomes a customer problem.
It might not be a huge problem though, since the customer now knows there is a problem and so can work to correct for it -- by changing their code and requested different encoding, or maybe by just using a different xml decoder.

No worries though - I'll look into workarounds - I'll even pass the info along to the folks who make the commercial s3 browser that I use to manage such things, since it is tripped up by this too.  :-)

-Jeff

> If the server (minio) silently returns corrupted data, then it's a server problem since it's not obvious to the consumer that the data being returned is corrupted/unusable.

Okay let me correct you right here @jt121dot5 MinIO is not sending any corrupted data it is sending what is being marshalled by Go stdlib XML library which is following XML spec 1.0, 

IT simply replaces control character byte sequences with standard common byte sequences

> [Definition:] A character is an atomic unit of text as specified by ISO/IEC 10646 [ISO/IEC 10646]. Legal characters are tab, carriage return, line feed, and the legal graphic characters of Unicode and ISO/IEC 10646. The use of "compatibility characters", as defined in section 6.8 of [Unicode], is discouraged.

Now by the time, we get access to this - its already converted to something that we cannot convert back.

```
                        if !isInCharacterRange(r) || (r == 0xFFFD && width == 1) {
                                esc = escFFFD
                                break
                        }
                        continue
```

What it means is that they are not simply encoded in any proprietary XML format like AWS S3, or any such XML 1.0 extensions that AWS S3 seems to follow.

Just like I demonstrated how this kind of proprietary code fails their own SDK while talking to AWS S3 bucket.

```
./aws 
Failed to list vadmeste, SerializationError: failed to decode REST XML response
	status code: 200, request id: EMK49T9ZJ84HA8DA
caused by: XML syntax error on line 2: illegal character code U+0001
```

We can try to mimic whatever AWS S3 does to the nail or simply say what Go says, avoid bad practices stick to XML 1.0 standards.  Right now the choice is that this is not a big deal to support - but if it comes to that we can fork Go's encoding/xml and extend this.


Fix patch:
diff --git a/buildscripts/unaligned-healing.sh b/buildscripts/unaligned-healing.sh
index 01a8d24824ac7..fc2e5ea4d8d02 100755
--- a/buildscripts/unaligned-healing.sh
+++ b/buildscripts/unaligned-healing.sh
@@ -120,7 +120,7 @@ function start_minio_16drive() {
 	cat "${WORK_DIR}/server1.log"
 	echo "FAILED"
 	mkdir -p inspects
-	(cd inspects; "${WORK_DIR}/mc" admin inspect minio/healing-shard-bucket/unaligned/**)
+	(cd inspects; "${WORK_DIR}/mc" support inspect minio/healing-shard-bucket/unaligned/**)
 
 	"${WORK_DIR}/mc" mb play/inspects
 	"${WORK_DIR}/mc" mirror inspects play/inspects
@@ -140,7 +140,7 @@ function start_minio_16drive() {
 	cat "${WORK_DIR}/server1.log"
 	echo "FAILED"
 	mkdir -p inspects
-	(cd inspects; "${WORK_DIR}/mc" admin inspect minio/healing-shard-bucket/unaligned/**)
+	(cd inspects; "${WORK_DIR}/mc" support inspect minio/healing-shard-bucket/unaligned/**)
 
 	"${WORK_DIR}/mc" mb play/inspects
 	"${WORK_DIR}/mc" mirror inspects play/inspects
diff --git a/cmd/api-headers.go b/cmd/api-headers.go
index 60cd14f14a10c..599b7a0fcc9c5 100644
--- a/cmd/api-headers.go
+++ b/cmd/api-headers.go
@@ -20,7 +20,6 @@ package cmd
 import (
 	"bytes"
 	"encoding/json"
-	"encoding/xml"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -30,6 +29,8 @@ import (
 
 	"github.com/minio/minio/internal/crypto"
 	xhttp "github.com/minio/minio/internal/http"
+	"github.com/minio/minio/internal/logger"
+	xxml "github.com/minio/xxml"
 )
 
 // Returns a hexadecimal representation of time at the
@@ -64,9 +65,13 @@ func setCommonHeaders(w http.ResponseWriter) {
 // Encodes the response headers into XML format.
 func encodeResponse(response interface{}) []byte {
 	var bytesBuffer bytes.Buffer
-	bytesBuffer.WriteString(xml.Header)
-	e := xml.NewEncoder(&bytesBuffer)
-	e.Encode(response)
+	bytesBuffer.WriteString(xxml.Header)
+	buf, err := xxml.Marshal(response)
+	if err != nil {
+		logger.LogIf(GlobalContext, err)
+		return nil
+	}
+	bytesBuffer.Write(buf)
 	return bytesBuffer.Bytes()
 }
 
diff --git a/cmd/api-response.go b/cmd/api-response.go
index f39a0ed3d9edd..2026342f754da 100644
--- a/cmd/api-response.go
+++ b/cmd/api-response.go
@@ -89,7 +89,8 @@ type ListVersionsResponse struct {
 	IsTruncated bool
 
 	CommonPrefixes []CommonPrefix
-	Versions       []ObjectVersion
+	DeleteMarkers  []DeleteMarkerVersion `xml:"DeleteMarker,omitempty"`
+	Versions       []ObjectVersion       `xml:"Version,omitempty"`
 
 	// Encoding type used to encode object keys in the response.
 	EncodingType string `xml:"EncodingType,omitempty"`
@@ -247,50 +248,80 @@ type ObjectVersion struct {
 	Object
 	IsLatest  bool
 	VersionID string `xml:"VersionId"`
+}
+
+// DeleteMarkerVersion container for delete marker metadata
+type DeleteMarkerVersion struct {
+	Key          string
+	LastModified string // time string of format "2006-01-02T15:04:05.000Z"
+
+	// Owner of the object.
+	Owner Owner
 
-	isDeleteMarker bool
+	IsLatest  bool
+	VersionID string `xml:"VersionId"`
 }
 
-// MarshalXML - marshal ObjectVersion
-func (o ObjectVersion) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
-	if o.isDeleteMarker {
-		start.Name.Local = "DeleteMarker"
-	} else {
-		start.Name.Local = "Version"
+// Metadata metadata items implemented to ensure XML marshaling works.
+type Metadata struct {
+	Items []struct {
+		Key   string
+		Value string
 	}
+}
 
-	type objectVersionWrapper ObjectVersion
-	return e.EncodeElement(objectVersionWrapper(o), start)
+// Set add items, duplicate items get replaced.
+func (s *Metadata) Set(k, v string) {
+	for i, item := range s.Items {
+		if item.Key == k {
+			s.Items[i] = struct {
+				Key   string
+				Value string
+			}{
+				Key:   k,
+				Value: v,
+			}
+			return
+		}
+	}
+	s.Items = append(s.Items, struct {
+		Key   string
+		Value string
+	}{
+		Key:   k,
+		Value: v,
+	})
 }
 
-// StringMap is a map[string]string
-type StringMap map[string]string
+type xmlKeyEntry struct {
+	XMLName xml.Name
+	Value   string `xml:",chardata"`
+}
 
 // MarshalXML - StringMap marshals into XML.
-func (s StringMap) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
-	tokens := []xml.Token{start}
-
-	for key, value := range s {
-		t := xml.StartElement{}
-		t.Name = xml.Name{
-			Space: "",
-			Local: key,
-		}
-		tokens = append(tokens, t, xml.CharData(value), xml.EndElement{Name: t.Name})
+func (s *Metadata) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
+	if s == nil {
+		return nil
 	}
 
-	tokens = append(tokens, xml.EndElement{
-		Name: start.Name,
-	})
+	if len(s.Items) == 0 {
+		return nil
+	}
 
-	for _, t := range tokens {
-		if err := e.EncodeToken(t); err != nil {
+	if err := e.EncodeToken(start); err != nil {
+		return err
+	}
+
+	for _, item := range s.Items {
+		if err := e.Encode(xmlKeyEntry{
+			XMLName: xml.Name{Local: item.Key},
+			Value:   item.Value,
+		}); err != nil {
 			return err
 		}
 	}
 
-	// flush to ensure tokens are written
-	return e.Flush()
+	return e.EncodeToken(start.End())
 }
 
 // Object container for object metadata
@@ -307,7 +338,7 @@ type Object struct {
 	StorageClass string
 
 	// UserMetadata user-defined metadata
-	UserMetadata StringMap `xml:"UserMetadata,omitempty"`
+	UserMetadata *Metadata `xml:"UserMetadata,omitempty"`
 }
 
 // CopyObjectResponse container returns ETag and LastModified of the successfully copied object
@@ -438,6 +469,8 @@ func generateListBucketsResponse(buckets []BucketInfo) ListBucketsResponse {
 // generates an ListBucketVersions response for the said bucket with other enumerated options.
 func generateListVersionsResponse(bucket, prefix, marker, versionIDMarker, delimiter, encodingType string, maxKeys int, resp ListObjectVersionsInfo) ListVersionsResponse {
 	versions := make([]ObjectVersion, 0, len(resp.Objects))
+	deleteMarkers := make([]DeleteMarkerVersion, 0, len(resp.Objects))
+
 	owner := Owner{
 		ID:          globalMinioDefaultOwnerID,
 		DisplayName: "minio",
@@ -445,10 +478,25 @@ func generateListVersionsResponse(bucket, prefix, marker, versionIDMarker, delim
 	data := ListVersionsResponse{}
 
 	for _, object := range resp.Objects {
-		content := ObjectVersion{}
 		if object.Name == "" {
 			continue
 		}
+
+		if object.DeleteMarker {
+			deleteMarker := DeleteMarkerVersion{
+				Key:          s3EncodeName(object.Name, encodingType),
+				LastModified: object.ModTime.UTC().Format(iso8601TimeFormat),
+				Owner:        owner,
+				VersionID:    object.VersionID,
+			}
+			if deleteMarker.VersionID == "" {
+				deleteMarker.VersionID = nullVersionID
+			}
+			deleteMarker.IsLatest = object.IsLatest
+			deleteMarkers = append(deleteMarkers, deleteMarker)
+			continue
+		}
+		content := ObjectVersion{}
 		content.Key = s3EncodeName(object.Name, encodingType)
 		content.LastModified = object.ModTime.UTC().Format(iso8601TimeFormat)
 		if object.ETag != "" {
@@ -466,12 +514,12 @@ func generateListVersionsResponse(bucket, prefix, marker, versionIDMarker, delim
 			content.VersionID = nullVersionID
 		}
 		content.IsLatest = object.IsLatest
-		content.isDeleteMarker = object.DeleteMarker
 		versions = append(versions, content)
 	}
 
 	data.Name = bucket
 	data.Versions = versions
+	data.DeleteMarkers = deleteMarkers
 	data.EncodingType = encodingType
 	data.Prefix = s3EncodeName(prefix, encodingType)
 	data.KeyMarker = s3EncodeName(marker, encodingType)
@@ -569,14 +617,14 @@ func generateListObjectsV2Response(bucket, prefix, token, nextToken, startAfter,
 		}
 		content.Owner = owner
 		if metadata {
-			content.UserMetadata = make(StringMap)
+			content.UserMetadata = &Metadata{}
 			switch kind, _ := crypto.IsEncrypted(object.UserDefined); kind {
 			case crypto.S3:
-				content.UserMetadata[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
+				content.UserMetadata.Set(xhttp.AmzServerSideEncryption, xhttp.AmzEncryptionAES)
 			case crypto.S3KMS:
-				content.UserMetadata[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionKMS
+				content.UserMetadata.Set(xhttp.AmzServerSideEncryption, xhttp.AmzEncryptionKMS)
 			case crypto.SSEC:
-				content.UserMetadata[xhttp.AmzServerSideEncryptionCustomerAlgorithm] = xhttp.AmzEncryptionAES
+				content.UserMetadata.Set(xhttp.AmzServerSideEncryptionCustomerAlgorithm, xhttp.AmzEncryptionAES)
 			}
 			for k, v := range CleanMinioInternalMetadataKeys(object.UserDefined) {
 				if strings.HasPrefix(strings.ToLower(k), ReservedMetadataPrefixLower) {
@@ -588,7 +636,7 @@ func generateListObjectsV2Response(bucket, prefix, token, nextToken, startAfter,
 				if equals(k, xhttp.AmzMetaUnencryptedContentLength, xhttp.AmzMetaUnencryptedContentMD5) {
 					continue
 				}
-				content.UserMetadata[k] = v
+				content.UserMetadata.Set(k, v)
 			}
 		}
 		contents = append(contents, content)
diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go
index 610baec3aaa8f..c86ea292aa09a 100644
--- a/cmd/object-handlers.go
+++ b/cmd/object-handlers.go
@@ -20,6 +20,7 @@ package cmd
 import (
 	"context"
 	"encoding/hex"
+	"encoding/xml"
 	"errors"
 	"fmt"
 	"io"
@@ -3066,6 +3067,16 @@ func (api objectAPIHandlers) GetObjectRetentionHandler(w http.ResponseWriter, r
 	})
 }
 
+// ObjectTagSet key value tags
+type ObjectTagSet struct {
+	Tags []tags.Tag `xml:"Tag"`
+}
+
+type objectTagging struct {
+	XMLName xml.Name      `xml:"Tagging"`
+	TagSet  *ObjectTagSet `xml:"TagSet"`
+}
+
 // GetObjectTaggingHandler - GET object tagging
 func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *http.Request) {
 	ctx := newContext(r, w, "GetObjectTagging")
@@ -3103,7 +3114,7 @@ func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *h
 	}
 
 	// Get object tags
-	tags, err := objAPI.GetObjectTags(ctx, bucket, object, opts)
+	ot, err := objAPI.GetObjectTags(ctx, bucket, object, opts)
 	if err != nil {
 		writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
 		return
@@ -3113,7 +3124,20 @@ func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *h
 		w.Header()[xhttp.AmzVersionID] = []string{opts.VersionID}
 	}
 
-	writeSuccessResponseXML(w, encodeResponse(tags))
+	otags := &objectTagging{
+		TagSet: &ObjectTagSet{},
+	}
+
+	var list []tags.Tag
+	for k, v := range ot.ToMap() {
+		list = append(list, tags.Tag{
+			Key:   k,
+			Value: v,
+		})
+	}
+	otags.TagSet.Tags = list
+
+	writeSuccessResponseXML(w, encodeResponse(otags))
 }
 
 // PutObjectTaggingHandler - PUT object tagging
diff --git a/go.mod b/go.mod
index e05c425938cf6..d206384782b1e 100644
--- a/go.mod
+++ b/go.mod
@@ -55,6 +55,7 @@ require (
 	github.com/minio/sha256-simd v1.0.0
 	github.com/minio/simdjson-go v0.4.2
 	github.com/minio/sio v0.3.0
+	github.com/minio/xxml v0.0.3
 	github.com/minio/zipindex v0.2.1
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/nats-io/nats-server/v2 v2.7.4
diff --git a/go.sum b/go.sum
index bee6454f3016c..eb067ec8e53fd 100644
--- a/go.sum
+++ b/go.sum
@@ -645,6 +645,8 @@ github.com/minio/simdjson-go v0.4.2 h1:+5rVznvslY/oLIGOO0M7y2g0D8DnFbFZyF2Nr+KT5
 github.com/minio/simdjson-go v0.4.2/go.mod h1:w6ptSSCcUPtqAOOdDVywJkX+8CyYzCdjrTCir0l6BVg=
 github.com/minio/sio v0.3.0 h1:syEFBewzOMOYVzSTFpp1MqpSZk8rUNbz8VIIc+PNzus=
 github.com/minio/sio v0.3.0/go.mod h1:8b0yPp2avGThviy/+OCJBI6OMpvxoUuiLvE6F1lebhw=
+github.com/minio/xxml v0.0.3 h1:ZIpPQpfyG5uZQnqqC0LZuWtPk/WT8G/qkxvO6jb7zMU=
+github.com/minio/xxml v0.0.3/go.mod h1:wcXErosl6IezQIMEWSK/LYC2VS7LJ1dAkgvuyIN3aH4=
 github.com/minio/zipindex v0.2.1 h1:A37vDQJ7Uyp4RHpQEEpintgiIxg0t3npH2CWjLT//u4=
 github.com/minio/zipindex v0.2.1/go.mod h1:s+b/Qyw9JtSEnYfaM4ASOWNO2xGnXCfzQ+SWAzVkVZc=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=


Test patch:
diff --git a/cmd/server_test.go b/cmd/server_test.go
index dd2ebef8406b3..d79c383a40728 100644
--- a/cmd/server_test.go
+++ b/cmd/server_test.go
@@ -28,6 +28,7 @@ import (
 	"net/http"
 	"net/url"
 	"reflect"
+	"runtime"
 	"strings"
 	"sync"
 	"testing"
@@ -1636,6 +1637,81 @@ func (s *TestSuiteCommon) TestListObjectsHandler(c *check) {
 	}
 }
 
+// TestListObjectsSpecialCharactersHandler - Setting valid parameters to List Objects
+// and then asserting the response with the expected one.
+func (s *TestSuiteCommon) TestListObjectsSpecialCharactersHandler(c *check) {
+	if runtime.GOOS == globalWindowsOSName {
+		c.Skip("skip special character test for windows")
+	}
+
+	// generate a random bucket name.
+	bucketName := getRandomBucketName()
+	// HTTP request to create the bucket.
+	request, err := newTestSignedRequest(http.MethodPut, getMakeBucketURL(s.endPoint, bucketName),
+		0, nil, s.accessKey, s.secretKey, s.signer)
+	c.Assert(err, nil)
+
+	// execute the HTTP request to create bucket.
+	response, err := s.client.Do(request)
+	c.Assert(err, nil)
+	c.Assert(response.StatusCode, http.StatusOK)
+
+	for _, objectName := range []string{"foo bar 1", "foo bar 2", "foo \x01 bar"} {
+		buffer := bytes.NewReader([]byte("Hello World"))
+		request, err = newTestSignedRequest(http.MethodPut, getPutObjectURL(s.endPoint, bucketName, objectName),
+			int64(buffer.Len()), buffer, s.accessKey, s.secretKey, s.signer)
+		c.Assert(err, nil)
+
+		response, err = s.client.Do(request)
+		c.Assert(err, nil)
+		c.Assert(response.StatusCode, http.StatusOK)
+	}
+
+	testCases := []struct {
+		getURL          string
+		expectedStrings []string
+	}{
+		{getListObjectsV1URL(s.endPoint, bucketName, "", "1000", ""), []string{"<Key>foo bar 1</Key>", "<Key>foo bar 2</Key>", "<Key>foo &#x1; bar</Key>"}},
+		{getListObjectsV1URL(s.endPoint, bucketName, "", "1000", "url"), []string{"<Key>foo+bar+1</Key>", "<Key>foo+bar+2</Key>", "<Key>foo+%01+bar</Key>"}},
+		{
+			getListObjectsV2URL(s.endPoint, bucketName, "", "1000", "", ""),
+			[]string{
+				"<Key>foo bar 1</Key>",
+				"<Key>foo bar 2</Key>",
+				"<Key>foo &#x1; bar</Key>",
+				fmt.Sprintf("<Owner><ID>%s</ID><DisplayName>minio</DisplayName></Owner>", globalMinioDefaultOwnerID),
+			},
+		},
+		{
+			getListObjectsV2URL(s.endPoint, bucketName, "", "1000", "true", ""),
+			[]string{
+				"<Key>foo bar 1</Key>",
+				"<Key>foo bar 2</Key>",
+				"<Key>foo &#x1; bar</Key>",
+				fmt.Sprintf("<Owner><ID>%s</ID><DisplayName>minio</DisplayName></Owner>", globalMinioDefaultOwnerID),
+			},
+		},
+		{getListObjectsV2URL(s.endPoint, bucketName, "", "1000", "", "url"), []string{"<Key>foo+bar+1</Key>", "<Key>foo+bar+2</Key>", "<Key>foo+%01+bar</Key>"}},
+	}
+
+	for _, testCase := range testCases {
+		// create listObjectsV1 request with valid parameters
+		request, err = newTestSignedRequest(http.MethodGet, testCase.getURL, 0, nil, s.accessKey, s.secretKey, s.signer)
+		c.Assert(err, nil)
+		// execute the HTTP request.
+		response, err = s.client.Do(request)
+		c.Assert(err, nil)
+		c.Assert(response.StatusCode, http.StatusOK)
+
+		getContent, err := ioutil.ReadAll(response.Body)
+		c.Assert(err, nil)
+
+		for _, expectedStr := range testCase.expectedStrings {
+			c.Assert(strings.Contains(string(getContent), expectedStr), true)
+		}
+	}
+}
+
 // TestListObjectsHandlerErrors - Setting invalid parameters to List Objects
 // and then asserting the error response with the expected one.
 func (s *TestSuiteCommon) TestListObjectsHandlerErrors(c *check) {