startback 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 618541ed038c6a619973934e3430a23eebd0ac9606f91d2535fbd7354e2835b1
4
- data.tar.gz: 0161c46f4272637e7ae3862eca10d5098c2f109cc28ef9e019826ca2d627aa79
3
+ metadata.gz: 8d29c7de7c7ebfb8c5cc68d73da62108b88cee7e24f0e59712601ed8ca8d776f
4
+ data.tar.gz: 4b3e850a0465313739cb45662983e6af17edeb3dce768f2dcb4a9227085a8362
5
5
  SHA512:
6
- metadata.gz: a12d45c8d149de30c27658638572bc32d9d5ba6d8fb0fd92a5a94a14681be318ce79473463476e5cd67c750639affbdb5d25aafc20ae4b3accd6a649a2762d20
7
- data.tar.gz: 8b3e16023edb27a7969984576b512974ad1a68277e9d08e94b8399b1c9c475a1063f0e5afd4b49141577a58ab00853ac53193bfa3d018875b87e3dec1a21d839
6
+ metadata.gz: 2f6b32627f15af41212ec13844db638f0d78bb5786d5151e6b0fab86b522ae294ede73bffb6cf2aff839f805eac9c85a9a6dde70a723f35d3b8da35124019c6d
7
+ data.tar.gz: 0070c3a2dc460fe286090ebaf0428a8697b2d0429df770542eda1b5c9f69e6c733caad05e230ab6be5d12ac75967a37be35b7264c28d3fc1f05da0c5f003e328
data/CHANGELOG.md CHANGED
@@ -1,3 +1,58 @@
1
+ ## 2.1.2 - 2026-10-27
2
+
3
+ A crawler could take a page down for everyone. **AutoCaching's fix needs
4
+ nothing from you**; the second half is a middleware to mount where it helps.
5
+
6
+ ### An error response is never made cacheable
7
+
8
+ AutoCaching stamped its production policy -- `public, must-revalidate,
9
+ max-age=3600, s-max-age=3600` by default -- on every response that carried
10
+ none of its own, a 4xx or a 5xx included. Whether a request is answered with
11
+ an error usually depends on that very request: a header, a cookie, a
12
+ credential. A cache in front of the application keys on the url alone, so one
13
+ client's error was handed to everyone who asked for the url next, for an
14
+ hour. Seen in the wild on a marketing site's home page, 400ing for every
15
+ visitor behind an nginx `proxy_cache` because a crawler had asked once with a
16
+ header it had no business sending.
17
+
18
+ A response of status 400 and up now carries `no-store`, in development and in
19
+ production alike. `no-store` rather than no header at all, because 404 and
20
+ 410 are heuristically cacheable (RFC 9111) and a shared cache may keep them
21
+ with nothing else to go by. An application that sets its own `Cache-Control`
22
+ on an error still wins, as it does on a success.
23
+
24
+ Configurable like the other two policies, for an application that does want
25
+ its errors cached:
26
+
27
+ ```ruby
28
+ use Startback::Web::AutoCaching, error: "public, max-age=60"
29
+ ```
30
+
31
+ or through `STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL`.
32
+
33
+ ### `Web::IgnoreContentTypeWithoutBody`
34
+
35
+ What produced that 400 in the first place: a `Content-Type:
36
+ multipart/form-data; boundary=...` on a plain GET makes Rack parse a form out
37
+ of a body that is not there and raise `Rack::Multipart::EmptyContentError`,
38
+ which Sinatra turns into a 400 before the route is ever reached. The url
39
+ answers an error that has nothing to do with what it serves, and crawlers
40
+ send that header on GETs all day. Rack is inconsistent rather than strict
41
+ here: the same request with `Content-Length: 0` parses to no parameters at
42
+ all.
43
+
44
+ The new middleware drops the content type of a GET or a HEAD, so the route
45
+ runs and the page is served:
46
+
47
+ ```ruby
48
+ use Startback::Web::IgnoreContentTypeWithoutBody
49
+ ```
50
+
51
+ Opt-in, like every middleware here. An application with a GET route that
52
+ loads a body -- `Web::Api#loaded_body` dispatches on the request's content
53
+ type -- would see that body become unsupported. A GET that reads a content
54
+ type at all is unusual, content negotiation being `Accept`'s job.
55
+
1
56
  ## 2.1.1 - 2026-10-23
