ProgrammingLlama
ProgrammingLlama

Reputation: 38850

Google Storage signed URL expiration results in 400 error with no CORS headers

Long story short: request to expired signed PUT URL response does not include the CORS headers that a successful upload to a non-expired signed PUT URL does.

We operate a service that allows users to upload videos. Our backend API produces a signed Google Storage URLs that the browser-based client application can upload chunks to.

Unfortunately, on slower connections, it's possible for these URLs to expire before they are used. When we use Azure Blob Storage or Amazon S3 as a storage medium, our retry mechanism will detect the failure (403), request a new URL for that particular chunk, and then continue with the upload.

On Google Storage this doesn't work, for one simple reason: while OPTIONS returns successfully, and successful PUT requests include the access-control-allow-origin header, failing uploads (those receiving the 400 status code) do not, and we get the following error logged in the console:

Access to XMLHttpRequest at 'https://storage.googleapis.com/ourbucket/uploads%2F20190308%2F5c822a942d1bd1000183a4a6%2Flawbarnd.mp4_c6b701ce-1687-4fb5-a453-61875c1b6d9a__000007?GoogleAccessId=user@project.iam.gserviceaccount.com&Expires=1552034512&Signature=L6pvUQX5UEa7GESO%2Bj12yR8%2FXln3tz1SDUA%2Bkf1NNx9eTvmUxTdgROYo30p4s%2FGGhXYwr%2BUdgnDuZ66pjX7YS0N5PO5BIr6LULtpR6i2xNC8Y2sKmpv5QF66FHqSBWK0YoLc%2B21MnJMPRgUBSXMcoyWJCJ%2FAapVgRe9QH%2BQt86agf6h0yEmHv48qgVJpzRH%2FbiNJKD7oiOyJc%2Fcon2y2hqsCo6x8buZVuPzTZg6ddHqmqKkscjABoT7bq1%2Bz7Sqkq3Vul%2B5XQfw3CvoNjELpuqVQA%2F0v0RXE86JkOnXf2kQKKlL%2Fq9AwidsEMF05n1LlBVRKSdv8qNKTCVFwBOU%2BMg%3D%3D' from origin 'https://www.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Our bucket CORS configuration is as follows:

[
    {
        "maxAgeSeconds": 3600,
        "method": ["*"],
        "origin": ["https://www.example.com"],
        "responseHeader": ["*"]
    }
]

Our client-side upload code:

this.bytesUploaded = 0;
this.xhr = new XMLHttpRequest();
this.xhr.open(method, url, true);

let keys = _.keys(headers);
if (keys !== null && keys !== undefined && keys.length > 0) {
    for (let i = 0; i < keys.length; ++i) {
        this.xhr.setRequestHeader(keys[i], headers[keys[i]]);
    }
}

this.xhr.upload.addEventListener('progress', this.onProgress);
this.xhr.addEventListener('load', this.onLoad);
this.xhr.addEventListener('error', this.onError);
this.xhr.addEventListener('abort', this.onAbort);

this.xhr.setRequestHeader('Content-Type', ' '); // we unset this because it interferes with signed URLs. This works fine.

this.xhr.send(this.data);

Response headers from a successful request (via Fiddler):

HTTP/1.1 200 OK
X-GUploader-UploadID: AEnB2UrZdsd-DAl0VdYOtKGVD_4AJLf6qeukybq0jBSv5HI5M4fRTqFVnoxko5LJBMttKYz8ExXG1c3BeASH4IuO8iKfCBb-iw
ETag: "87a742c72cc29950f03e5dd86dc95cf4"
x-goog-generation: 1552036072503518
x-goog-metageneration: 1
x-goog-hash: crc32c=u2rTqA==
x-goog-hash: md5=h6dCxyzCmVDwPl3Ybclc9A==
x-goog-stored-content-length: 239170
x-goog-stored-content-encoding: identity
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Expose-Headers: *, Content-Length, Content-Type, Date, Server, Transfer-Encoding, X-GUploader-UploadID, X-Google-Trace
Vary: Origin
Content-Length: 0
Date: Fri, 08 Mar 2019 09:07:52 GMT
Server: UploadServer
Content-Type: text/html; charset=UTF-8
Alt-Svc: quic=":443"; ma=2592000; v="46,44,43,39"

Response from a failing request (via Fiddler):

HTTP/1.1 400 Bad Request
X-GUploader-UploadID: AEnB2UpjxOthcO6AZgBgw_P8Msw1zeZFkEqMhEWF5pV9jPORajlBnizndw48WSBtW_Ft9G7NOHu_HWxjgywpG7dqhZ0QUz8znA
Content-Type: application/xml; charset=UTF-8
Content-Length: 202
Date: Fri, 08 Mar 2019 09:01:39 GMT
Server: UploadServer
Alt-Svc: quic=":443"; ma=2592000; v="46,44,43,39"

<?xml version='1.0' encoding='UTF-8'?><Error><Code>ExpiredToken</Code><Message>The provided token has expired.</Message><Details>Request signature expired at: 2019-03-08T09:01:24+00:00</Details></Error>

Is there a configuration option we're missing on the bucket? Is there a way we can ignore CORS in case of failure in order to extract the failing status code? At the moment we only receive -1 which isn't helpful.

Edit in response to Yasser Karout's question:

The browser, to complete the XHR PUT request, first makes a pre-flight OPTIONS call:

