Weak ETAGs in Rails? - ruby-on-rails

What is the best way to tell rails to use weak instead of strong ETAGs when using methods fresh_when and stale??
The reason I ask is that nginx (correctly) removes strong ETAG headers from responses when on-the-fly gzipping is enabled.

I took the code from #grosser's answer and turned it into a Gem:
https://rubygems.org/gems/rails_weak_etags
https://github.com/johnnaegle/rails_weak_etags
You can just add this to your gemfile:
gem 'rails_weak_etags'
And it will be installed into your middleware before Rack::ConditionalGet:
> bundle exec rake middleware
....
use RailsWeakEtags::Middleware
use Rack::ConditionalGet
use Rack::ETag
....
Then all the e-tags generated by rails, either with Rack::ETag or with explicit e-tags will be converted to weak. Using a patched, or version > 1.7.3 of nginx, will then let you use e-tags and gzip compression.
RACK 1.6 defaults etags to weak - this gem is no longer helpful if you upgrade.

middleware:
class WeakEtagMiddleware
def initialize(app)
#app = app
end
def call(env)
# make request etags "strong"
etag = env['HTTP_IF_NONE_MATCH']
if etag && etag =~ /^W\//
env['HTTP_IF_NONE_MATCH'] = etag[2..-1]
end
status, headers, body = #app.call(env)
# make response etags "weak"
etag = headers['ETag']
if etag && etag !~ /^W\//
headers['ETag'] = "W/#{etag}"
end
[status, headers, body]
end
end
plus add middleware
Rails.application.config.middleware.insert_before(Rack::ETag, WeakEtagMiddleware)
plus unit tests
context WeakEtagMiddleware do
let(:backend) { Rack::ConditionalGet.new(Rack::ETag.new(lambda { |env| [env["status"] || 200, {}, ["XXX"]] })) }
let(:app) { WeakEtagMiddleware.new(backend) }
let(:expected_digest_1) { "bc9189406be84ec297464a514221406d" }
let(:env) { {"REQUEST_METHOD" => "GET"} }
should "converts etags to weak" do
status, headers, body = app.call(env)
assert_equal %{W/"#{expected_digest_1}"}, headers["ETag"]
assert_equal status, 200
end
should "not add etags to responses without etag" do
status, headers, body = app.call(env.merge("status" => 400))
refute headers["ETag"]
assert_equal status, 400
end
should "recognize weak ETags" do
status, headers, body = app.call(env.merge("HTTP_IF_NONE_MATCH" => %{W/"#{expected_digest_1}"}))
assert_equal status, 304
end
should "not recognize invalid ETags" do
status, headers, body = app.call(env.merge("HTTP_IF_NONE_MATCH" => %{W/"something-not-fresh"}))
assert_equal status, 200
end
end
plus integration tests
require_relative "../helpers/test_helper"
class WeakEtagsTest < ActionController::IntegrationTest
class TestController < ActionController::Base
def auto
render :text => "XXX"
end
def fresh
if stale? :etag => "YYY"
render :text => "XXX"
end
end
end
additional_routes do
get '/test/weak_etags/:action', :controller => 'weak_etags_test/test'
end
fixtures :accounts, :users
context "weak etags" do
let(:expected_digest_1) { "bc9189406be84ec297464a514221406d" }
let(:expected_digest_2) { "fd7c5c4fdaa97163ee4ba8842baa537a" }
should "auto adds weak etags" do
get "/test/weak_etags/auto"
assert_equal "XXX", #response.body
assert_equal %{W/"#{expected_digest_1}"}, #response.headers["ETag"]
end
should "adds weak etags through fresh_when" do
get "/test/weak_etags/fresh"
assert_equal "XXX", #response.body
assert_equal %{W/"#{expected_digest_2}"}, #response.headers["ETag"]
end
should "recognize auto-added ETags" do
get "/test/weak_etags/auto", {}, {"HTTP_IF_NONE_MATCH" => %{W/"#{expected_digest_1}"}}
assert_response :not_modified
end
should "recognize fresh ETags" do
get "/test/weak_etags/fresh", {}, {"HTTP_IF_NONE_MATCH" => %{W/"#{expected_digest_2}"}}
assert_response :not_modified
end
end
end