2
57
 
3
58
  The Bunny event bus keeps consuming across a broker restart. **Nothing to do
@@ -2,7 +2,7 @@ module Startback
2
2
  module Version
3
3
  MAJOR = 2
4
4
  MINOR = 1
5
- TINY = 1
5
+ TINY = 2
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
@@ -6,9 +6,18 @@ module Startback
6
6
  # This rack middleware automatically mark response as non being cacheable
7
7
  # in development, and being cacheble in production.
8
8
  #
9
- # The headers to set in development and production can be passed at
10
- # construction, as well as whether the development environment must be
11
- # forced. This class may also be configured through environment variables:
9
+ # An error response is never made cacheable, in either mode. Whether a
10
+ # request is answered 4xx usually depends on that very request -- a header,
11
+ # a cookie, a credential -- while a cache in front of the application keys
12
+ # on the url alone, so one client's error would otherwise be served to
13
+ # everyone asking for the url next. It is marked `no-store` rather than
14
+ # merely left alone, because 404 and 410 are heuristically cacheable
15
+ # (RFC 9111) and a shared cache may keep them with no header to go by.
16
+ #
17
+ # The headers to set in development, in production and on an error can be
18
+ # passed at construction, as well as whether the development environment
19
+ # must be forced. This class may also be configured through environment
20
+ # variables:
12
21
  #
13
22
  # - RACK_ENV: when "production" use the production headers, otherwise use
14
23
  # the development ones
@@ -16,6 +25,8 @@ module Startback
16
25
  # to use in development mode
17
26
  # - STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL: Cache-Control header
18
27
  # to use in production mode
28
+ # - STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL: Cache-Control header to use
29
+ # on a response of status 400 and up
19
30
  #
20
31
  # Example:
21
32
  #
@@ -31,6 +42,9 @@ module Startback
31
42
  # # Set production headers manually
32
43
  # use Autocaching, { :production => "public, no-cache, no-store" }
33
44
  #
45
+ # # Let a shared cache keep a not found for a minute
46
+ # use Autocaching, { :error => "public, max-age=60" }
47
+ #
34
48
  class AutoCaching
35
49
 
36
50
  # Cache-Control header to use in development mode
@@ -41,6 +55,13 @@ module Startback
41
55
  PRODUCTION_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL'] ||\
42
56
  "public, must-revalidate, max-age=3600, s-max-age=3600"
43
57
 
58
+ # Cache-Control header to use on an error response, in either mode
59
+ ERROR_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL'] ||\
60
+ "no-store"
61
+
62
+ # Lowest status this middleware considers an error
63
+ ERROR_STATUS = 400
64
+
44
65
  def initialize(app, development = nil, cache_headers = {})
45
66
  development, cache_headers = nil, development if development.is_a?(Hash)
46
67
  @app = app
@@ -50,7 +71,7 @@ module Startback
50
71
 
51
72
  def call(env)
52
73
  status, headers, body = @app.call(env)
53
- [status, patch_response_headers(headers), body]
74
+ [status, patch_response_headers(status, headers), body]
54
75
  end
55
76
 
56
77
  protected
@@ -58,9 +79,14 @@ module Startback
58
79
  # Rack::Headers is used so that the defaults set here are actually
59
80
  # overriden by the downstream application, whatever the case it uses
60
81
  # for its own header names.
61
- def patch_response_headers(hs)
62
- defaults = development? ? @cache_headers[:development] : @cache_headers[:production]
63
- Rack::Headers[defaults].merge(hs)
82
+ def patch_response_headers(status, hs)
83
+ Rack::Headers[defaults_for(status)].merge(hs)
84
+ end
85
+
86
+ def defaults_for(status)
87
+ return @cache_headers[:error] if status >= ERROR_STATUS
88
+
89
+ development? ? @cache_headers[:development] : @cache_headers[:production]
64
90
  end
65
91
 
66
92
  def development?
@@ -78,6 +104,9 @@ module Startback
78
104
  },
79
105
  production: {
80
106
  "cache-control" => PRODUCTION_CACHE_CONTROL
107
+ },
108
+ error: {
109
+ "cache-control" => ERROR_CACHE_CONTROL
81
110
  }
82
111
  }
83
112
  end