OPTIONS https://storage.googleapis.com/example-com-media/uploads%2F20190404%2F5ca556b3d72f640001981487%2Fu1equspb.mp4_2c69f05f-0cb7-4c08-bd20-3858b06f6d51__000005?GoogleAccessId=example-com-media@stalwart-kite-714.iam.gserviceaccount.com&Expires=1554339568&Signature=IWjzT0D3Vxzw96JSTwqclhlJWZ%2B%2FBHYviL9SPnZCT3c5P2%2FSqJaq0Grxc%2BpDNLQ2DABH7LdnINR1ZJWF5TMsHoVyWwcwF5OnOqJiKUaGldKos0XFqwXMWo4c%2F7RN1fnKqBkfeSoQXccqwIxr19fh6NYojc09wDwAggcqmBYPmLv7g%2Bui%2FtkEyRTqs4%2Fw4Csl5kmXcOJliX9EWlOmsaJKlFXOmeQEM1IePtBBf4hjJJ%2FnKeRjfdjdmz1d%2BZ1F2LP6qGHCe5ay%2FSn7%2Fw23GfAaWZHFlcevLxgNuu0dpRW4yN6dTjckpgRonXYupGizMDzkQ7K6d1rKEl5bSpXBROMp7Q%3D%3D HTTP/1.1
Host: storage.googleapis.com
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Access-Control-Request-Method: PUT
Origin: https://www.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36
Access-Control-Request-Headers: content-type
Accept: */*
X-Client-Data: CJW2yQEIpLbJAQjEtskBCKmdygEIqKPKAQi8pMoBCLGnygEI4qjKAQjxqcoB
Referer: https://www.example.com/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en;q=0.9,ja;q=0.8

The response to this is 200 OK and does include the CORS headers:

HTTP/1.1 200 OK
X-GUploader-UploadID: AEnB2UqxbjqsngYYKdvLmrHj21htyUusQkR2W3tge38fMd30TehyRy7wDDmq6U9a7oYIL1OCGJP9hw3uXNVFH8_qbIR-Skhpag
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Max-Age: 3600
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: content-type
Vary: Origin
Date: Thu, 04 Apr 2019 01:00:39 GMT
Expires: Thu, 04 Apr 2019 01:00:39 GMT
Cache-Control: private, max-age=0
Content-Length: 0
Server: UploadServer
Content-Type: text/html; charset=UTF-8
Alt-Svc: quic=":443"; ma=2592000; v="46,44,43,39"

Because this is OK the browser then makes the PUT request:

PUT https://storage.googleapis.com/example-com-media/uploads%2F20190404%2F5ca556b3d72f640001981487%2Fu1equspb.mp4_2c69f05f-0cb7-4c08-bd20-3858b06f6d51__000005?GoogleAccessId=example-com-media@stalwart-kite-714.iam.gserviceaccount.com&Expires=1554339568&Signature=IWjzT0D3Vxzw96JSTwqclhlJWZ%2B%2FBHYviL9SPnZCT3c5P2%2FSqJaq0Grxc%2BpDNLQ2DABH7LdnINR1ZJWF5TMsHoVyWwcwF5OnOqJiKUaGldKos0XFqwXMWo4c%2F7RN1fnKqBkfeSoQXccqwIxr19fh6NYojc09wDwAggcqmBYPmLv7g%2Bui%2FtkEyRTqs4%2Fw4Csl5kmXcOJliX9EWlOmsaJKlFXOmeQEM1IePtBBf4hjJJ%2FnKeRjfdjdmz1d%2BZ1F2LP6qGHCe5ay%2FSn7%2Fw23GfAaWZHFlcevLxgNuu0dpRW4yN6dTjckpgRonXYupGizMDzkQ7K6d1rKEl5bSpXBROMp7Q%3D%3D HTTP/1.1
Host: storage.googleapis.com
Connection: keep-alive
Content-Length: 1332887
Pragma: no-cache
Cache-Control: no-cache
Origin: https://www.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36
Content-Type: 
Accept: */*
X-Client-Data: CJW2yQEIpLbJAQjEtskBCKmdygEIqKPKAQi8pMoBCLGnygEI4qjKAQjxqcoB
Referer: https://www.example.com/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en;q=0.9,ja;q=0.8

viewing the HTTP request in Telerik's Fiddler I can see the following response:

HTTP/1.1 400 Bad Request
X-GUploader-UploadID: AEnB2UqdCe0t1tcfu_vgw7xirkbY6ACX_rZRac4UuufCU5vLufAsFQIQ06uNuE7zzCg7u8OXZN0aEu5ygD7TAJdqv4kVkBDf0w
Content-Type: application/xml; charset=UTF-8
Content-Length: 202
Date: Thu, 04 Apr 2019 01:00:39 GMT
Server: UploadServer
Alt-Svc: quic=":443"; ma=2592000; v="46,44,43,39"

<?xml version='1.0' encoding='UTF-8'?><Error><Code>ExpiredToken</Code><Message>The provided token has expired.</Message><Details>Request signature expired at: 2019-04-04T00:59:28+00:00</Details></Error>

So to answer Yasser's question: yes a 400 status code is returned, but because the CORS headers are not present, that response is never supplied to the calling Javascript code by the browser, so it's impossible to know why the request failed. All it's safe to say is that the request failed.

Upvotes: 6

Views: 2854

Answers (1)

Yasser Karout
Yasser Karout

Reputation: 336

Thanks for clarifying. After looking into this further, it's looking like this is the expected behavior currently for an expired signed URL. The header 'Access-Control-Allow-Origin' header is not present causing the error.

There is an open Feature Request for this, which is also linked to an internal feature request with the Storage Team. I will provide this Stack post as an extra use case internally as well.

Upvotes: 1

Related Questions