It looks like Rack::ETag will use weak-etags in the future:
https://github.com/rack/rack/issues/681
https://github.com/rack/rack/commit/12528d4567d8e6c1c7e9422fee6cd8b43c4389bf

Here's an alternative that avoids making any changes in your application server. This directive converts all etags returned by your application to weak etags before they get stripped from the response. Put it inside your inside your nginx config:
location / {
add_header ETag "W/$sent_http_ETAG";
}
I've checked that this works with nginx 1.7.6.

Related

Adding 'SameSite=None;' cookies to Rails via Rack middleware?

On February 4th 2020, Google Chrome will require SameSite=None; to be added to all cross-site cookies. Rails 6.1 and soon Rails 6.0 have added a same_site: :none option to the rails cookie hash:
cookies["foo"]= {
value: "bar",
expires: 1.year.from_now,
same_site: :none
}
But older Rails 5.x apps won't receive the upgrade to have access to the same_site options hash. I know the SameSite=None; cookie option can be manually added to Rails in a controller using:
response.headers["Set-Cookie"] = "my=cookie; path=/; expires=#{1.year.from_now}; SameSite=None;"
But my Rails 5.x app uses complicated cookie objects that modify cookies. Instead of breaking them apart, I would like to write Rack middleware to manually update all cookies with the SameSite=None; attribute at once.
This StackOverflow answer shows a way to cookies can be modified to update cookies within Rack Middleware:
# lib/same_site_cookie_middleware
class SameSiteCookieMiddleware
def initialize(app)
#app = app
end
def call(env)
status, headers, body = #app.call(env)
# confusingly, response takes its args in a different order
# than rack requires them to be passed on
# I know it's because most likely you'll modify the body,
# and the defaults are fine for the others. But, it still bothers me.
response = Rack::Response.new body, status, headers
response.set_cookie("foo", {:value => "bar", :path => "/", :expires => 1.year.from_now, same_site: :none})
response.finish # finish writes out the response in the expected format.
end
end
# application.rb
require 'same_site_cookie_middleware'
config.middleware.insert_after(ActionDispatch::Cookies, SameSiteCookieMiddleware)
How do I re-write this Rack Middleware code to manually append SameSite=None; into every existing cookie?
I was able to get all cookies to use SameSite=None by default updating rack:
gem 'rack', '~> 2.1'
use Rack::Session::Cookie,
:httponly => true,
:same_site => :none,
:secure => true,
:secret => COOKIE_SECRET.to_s()
I was able to get this to work with the following:
# frozen_string_literals: true
class SameSiteCookies
def initialize(app)
#app = app
end
def call(env)
status, headers, body = #app.call(env)
set_cookie_header = headers['Set-Cookie']
if set_cookie_header && !(set_cookie_header =~ /SameSite\=/)
headers['Set-Cookie'] << ';' if !(set_cookie_header =~ /;$/)
headers['Set-Cookie'] << ' SameSite=None'
headers['Set-Cookie'] << '; Secure' if env['rack.url_scheme'] == 'https';
end
[status, headers, body]
end
end
and adding to middleware with:
Rails.application.config.middleware.insert_before(ActionDispatch::Cookies, SameSiteCookies)
I had a problem with Rails 5 headers being frozen. This is similar to Carson's answer but it goes around this problem. Should work for both rails 5 < and Rails 5+.
# frozen_string_literals: true
class SameSiteCookies
def initialize(app)
#app = app
end
def call(env)
status, headers, body = #app.call(env)
set_cookie_header = headers['Set-Cookie']
if set_cookie_header && !(set_cookie_header =~ /SameSite\=/)
# the set cookie header variable is frozen
new_set_cookie_header = set_cookie_header.dup
new_set_cookie_header << ';' if !(set_cookie_header =~ /;$/)
new_set_cookie_header << ' SameSite=None'
new_set_cookie_header << '; Secure' if is_ssl?
headers['Set-Cookie'] = new_set_cookie_header
end
[status, headers, body]
end
private
def is_ssl?
# custom logic for my application
end
end
Insert the middleware
Rails.application.config.middleware.insert_before(ActionDispatch::Cookies, SameSiteCookies)
Update: For Rails 5.x and lower, I found the rails_same_site_cookie gem to be a good option for adding SameSite=None; to all your app's cookies. It uses middleware to do it.
The secure_headers gem lets you configure the cookie policy out of the box:
SecureHeaders::Configuration.default do |config|
config.cookies = {
secure: true, # mark all cookies as "Secure"
httponly: true, # mark all cookies as "HttpOnly"
samesite: {
none: true # mark all cookies as SameSite=lax
}
}
I used this solution to add SameSite=None to our cookies on a Rails 5 application.
Once a cookie is set you cannot modify the cookie properties like expiry, domain, path.
Browsers return only the cookie name and value once a cookie has already been set, over-riding any of the cookie property will create a new cookie. I would recommend to delete the existing cookie and create a new cookie with same name and value.
headers['Set-Cookie'] instructs the browser to create a new cookie and modifying the value in middleware gives you a very little control on the attribute value.
I have answered here how this can be achieved by modifying the Rack::Utils.set_cookie_header! method.