@@ -0,0 +1,48 @@
1
+ require 'rack'
2
+
3
+ module Startback
4
+ module Web
5
+ #
6
+ # This Rack middleware drops the content type of a request that carries no
7
+ # body, that is, of a GET or a HEAD.
8
+ #
9
+ # A `Content-Type: multipart/form-data; boundary=...` on a GET makes Rack
10
+ # parse a form out of a body that is not there and raise
11
+ # `Rack::Multipart::EmptyContentError`, which Sinatra turns into a 400
12
+ # before the route is ever reached. The answer therefore has nothing to do
13
+ # with what the url serves, and crawlers send exactly that header. Rack is
14
+ # inconsistent here rather than strict: the same request with
15
+ # `Content-Length: 0` parses to no parameters at all.
16
+ #
17
+ # Mount it on an application whose GETs and HEADs ignore the request's
18
+ # content type, which is the usual case -- content negotiation reads
19
+ # `Accept`. An application with a GET route that loads a body (see
20
+ # `Startback::Web::Api#loaded_body`) would see that body become
21
+ # unsupported, hence the opt-in.
22
+ #
23
+ # Examples:
24
+ #
25
+ # Rack::Builder.new do
26
+ # use Startback::Web::IgnoreContentTypeWithoutBody
27
+ # end
28
+ #
29
+ class IgnoreContentTypeWithoutBody
30
+
31
+ BODYLESS_METHODS = %w[GET HEAD]
32
+
33
+ def self.ignore_content_type?(env)
34
+ BODYLESS_METHODS.include?(env["REQUEST_METHOD"]) && env.key?("CONTENT_TYPE")
35
+ end
36
+
37
+ def initialize(app)
38
+ @app = app
39
+ end
40
+
41
+ def call(env)
42
+ env.delete("CONTENT_TYPE") if self.class.ignore_content_type?(env)
43
+ @app.call(env)
44
+ end
45
+
46
+ end # class IgnoreContentTypeWithoutBody
47
+ end # module Web
48
+ end # module Startback
@@ -62,6 +62,64 @@ module Startback
62
62
  end
63
63
  end
64
64
 
65
+ context 'when the application answers an error in production' do
66
+ def cache_control_for(status)
67
+ app = Rack::Builder.new do
68
+ use AutoCaching, false
69
+ run ->(env){ [status, {}, ["Hello error"]] }
70
+ end.to_app
71
+ _, headers, _ = app.call(Rack::MockRequest.env_for("/"))
72
+ headers["cache-control"]
73
+ end
74
+
75
+ # A cache in front of the application keys on the url, while the error
76
+ # is about this request; the production policy would hand it to every
77
+ # later visitor of the url.
78
+ it 'forbids storing a bad request' do
79
+ expect(cache_control_for(400)).to eql("no-store")
80
+ end
81
+
82
+ it 'forbids storing a not found, heuristically cacheable otherwise' do
83
+ expect(cache_control_for(404)).to eql("no-store")
84
+ end
85
+
86
+ it 'forbids storing a server error' do
87
+ expect(cache_control_for(500)).to eql("no-store")
88
+ end
89
+
90
+ it 'still makes a redirect cacheable' do
91
+ expect(cache_control_for(301)).to eql("public, must-revalidate, max-age=3600, s-max-age=3600")
92
+ end
93
+ end
94
+
95
+ context 'when the error policy is set manually' do
96
+ def app
97
+ Rack::Builder.new do
98
+ use AutoCaching, false, error: "public, max-age=60"
99
+ run ->(env){ [404, {}, ["Hello error"]] }
100
+ end
101
+ end
102
+
103
+ it 'uses it rather than the default one' do
104
+ get '/'
105
+ expect(last_response['Cache-Control']).to eql("public, max-age=60")
106
+ end
107
+ end
108
+
109
+ context 'when the application sets its own Cache-Control on an error' do
110
+ def app
111
+ Rack::Builder.new do
112
+ use AutoCaching, false
113
+ run ->(env){ [404, {"cache-control" => "public, max-age=60"}, ["Hello error"]] }
114
+ end
115
+ end
116
+
117
+ it 'lets the application win, as on a success' do
118
+ get '/'
119
+ expect(last_response['Cache-Control']).to eql("public, max-age=60")
120
+ end
121
+ end
122
+
65
123
  context 'when a Cache-Control header is already set by the app' do
