Chrome S3 Cloudfront: No 'Access-Control-Allow-Origin' header on initial XHR request

39,793

Solution 1

You're making two requests for the same object, one from HTML, one from XHR. The second one fails, because Chrome uses the cached response from the first request, which has no Access-Control-Allow-Origin response header.

Why?

Chromium bug 409090 Cross-origin request from cache failing after regular request is cached describes this problem, and it's a "won't fix" -- they believe their behavior is correct. Chrome considers the cached response to be usable, apparently because the response didn't include a Vary: Origin header.

But S3 does not return Vary: Origin when an object is requested without an Origin: request header, even when CORS is configured on the bucket. Vary: Origin is only sent when an Origin header is present in the request.

And CloudFront does not add Vary: Origin even when Origin is whitelisted for forwarding, which should by definition mean that varying the header might modify the response -- that's the reason why you forward and cache against request headers.

CloudFront gets a pass, because its response would be correct if S3's were more correct, since CloudFront does return this when it's provided by S3.

S3, a little fuzzier. It is not wrong to return Vary: Some-Header when there was no Some-Header in the request.

For example, a response that contains

Vary: accept-encoding, accept-language

indicates that the origin server might have used the request's Accept-Encoding and Accept-Language fields (or lack thereof) as determining factors while choosing the content for this response. (emphasis added)

https://www.rfc-editor.org/rfc/rfc7231#section-7.1.4

Clearly, Vary: Some-Absent-Header is valid, so S3 would be correct if it added Vary: Origin to its response if CORS is configured, since that indeed could vary the response.

And, apparently, this would make Chrome do the right thing. Or, if it doesn't do the right thing in this case, it would be violating a MUST NOT. From the same section:

An origin server might send Vary with a list of fields for two purposes:

  1. To inform cache recipients that they MUST NOT use this response to satisfy a later request unless the later request has the same values for the listed fields as the original request (Section 4.1 of [RFC7234]). In other words, Vary expands the cache key required to match a new request to the stored cache entry.

...

So, S3 really SHOULD be returning Vary: Origin when CORS is configured on the bucket, if Origin is absent from the request, but it doesn't.

Still, S3 is not strictly wrong for not returning the header, because it's only a SHOULD, not a MUST. Again, from the same section of RFC-7231:

An origin server SHOULD send a Vary header field when its algorithm for selecting a representation varies based on aspects of the request message other than the method and request target, ...

On the other hand, the argument could be made that Chrome should implicitly know that varying the Origin header should be a cache key because it could change the response in the same way Authorization could change the response.

...unless the variance cannot be crossed or the origin server has been deliberately configured to prevent cache transparency. For example, there is no need to send the Authorization field name in Vary because reuse across users is constrained by the field definition [...]

Similarly, reuse across origins is arguably constrained by the nature of Origin but this argument is not a strong one.


tl;dr: You apparently cannot successfully fetch an object from HTML and then successfully fetch it again with as a CORS request with Chrome and S3 (with or without CloudFront), due to peculiarities in the implementations.


Workaround:

This behavior can be worked-around with CloudFront and Lambda@Edge, using the following code as an Origin Response trigger.

This adds Vary: Access-Control-Request-Headers, Access-Control-Request-Method, Origin to any response from S3 that has no Vary header. Otherwise, the Vary header in the response is not modified.

'use strict';
 
// If the response lacks a Vary: header, fix it in a CloudFront Origin Response trigger.
 
exports.handler = (event, context, callback) => {
    const response = event.Records[0].cf.response;
    const headers = response.headers;
 
    if (!headers['vary'])
    {
        headers['vary'] = [
            { key: 'Vary', value: 'Access-Control-Request-Headers' },
            { key: 'Vary', value: 'Access-Control-Request-Method' },
            { key: 'Vary', value: 'Origin' },
        ];
    }
    callback(null, response);
};

Attribution: I am also the author of the original post on the AWS Support forums where this code was initially shared.


The Lambda@Edge solution above results in fully correct behavior, but here are two alternatives that you may find useful, depending on your specific needs:

Alternative/Hackaround #1: Forge the CORS headers in CloudFront.

CloudFront supports custom headers that are added to each request. If you set Origin: on every request, even those that are not cross-origin, this will enable correct behavior in S3. The configuration option is called Custom Origin Headers, with the word "Origin" meaning something entirely different than it means in CORS. Configuring a custom header like this in CloudFront overwrites what is sent in the request with the value specified, or adds it if absent. If you have exactly one origin accessing your content over XHR, e.g. https://example.com, you can add that. Using * is dubious, but might work for other scenarios. Consider the implications carefully.

Alternative/Hackaround #2: Use a "dummy" query string parameter that differs for HTML and XHR or is absent from one or the other. These parameters are typically named x-* but should not be x-amz-*.

Let's say you make up the name x-request. So <img src="https://dzczcexample.cloudfront.net/image.png?x-request=html">. When accessing the object from JS, don't add the query parameter. CloudFront is already doing the right thing, by caching different versions of the objects using the Origin header or absence of it as part of the cache key, because you forwarded that header in your cache behavior. The problem is, your browser doesn't know this. This convinces the browser that this is actually a separate object that needs to be requested again, in a CORS context.

If you use these alternative suggestions, use one or the other -- not both.

Solution 2

As of Nov 2021, CloudFront directly supports Response Headers Policies. These include CORS, security and custom headers. There is no need to inject custom headers via Lambda@Edge or CloudFront Functions anymore.

Perhaps more pleasingly, there is no need to add Vary as a custom header any more. The new CORS implementation in Headers Polices includes additional logic to set the appropriate headers such as Vary according to the fetch standard.

Solution 3

I don't know why you'd be getting such different results from various browsers, but:

X-Amz-Cf-Id: wxn_m9meR6yPoyyvj1R7x83pBDPJy1nT7kdMv1aMwXVtHCunT9OC9g==

That line right there is what (if you can get their attention) a CloudFront or Support engineer will use to follow one of your failed requests. If the request is getting to a CloudFront server, it should have this header in the response. If that header isn't there, then the request is likely failing somewhere before it gets to CloudFront.

Solution 4

There is another simpler solution that works for me with the use of a HTML attribute called crossorigin='anonymous' as detailed here. Basically you can add this attribute as such:

<img src="your_image_url_here" crossorigin='anonymous'>

and this would essentially makes your "first" request to the image a CORS request, now if you try to retrieve the same image again through XHR, even if Chrome decides to use the cache (cached response for the first request) it will be fine as it will now comes with Access-Control-Allow-Origin header.

Solution 5

The accepted solution addresses the issue, but it's not the most performant, particularly for CloudFront distributions that serve dynamic content. Setting up header-caching with a whitelist results in CloudFront caching multiple versions of the requested object depending on the header. This means that internally CloudFront may need to refetch the object from the S3 origin multiple times. Data transfer from S3 to CloudFront is free, but that doesn't account for additional latency.

An alternative solution here would be to disable CORS configuration on the S3 bucket, and instead manually set CORS headers using a Lambda@Edge function configured on the viewer response. The function could look as follows:

'use strict';

const AllowedOriginRegex = /^(.*\.)?example\.com$/;

exports.handler = async (event = {}) => {
  const request = event.Records[0].cf.request;
  const response = event.Records[0].cf.response;

  if (!response.headers.vary) {
    response.headers.vary = [
      {key: 'Vary', value: 'Origin'},
      {key: 'Vary', value: 'Access-Control-Request-Headers'},
      {key: 'Vary', value: 'Access-Control-Request-Method'},
    ];
  }

  const origin = request.headers.origin && request.headers.origin[0].value;
  if (origin && AllowedOriginRegex.test(origin)) {
    response.headers['access-control-allow-origin'] = [
      {key: 'Access-Control-Allow-Origin', value: origin},
    ];
    response.headers['access-control-allow-methods'] = [
      {key: 'Access-Control-Allow-Methods', value: 'GET, HEAD'},
    ];
    response.headers['access-control-max-age'] = [
      {key: 'Access-Control-Max-Age', value: '3600'},
    ];
  }

  return response;
}
Share:
39,793

Related videos on Youtube

SunSparc
Author by

SunSparc

Devops/Developer using Go, Python, Bash Currently working for an exciting company called SmartyStreets where we rock the world of address verification.

Updated on September 18, 2022

Comments

  • SunSparc
    SunSparc over 1 year

    I have a webpage (https://smartystreets.com/contact) that uses jQuery to load some SVG files from S3 through the CloudFront CDN.

    In Chrome I will open an Incognito window as well as the console. Then I will load the page. As the page loads, I will typically get 6 to 8 messages in the console that look similar to this:

    XMLHttpRequest cannot load 
    https://d79i1fxsrar4t.cloudfront.net/assets/img/feature-icons/documentation.08e71af6.svg.
    No 'Access-Control-Allow-Origin' header is present on the requested resource.
    Origin 'https://smartystreets.com' is therefore not allowed access.
    

    If I do a standard reload of the page, even multiple time, I continue to get the same errors. If I do Command+Shift+R then most, and sometimes all, of the images will load without the XMLHttpRequest error.

    Sometimes even after the images have loaded, I will refresh and one or more of the images will not load and return that XMLHttpRequest error again.

    I have checked, changed, and re-checked the settings on S3 and Cloudfront. In S3 my CORS configuration looks like this:

    <?xml version="1.0" encoding="UTF-8"?>
    <CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedOrigin>http://*</AllowedOrigin>
        <AllowedOrigin>https://*</AllowedOrigin>
        <AllowedMethod>GET</AllowedMethod>
        <MaxAgeSeconds>3000</MaxAgeSeconds>
        <AllowedHeader>Authorization</AllowedHeader>
    </CORSRule>
    </CORSConfiguration>
    

    (Note: initially had only <AllowedOrigin>*</AllowedOrigin>, same problem.)

    In CloudFront the distribution behavior is set to allow the HTTP Methods: GET, HEAD, OPTIONS. Cached methods are the same. Forward Headers is set to "Whitelist" and that whitelist includes, "Access-Control-Request-Headers, Access-Control-Request-Method, Origin".

    The fact that it works after a cache-less browser reload seems to indicate that all is well on the S3/CloudFront side, else why would the content be delivered. But then why would the content not be delivered on the initial page-view?

    I am working in Google Chrome on macOS. Firefox has no problem getting the files every time. Opera NEVER gets the files. Safari will pick up the images after several refreshes.

    Using curl I do not get any problems:

    curl -I -H 'Origin: smartystreets.com' https://d79i1fxsrar4t.cloudfront.net/assets/img/phone-icon-outline.dc7e4079.svg
    
    HTTP/1.1 200 OK
    Content-Type: image/svg+xml
    Content-Length: 508
    Connection: keep-alive
    Date: Tue, 20 Jun 2017 17:35:57 GMT
    Access-Control-Allow-Origin: *
    Access-Control-Allow-Methods: GET
    Access-Control-Max-Age: 3000
    Last-Modified: Thu, 15 Jun 2017 16:02:19 GMT
    ETag: "dc7e4079f937e83291f2174853adb564"
    Cache-Control: max-age=31536000
    Expires: Wed, 01 Jan 2020 23:59:59 GMT
    Accept-Ranges: bytes
    Server: AmazonS3
    Vary: Origin,Access-Control-Request-Headers,Access-Control-Request-Method
    Age: 4373
    X-Cache: Hit from cloudfront
    Via: 1.1 09fc52f58485a5da8e63d1ea27596895.cloudfront.net (CloudFront)
    X-Amz-Cf-Id: wxn_m9meR6yPoyyvj1R7x83pBDPJy1nT7kdMv1aMwXVtHCunT9OC9g==
    

    Some have suggested that I delete the CloudFront distribution and recreate it. Seems like a rather harsh and inconvenient fix.

    What is causing this problem?

    Update:

    Adding response headers from an image that failed to load.

    age:1709
    cache-control:max-age=31536000
    content-encoding:gzip
    content-type:image/svg+xml
    date:Tue, 20 Jun 2017 17:27:17 GMT
    expires:2020-01-01T23:59:59.999Z
    last-modified:Tue, 11 Apr 2017 18:17:41 GMT
    server:AmazonS3
    status:200
    vary:Accept-Encoding
    via:1.1 022c901b294fedd7074704d46fce9819.cloudfront.net (CloudFront)
    x-amz-cf-id:i0PfeopzJdwhPAKoHpbCTUj1JOMXv4TaBgo7wrQ3TW9Kq_4Bx0k_pQ==
    x-cache:Hit from cloudfront
    
    • Michael - sqlbot
      Michael - sqlbot almost 7 years
      You are right -- delete and recreate is extreme and should simply never be necessary. Can you show us the browser's request and response headers for a failed request? And maybe for a successful request of the exact same object?
    • SunSparc
      SunSparc almost 7 years
      @Michael-sqlbot, I was kinda hoping you would visit the URL (smartystreets.com/contact) and see if the same thing was happening on your machine. :) The interesting thing about the errors is that aside from the error in the console, the browser reports a status of 200, citing that it is using the image "(from disk cache)", which should not be possible with Incognito, I thought. Even after I clear the local cache.
    • Michael - sqlbot
      Michael - sqlbot almost 7 years
      Yeah, people so often "make up" domain names (that turn out to be real sites, but not the site in question) that I initially didn't realize you had given the actual, correct link to your site. Thanks for that, you can disregard my request. I can duplicate the problem. This seems like a client side issue. I'm chasing down a theory.
    • SunSparc
      SunSparc almost 7 years
      I think you might be correct about it being a client-side issue. The images are linked to with A tags in the HTML and then it looks like they are requested again in the jQuery. Perhaps the error is from one call and the 200 is from the other.
    • Michael - sqlbot
      Michael - sqlbot almost 7 years
      That is exactly what I believe to be the case. Chrome and S3 are interacting in a way that breaks a CORS request that follows a non-CORS request for the same object. Arguably, both of them are wrong... but arguably, neither of them is wrong. I don't think you can fix this without storing two copies of the object with different keys... or using two different CloudFront distributions (different hostnames) so that you don't make both a CORS and non-CORS request. I'll write it up with details of how I'm arriving at this conclusion, if you like.
    • tarikakyol
      tarikakyol over 4 years
      When you mess around with headers etc, they are cached, to revert back to current settings just invalidate those in CloudFront
    • Alex Filatov
      Alex Filatov over 4 years
      I'm using this post for avoiding cors in chrome alfilatov.com/posts/run-chrome-without-cors
  • SunSparc
    SunSparc almost 7 years
    Thanks, I will see if I can get any responses over on the AWS forums.
  • Tim
    Tim almost 7 years
    You may need to pay the $29 for developer support. That's a trivial amount of money for any business, given how much a persons time costs.
  • Michael - sqlbot
    Michael - sqlbot almost 7 years
    @Tim, note that developer support is not simply $29. That's the base price. If 3% of your monthly AWS bill is >= $29, you pay 3% instead of the base.
  • Tim
    Tim almost 7 years
    Thanks @Michael-sqlbot, I didn't realise that. I know support price can add up quickly when you have things like reserved instances, but I've never looked at developer pricing when you have a lot of resources.
  • mtyurt
    mtyurt over 6 years
    Your response is a lifesaver, great answer. You saved me some serious time.
  • Jeffin
    Jeffin almost 6 years
    Hi, I dont use cloudfront for my s3 so this workaround is not helping , is there anything else i can do?
  • Michael - sqlbot
    Michael - sqlbot almost 6 years
    @Jeffin, alternative #2 above will work for S3 alone, without CloudFront. Adding an arbitrary ?x-some-key=some-value query string parameter will convince the browser that the request is different.
  • Jeffin
    Jeffin almost 6 years
    @Michael-sqlbot: Yep, worked like a charm
  • Lionel
    Lionel over 5 years
    @Michael-sqlbot Great answer, thank you! Should we keep these settings when also applying the Lambda function?
  • ArcadeRenegade
    ArcadeRenegade over 4 years
    You are a legend! Workaround worked like a charm. Thank you for this answer. Wish I could give it 100 points!!!!
  • Martin Ratinaud
    Martin Ratinaud over 3 years
    Hi all and thanks for this answer. Although I do not manage to make it work correctly. - I managed to setup the role correctly with dev.to/geekgalgroks/using-lambda-to-rewrite-urls-for-hugo-2j‌​oi - I created a trigger on cloudfront with my distribution And nothing happens, should I create a destination ? and if yes hoiw should I configure it. Thanks very much for your answers
  • Martin Ratinaud
    Martin Ratinaud over 3 years
    @Michael-sqlbot tagging you for visibility in case you can help me. Thanks :-)
  • Michael - sqlbot
    Michael - sqlbot over 3 years
    @MartinRatinaud it's not clear what you mean by "nothing happens." (Authors can see comments on their posts without tagging.)
  • Michael - sqlbot
    Michael - sqlbot over 3 years
    I would expect this to be much less performant overall, and more expensive, than my solution because the Lambda trigger must fire for each and every response. My solution allows the modified response from the Lambda trigger to be stored in the CloudFront cache to be replayed for future requests with the relevant CORS request headers set to identical values, and caching multiple version on that basis is exactly the intended behavior.
  • Michael - sqlbot
    Michael - sqlbot over 3 years
    const origin = request.headers.origin; is incorrect, since request.headers.origin will be either undefined or an array, not a string.
  • Oleg Vaskevich
    Oleg Vaskevich over 3 years
    With regards to performance, I think it really depends on the type of content you're serving. For my specific use case, I'm serving dynamic content from S3 using CF, and the content can be quite large (~100MB). If a browser sends multiple requests with different Origin headers, CloudFront will have to fetch the object from S3 twice.
  • Oleg Vaskevich
    Oleg Vaskevich over 3 years
    It's true that the Lambda trigger will fire for each and every response, but it's quite fast -- this Lambda runs in about 1ms, which is negligible compared to copying large files cross-region within CloudFront.
  • Oleg Vaskevich
    Oleg Vaskevich over 3 years
    Thanks for the catch on the array -- fixed up the code I added here and also incorporated part of the Vary header solution. For static content I do think that the accepted solution should be used, but I wanted to share this alternative for dynamic content CDNs.
  • Martin Ratinaud
    Martin Ratinaud over 3 years
    Hi and sorry for having not been clear enough. "nothing happens" meant that I did not see anly logs when accessing the asset. In fact I managed by making it a "origin response" and giving permissions "CloudFrontFullAccess" which is not that good. Thanks for the answer anyway
  • xhafan
    xhafan about 3 years
    I added crossorigin="anonymous" for all <img/> tags, and then all images subsequently loaded via ajax from cloudfront were loaded successfully without CORS error. For ASP.NET Core MVC I added this tag helper to do this for me: [HtmlTargetElement("img")] public class ImgTagHelper : TagHelper { public override void Process(TagHelperContext context, TagHelperOutput output) { output.Attributes.SetAttribute("crossorigin", "anonymous"); } }
  • Lee
    Lee about 3 years
    +1 for the crossorigin="anonymous" solution proposed by @xhafan and its much simpler than maintaining a Lambda@Edge pipeline.
  • Thomas Potaire
    Thomas Potaire over 2 years
    I don't see it linked here but Cloudfront now has functions and this is an example how to handle a response: docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/… It should be pretty easy to adapt @Michael-sqlbot answer to the new syntax
  • cweekly
    cweekly over 2 years
    Great news! Thanks for sharing! :)
  • cweekly
    cweekly over 2 years
    This should be the new accepted answer. (Michael-sqlbot's 2017 answer is terrific, but the new Response Headers Policies support is a game-changer.)