How to stub HTTP request on Mechanize in Rails?

I have some codebase like this, and I wanna use rspec test favicon_href, but as you like, the favicon_href will call the page function, I know I can mock page function, but for this stage I wanna mock the HTTP request from the given url, so I use WebMock gem's syntax to stub HTTP request, but it seems WebMock is not compatibility with Mechanize, it always show the error in the below despite I relleay have done the stub, anyone know how can solve it or any gem can stub HTTP request on Mechanize?
Code
def favicon_href
#favicon_href ||=
begin
page.at(FAVICON_DOM).attributes['href'].value # finding <link> elements
rescue Exception
'/favicon.ico' # there are some situation the favicon's not <link>'
end
end
def page
#page ||= mechanize.get(url)
end
def mechanize
#mechanize ||= Mechanize.new
end
Error
Failure/Error: #page ||= mechanize.get(valid_url(url))
WebMock::NetConnectNotAllowedError:
Real HTTP connections are disabled. Unregistered request: GET https://tsaohucn.wordpress.com/ with headers {'Accept'=>'*/*', 'Accept-Charset'=>'ISO-8859-1,utf-8;q=0.7,*;q=0.7', 'Accept-Encoding'=>'gzip,deflate,identity', 'Accept-Language'=>'en-us,en;q=0.5', 'Connection'=>'keep-alive', 'Host'=>'tsaohucn.wordpress.com', 'Keep-Alive'=>'300', 'User-Agent'=>'Mechanize/2.7.5 Ruby/2.3.1p112 (http://github.com/sparklemotion/mechanize/)'}
You can stub this request with the following snippet:
stub_request(:get, "https://tsaohucn.wordpress.com/").
with(headers: {'Accept'=>'*/*', 'Accept-Charset'=>'ISO-8859-1,utf-8;q=0.7,*;q=0.7', 'Accept-Encoding'=>'gzip,deflate,identity', 'Accept-Language'=>'en-us,en;q=0.5', 'Connection'=>'keep-alive', 'Host'=>'tsaohucn.wordpress.com', 'Keep-Alive'=>'300', 'User-Agent'=>'Mechanize/2.7.5 Ruby/2.3.1p112 (http://github.com/sparklemotion/mechanize/)'}).
to_return(status: 200, body: "", headers: {})
registered request stubs:
stub_request(:get, "https://tsaohucn.wordpress.com/").
with(headers: {'Accept'=>'*/*', 'User-Agent'=>'Ruby'})
stub_request(:any, "http://api.stripe.com/")
stub_request(:any, "/api.stripe.com/")
============================================================
There exists an incompatibility between WebMock and net-http-persistent.
See
https://github.com/bblimke/webmock#connecting-on-nethttpstart
Add
WebMock.allow_net_connect!(:net_http_connect_on_start => true)
to your test set up.