66
124
  def app
67
125
  Rack::Builder.new do
@@ -0,0 +1,91 @@
1
+ require 'spec_helper'
2
+ require 'sinatra/base'
3
+ require 'startback/web/ignore_content_type_without_body'
4
+
5
+ module Startback
6
+ module Web
7
+ describe IgnoreContentTypeWithoutBody do
8
+ include Rack::Test::Methods
9
+
10
+ MULTIPART_CTYPE = "multipart/form-data; boundary=----WebKitFormBoundaryJ2mHj"
11
+
12
+ context 'what the application sees' do
13
+ # Reported as a header rather than a body, so that a HEAD can be
14
+ # asserted on the same way as a GET.
15
+ def app
16
+ Rack::Builder.new do
17
+ use IgnoreContentTypeWithoutBody
18
+ run ->(env){ [200, { "x-seen-content-type" => env["CONTENT_TYPE"].to_s }, []] }
19
+ end
20
+ end
21
+
22
+ def seen_content_type
23
+ last_response["x-seen-content-type"]
24
+ end
25
+
26
+ it 'hides the content type a GET has no business sending' do
27
+ get '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
28
+ expect(seen_content_type).to eql("")
29
+ end
30
+
31
+ it 'hides it from a HEAD too' do
32
+ head '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
33
+ expect(seen_content_type).to eql("")
34
+ end
35
+
36
+ it 'leaves the content type of a POST alone, body or no body' do
37
+ post '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
38
+ expect(seen_content_type).to eql(MULTIPART_CTYPE)
39
+ end
40
+ end
41
+
42
+ # Rack only parses the form, and only raises, when a body is there to
43
+ # read and no content length says how long it is -- which is what a
44
+ # server hands over for a GET. Rack::Test provides neither on its own.
45
+ def get_with_bogus_content_type
46
+ get '/', {}, {
47
+ "CONTENT_TYPE" => MULTIPART_CTYPE,
48
+ "rack.input" => StringIO.new('')
49
+ }
50
+ end
51
+
52
+ class Page < Sinatra::Base
53
+ set :raise_errors, true
54
+ set :show_exceptions, false
55
+
56
+ get('/') { "hello" }
57
+ end
58
+
59
+ context 'on a sinatra application that serves a GET' do
60
+ def app
61
+ Rack::Builder.new do
62
+ use IgnoreContentTypeWithoutBody
63
+ run Page
64
+ end
65
+ end
66
+
67
+ it 'serves the page instead of raising on the absent body' do
68
+ get_with_bogus_content_type
69
+ expect(last_response.status).to eql(200)
70
+ expect(last_response.body).to eql("hello")
71
+ end
72
+ end
73
+
74
+ context 'on the same application, unprotected' do
75
+ def app
76
+ Page
77
+ end
78
+
79
+ # Which is what this middleware exists for. The day Rack stops telling
80
+ # an empty multipart body apart from a missing one, this fails and the
81
+ # middleware can go.
82
+ it 'answers a 400 that has nothing to do with the url' do
83
+ get_with_bogus_content_type
84
+ expect(last_response.status).to eql(400)
85
+ expect(last_response.body).to include("Invalid multipart/form-data")
86
+ end
87
+ end
88
+
89
+ end # IgnoreContentTypeWithoutBody
90
+ end # module Web
91
+ end # module Startback
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: startback
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bernard Lambeau
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-23 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -529,6 +529,7 @@ files:
529
529
  - lib/startback/web/catch_all.rb
530
530
  - lib/startback/web/cors_headers.rb
531
531
  - lib/startback/web/health_check.rb
532
+ - lib/startback/web/ignore_content_type_without_body.rb
532
533
  - lib/startback/web/middleware.rb
533
534
  - lib/startback/web/prometheus.rb
534
535
  - lib/startback/web/shield.rb
@@ -569,6 +570,7 @@ files:
569
570
  - spec/unit/web/test_catch_all.rb
570
571
  - spec/unit/web/test_cors_headers.rb
571
572
  - spec/unit/web/test_healthcheck.rb
573
+ - spec/unit/web/test_ignore_content_type_without_body.rb
572
574
  - tasks/test.rake
573
575
  homepage: https://www.enspirit.be
574
576
  licenses: