Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement 'no_store' HTTP cache directive #40324

Merged
merged 1 commit into from
Jun 23, 2021

Conversation

tadas-s
Copy link
Contributor

@tadas-s tadas-s commented Oct 3, 2020

I tried starting conversation in rubyonrails-core but haven't really got much feedback from there.

Summary

Currently there is no way to set Cache-Control: no-store header using
built-in cache control methods (expires_now/expires_in/etc..). One of
the top StackOverflow answers currently suggests putting it directly
into header set.

Unfortunately, it cannot later be overridden in specific/individual actions by
calling say expires_in 5.minutes. Resulting header in that case is
stays the same, i.e. Cache-Control: no-store.

This:

  1. Adds the no_store method to set Cache-Control: no-store header.
  2. Changes cache control "merge and normalize" code so default no-store
    directive can be overridden using built in cache control methods mentioned
    above.

What's the use of it

Couple examples:

Naming it

I'm open for ideas. Couple random suggestions:

  • no_store
  • http_cache_no_store

Other Information

@rafaelfranca
Copy link
Member

Can you add a CHANGELOG entry for the new method added? Also please squash all commits in one.

@tadas-s
Copy link
Contributor Author

tadas-s commented Oct 9, 2020

@rafaelfranca done.

(ps: if anyone's wondering why @rails-bot added "activesupport" label - I updated wrong changelog on previous commit)

@tadas-s tadas-s force-pushed the cache-control-no-store branch 2 times, most recently from c73c4ca to 77548fb Compare October 12, 2020 08:23
@tadas-s tadas-s force-pushed the cache-control-no-store branch 2 times, most recently from 33bb8d9 to 2a2fcb5 Compare October 28, 2020 15:48
Base automatically changed from master to main January 14, 2021 17:02
@tadas-s tadas-s force-pushed the cache-control-no-store branch 3 times, most recently from 8b89f26 to 1687da0 Compare January 23, 2021 12:56
@tadas-s
Copy link
Contributor Author

tadas-s commented Jan 23, 2021

Any chance this could be reviewed before next minor release cycle? 🙂

@tadas-s tadas-s force-pushed the cache-control-no-store branch 2 times, most recently from fb61083 to 8ca1be8 Compare February 19, 2021 09:07
@zzak
Copy link
Member

zzak commented Jun 10, 2021

@tadas-s Would you mind rebasing this? 🙇

Summary
=======

Currently there is no way to set "Cache-Control: no-store" header using
built-in cache control methods ("expires_now"/"expires_in"/etc..). One of
the [top StackOverflow][1] answers currently suggests putting it directly
into header set.

Unfortunately, it cannot later be overridden in specific/individual actions by
calling say 'expires_in 5.minutes'. Resulting header in that case is
stays the same, i.e. 'Cache-Control: no-store'.

This:
 1. Adds the 'no_store' method to set "Cache-Control: no-store" header.
 2. Changes cache control "merge and normalize" code so default "no-store"
    directive can be overridden using built in cache control methods mentioned
    above.

What's the use of it
--------------------

Couple examples:

* To [prevent rendering stale content][3] if browser return button is used
('expires_now' does not help).
* To prevent browser disk cache being used. In some situations it's considered
a [privacy/security risk][4].

Other Information
=================

Mozilla developer docs for [Cache-Control][2] header.

[1]: https://stackoverflow.com/questions/10744169/rails-set-no-cache-method-cannot-disable-browser-caching-in-safari-and-opera
[2]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
[3]: https://engineering.mixmax.com/blog/chrome-back-button-cache-no-store/
[4]: https://portswigger.net/kb/issues/00700100_cacheable-https-response
@tadas-s
Copy link
Contributor Author

tadas-s commented Jun 12, 2021

@zzak rebased.

@zzak zzak added the ready PRs ready to merge label Jun 12, 2021
@zzak zzak requested a review from rafaelfranca June 12, 2021 23:29
@rafaelfranca rafaelfranca merged commit cb52350 into rails:main Jun 23, 2021
@Tonkpils
Copy link
Contributor

@tadas-s I'm looking into a failing tests on the GitHub test suite. It seems this change might have introduced a change which causes the Cache-Control headers to be replaced when the controller's action uses send_data

send_data sets a public cache_control https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/data_streaming.rb#L148 and along with this patch any Cache-Control headers set on the action are cleared.

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "byebug"
  gem "rails", github: "rails/rails", branch: "main"
end

require "byebug"
require "action_controller/railtie"

class TestApp < Rails::Application
  config.root = __dir__
  config.hosts << "example.org"
  secrets.secret_key_base = "secret_key_base"

  config.logger = Logger.new($stdout)
  Rails.logger  = config.logger

  routes.draw do
    get "/" => "test#index"
  end
end

class TestController < ActionController::Base
  include Rails.application.routes.url_helpers

  def index
    headers["Cache-Control"] = "no-cache, no-store"
    send_data("foo\r\n", filename: "foobar.txt")
  end
end

require "minitest/autorun"
require "rack/test"

class BugTest < Minitest::Test
  include Rack::Test::Methods

  def test_returns_success
    get "/"
    assert last_response.ok?
    assert_equal "no-store", last_response.headers["Cache-Control"]
  end

  private
    def app
      Rails.application
    end
end
Failure:
BugTest#test_returns_success [duplication-bug.rb:49]:
Expected: "no-store"
  Actual: "private"

I'm happy to create a new issue for this if you think this is unintended behavior. A workaround here could be to explicitly set the headers with this new no_store method but I'm curious what your thoughts are here.

@tadas-s
Copy link
Contributor Author

tadas-s commented Jun 24, 2021

@Tonkpils

Oh this is a fun one.

So yes, no_store instead of headers["Cache-Control"] = "no-cache, no-store" should work. And this is why I created this PR. Although, if we're being nitpicky - according to Mozilla docs Cache-Control: no-cache, no-store is does not make sense and it should simply be Cache-Control: no-store

However.. I assumed that response.cache_control will only be manipulated via fresh_when / expires_in / expires_now .. etc. But your example shows that there's at least one piece of code which writes directly to response.cache_control, that send_data method.

Anyway, I'm still looking at this.

@tadas-s
Copy link
Contributor Author

tadas-s commented Jun 24, 2021

I'm also wondering if that IE 6.0 hack is still relevant at all 😅

https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/data_streaming.rb#L142-L148

@tadas-s
Copy link
Contributor Author

tadas-s commented Jun 24, 2021

Using github search I even found code like this: https://github.com/gitlabhq/gitlabhq/blob/master/app/controllers/concerns/sends_blob.rb#L32-L45 .. Do not think this is a documented way to handle response caching headers.

@zzak
Copy link
Member

zzak commented Jun 25, 2021

I'm also wondering if that IE 6.0 hack is still relevant at all 😅
https://github.com/rails/rails/blob/main/actionpack/lib/action_controller/metal/data_streaming.rb#L142-L148

@tadas-s Do you want to send a PR? 🙇

@tadas-s
Copy link
Contributor Author

tadas-s commented Jun 25, 2021

@zzak will do. I'm still playing around this + want to test with IE8 (lowest currently available IE vm I can get my hands on..). Just in case..

@Tonkpils
Copy link
Contributor

Tonkpils commented Jun 25, 2021

Although, if we're being nitpicky - according to Mozilla docs Cache-Control: no-cache, no-store is does not make sense and it should simply be Cache-Control: no-store

I'm curious about this and I'm honestly not 100% sure what the right approach is here. While the Mozilla docs specify no-store should suffice, there's other places that also maintain that no-cache is necessary to prevent the browser or proxy from using cached data without revalidating it.

As a similar example https://github.com/gitlabhq/gitlabhq/blob/21e08b6197f192c983f8527f4bba1f2aaec8abf2/lib/gitlab/no_cache_headers.rb sets no-store, no-cache while also setting

ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL
=> "max-age=0, private, must-revalidate"

On another note, I also want to confirm that setting the Cache-Control header is not going to be the proper approach to controlling the values in the header? It almost seems to me like we want a more flexible method for setting these values if we're going to have specific methods like no_store

tadas-s added a commit to tadas-s/rails that referenced this pull request Jun 25, 2021
IE 6-7-8 has bug when 'Cache-Control: no-cache' head would break file download.
It's been fixed in IE9 (which is also ancient and barely used version).

Some extra details [here][1] and [here][2].

The way this fix is implemented clashes with what's been done in PR rails#40324. By
adding ':public' key to cache control header set it wipes default headers.

Since IE 6-7-8 are ancient browsers - let's just get rid of this hack.

[1]: https://stackoverflow.com/q/9766639/843067
[2]: https://stackoverflow.com/q/3415370/843067
@tadas-s
Copy link
Contributor Author

tadas-s commented Jun 25, 2021

On another note, I also want to confirm that setting the Cache-Control header is not going to be the proper approach to controlling the values in the header? It almost seems to me like we want a more flexible method for setting these values if we're going to have specific methods like no_store

This is only my opinion, I'm not a Rails maintainer or anything: setting caching headers via Rails provided APIs like fresh_when / expires_in / expires_now / .. is great. If flexibility of that is not enough - setting response.headers["Cache-Control"] = ... is good as well given Rails APIs do not provide enough flexibility. Both are documented facilities (here and here) provided by Rails. However.. Directly manipulating response.cache_control from the application code (like gitlab does) should be avoided. It's not documented and it touches internal Rails structures.

@casperisfine
Copy link
Contributor

Hum, this broke our test suite as well. In a controller we have a before_filter with:

      def configure_cache_control
        response.cache_control = 'no-store'
      end

And this change clears it and replace it with no-cache, which isn't what we want.

@tadas-s
Copy link
Contributor Author

tadas-s commented Jun 28, 2021

@casperisfine any chance you could try re-writing this bit of code into:

      def configure_cache_control
        no_store
      end

That should do the trick.

@casperisfine
Copy link
Contributor

Yeah expires_now did the trick. I just wanted to point out how frequent this usage was.

@645383
Copy link

645383 commented Aug 2, 2021

Is there a way to set Cache-Control: no-store, max-age=0 (example: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#preventing_caching)? Adding no-store to headers replaces other directives.

@tadas-s
Copy link
Contributor Author

tadas-s commented Aug 2, 2021

@645383

See this section: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives

And specifically:

no-store
The response may not be stored in any cache. Note that this will not prevent a valid pre-existing cached response being returned. Clients can set max-age=0 to also clear existing cache responses, as this forces the cache to revalidate with the server (no other directives have an effect when used with no-store).

(emphasis mine)

I understand it's client (i.e. browser side thing) and hence not applicable for Rails/server side.

@tadas-s
Copy link
Contributor Author

tadas-s commented Aug 2, 2021

@645383 if you could describe what you're trying to solve in bit more detail, myself or someone else reading this may be able to help.

@645383
Copy link

645383 commented Aug 2, 2021

Sorry, the question is not related to this PR. There are another PRs allow for only no-store in cache-control header and Allow 'private, no-store' Cache-Control header.
I wonder what is the correct way to set Cache-Control: no-store, max-age=0? If no-store directive exists, rails does not allow max-age directive.

@645383
Copy link

645383 commented Aug 2, 2021

Missed this part Clients can set, thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
actionpack ready PRs ready to merge
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add Cache-Control: no-store by default to prevent bfcache
7 participants