Rspec request specs and Rails 5

I'm starting a new project, my first with Rails 5.1.0. I have a pb with my first request spec.
describe 'Users', type: :request do
it 'are created from external data' do
json_string = File.read('path/to/test_data/user_data.json')
params = { user: JSON.parse(json_string) }
headers = { "CONTENT_TYPE" => "application/json" }
expect do
post '/api/v1/users', params.to_s, headers
end.to change {
User.count
}.by(1)
expect(response.status).to eq 200
end
end
this spec return the error ArgumentError: wrong number of arguments (given 3, expected 1). The official documentation don't say much.
If I take out the .to_s, and send a hash, like this:
post '/api/v1/users', params, headers
I got another error:
ArgumentError: unknown keyword: user
Any thought?
I think they changed the syntax recently. Now it should use keyword args. So, something like this:
post '/api/v1/users', params: params, headers: headers
Here's a little addendum to Sergio's answer. If you are upgrading from Rails 4 to Rails 5, have lots of tests, and aren't too keen on changing them all – at least not until you've finished upgrading – I've found a way to make them work with the old method signature.
In my spec_helper I added
module FixLegacyTestRequests
def get(path, par = {}, hdr = {})
process(:get, path, params: par, headers: hdr)
end
def post(path, par = {}, hdr = {})
process(:post, path, params: par, headers: hdr)
end
def put(path, par = {}, hdr = {})
process(:put, path, params: par, headers: hdr)
end
def delete(path, par = {}, hdr = {})
process(:delete, path, params: par, headers: hdr)
end
end
and then I added this configuration for each test:
RSpec.configure do |config|
config.before :each do |example|
extend(FixLegacyTestRequests) # to be removed at some point!
end
end
My tests went back to working, and I think it should be safe because it's only applied to the currently running test and shouldn't pollute any gem's code such as with a monkey patch.

Multiple HTTP requests matchable in one VCR cassette for rspec tests

I have a spec file with an expectation that a controller action will return success.
The POST api/v1/users/:id/features/block action in the controller calls two HTTP calls on an external API, the only difference being in the body.
I've put the two requests and responses in the same VCR cassette, but when the cassette is being used, only the first request ever gets compared against and fails when it should be matching the second, causing the tests to fail.
What I'm looking for is a way of having the multiple requests match so the controller action completes and returns successfully.
The error I'm getting is at the end.
describe "POST /api/v1/users/:id/features/block" do
before(:each) do
#user = FactoryGirl.create(:user)
post :block, user_id: #user.id, block: "0"
end
it "should return 200 OK" do
expect(response).to be_success
end
end
Simplified versions of my VCR configuration and RSpec configuration follow:
VCR.configure do |c|
c.hook_into :webmock
c.default_cassette_options = {
match_requests_on: [:method, :uri, :body_regex]
}
c.register_request_matcher :body_regex do |request_1, request_2|
# Match body against regex if cassette body is "--ruby_regex /regexhere/"
if request_2.body[/^--ruby_regex\s*\//]
regex = request_2.body.gsub(/^--ruby_regex\s*\//, '').gsub(/\/$/, '')
request_1.body[/#{regex}/] ? true : false
else
true # No regex defined, continue processing
end
end
end
RSpec.configure do |c|
c.around(:each) do |example|
options = example.metadata[:vcr] || {}
name = example.metadata[:full_description].split(/\s+/, 2).join("/").underscore.gsub(/[^\w\/]+/, "_")
VCR.use_cassette(name, options, &example)
end
end
end
A summarized version of the cassette being used in this comparison that I'm having trouble with is:
---
http_interactions:
- request:
method: post
uri: https://upstream/api
body:
string: --ruby_regex /query1.+block/
response:
status:
code: 200
body:
string: { "response": "SUCCESS" }
- request:
method: post
uri: https://upstream/api
body:
string: --ruby_regex /query2.+block/
response:
status:
code: 200
body:
string: { "response": "SUCCESS" }
recorded_at: Fri, 05 Sep 2014 08:26:12 GMT
recorded_with: VCR 2.8.0
Error during tests:
An HTTP request has been made that VCR does not know how to handle
...
VCR is using the current cassette: (Correct cassette file path)
...
Under the current configuration VCR can not find a suitable HTTP interaction to replay and is prevented from recording new requests.
I don't want to record new requests because then the second one overwrites the first instead of adding the second request to the end of the cassette.

How do you test HttpAuthentication::Digest in rails 4?

I'm upgrading from rails 3 to rails 4 and trying to get digest authentication working based on this example:
http://lightyearsoftware.com/2009/04/testing-http-digest-authentication-in-rails/
It looks like the 'process_with_test' method was removed, so I think I can just override the controller's process method like this:
def authenticate_with_http_digest(user = API_USERNAME, password = API_PASSWORD, realm = API_REALM)
ActionController::Base.class_eval { include ActionController::Testing }
#controller.instance_eval %Q(
alias real_process process
def process(name)
credentials = {
:uri => request.url,
:realm => "#{realm}",
:username => "#{user}",
:nonce => ActionController::HttpAuthentication::Digest.nonce(Rails.configuration.secret_key_base),
:opaque => ActionController::HttpAuthentication::Digest.opaque(Rails.configuration.secret_key_base)
}
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Digest.encode_credentials(request.request_method, credentials, "#{password}", false)
real_process(name)
end
)
end
I can see the new method gets called, but I still get 401 access denied errors when I call the controller. I'm not sure I am creating the digest authentication correctly, but I don't know which part is incorrect. Does anyone have tips for debugging this?
I had the same issue. I read through the Rails 4 test cases and built the below solution. Its not perfect by any stretch of the imagination but it works in my test environment. It is a drop-in solution for the original authenticate_with_http_digest helper method.
Gist here:
https://gist.github.com/illoyd/9429839
And for posterity:
# This should go into spec/support/auth_spec_helpers.rb (if you are using RSpec)
module AuthSpecHelpers
##
# Convenience method for setting the Digest Authentication details.
# To use, pass the username and password.
# The method and target are used for the initial request to get the digest auth headers. These will be translated into 'get :index' for example.
# The final 'header' parameter sets the request's authentication headers.
def authenticate_with_http_digest(user, password, method = :get, target = :index, header = 'HTTP_AUTHORIZATION')
#request.env[header] = encode_credentials(username: user, password: password, method: method, target: target)
end
##
# Shamelessly stolen from the Rails 4 test framework.
# See https://github.com/rails/rails/blob/a3b1105ada3da64acfa3843b164b14b734456a50/actionpack/test/controller/http_digest_authentication_test.rb
def encode_credentials(options)
options.reverse_merge!(:nc => "00000001", :cnonce => "0a4f113b", :password_is_ha1 => false)
password = options.delete(:password)
# Perform unauthenticated request to retrieve digest parameters to use on subsequent request
method = options.delete(:method) || 'GET'
target = options.delete(:target) || :index
case method.to_s.upcase
when 'GET'
get target
when 'POST'
post target
end
assert_response :unauthorized
credentials = decode_credentials(#response.headers['WWW-Authenticate'])
credentials.merge!(options)
path_info = #request.env['PATH_INFO'].to_s
uri = options[:uri] || path_info
credentials.merge!(:uri => uri)
#request.env["ORIGINAL_FULLPATH"] = path_info
ActionController::HttpAuthentication::Digest.encode_credentials(method, credentials, password, options[:password_is_ha1])
end
##
# Also shamelessly stolen from the Rails 4 test framework.
# See https://github.com/rails/rails/blob/a3b1105ada3da64acfa3843b164b14b734456a50/actionpack/test/controller/http_digest_authentication_test.rb
def decode_credentials(header)
ActionController::HttpAuthentication::Digest.decode_credentials(header)
end
end
# Don't forget to add to rspec's config (spec/spec_helper.rb)
RSpec.configure do |config|
# Include auth digest helper
config.include AuthSpecHelpers, :type => :controller
end
Happy testing.

Resources