startback 2.1.1 → 2.1.3

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: 6faa6fe4856146f74795957a004c60e81db8a19a70ce6bcb874f9490c9012f99
4
+ data.tar.gz: 94debf9522beae906f8a725f9549d9acaebecbf6702969c1ea7ab73c6d0df926
5
5
  SHA512:
6
- metadata.gz: a12d45c8d149de30c27658638572bc32d9d5ba6d8fb0fd92a5a94a14681be318ce79473463476e5cd67c750639affbdb5d25aafc20ae4b3accd6a649a2762d20
7
- data.tar.gz: 8b3e16023edb27a7969984576b512974ad1a68277e9d08e94b8399b1c9c475a1063f0e5afd4b49141577a58ab00853ac53193bfa3d018875b87e3dec1a21d839
6
+ metadata.gz: 1e0e25409e12e35623aba0fc4dbdeaa9cdeef0cbe4f78b0a29b98c47445100776708b58e13ac28251d5a36b13cb9cb9323b6dcacda18df51c237eed903fe9766
7
+ data.tar.gz: b290c7942c9a985da8aa40904547b73dc7e6388bdb21c65a95bfb4d6ffd135577457f3914c61300c4a55d4dbf3052900d38be04862e18005a1461a1578264462
data/CHANGELOG.md CHANGED
@@ -1,3 +1,87 @@
1
+ ## 2.1.3 - 2026-10-29
2
+
3
+ A one-character fix to AutoCaching's production policy. **Nothing to do, and
4
+ nothing changes for an application that kept the default** -- but the knob it
5
+ unlocks is worth knowing about.
6
+
7
+ ### `s-maxage`, not `s-max-age`
8
+
9
+ The default production policy read `public, must-revalidate, max-age=3600,
10
+ s-max-age=3600`. There is no `s-max-age` directive: caches skip what they do
11
+ not recognise, so a shared cache had nothing but `max-age` to go by. Same
12
+ hour either way, which is why this went unnoticed -- the two values were
13
+ equal.
14
+
15
+ It matters the moment they should not be. A page whose reverse proxy should
16
+ hold it for an hour while the browser comes back in a minute is exactly what
17
+ `s-maxage` is for, and overriding the policy to say so had no effect on the
18
+ proxy:
19
+
20
+ ```ruby
21
+ # Now does what it says: one minute in the browser, one hour in nginx.
22
+ use Startback::Web::AutoCaching, production: "public, max-age=60, s-maxage=3600"
23
+ ```
24
+
25
+ Worth a look if your application already overrides
26
+ `PRODUCTION_CACHE_CONTROL`, or sets
27
+ `STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL`, with a `s-max-age` copied
28
+ from the default: it was being ignored, and now it is not.
29
+
30
+ ## 2.1.2 - 2026-10-27
31
+
32
+ A crawler could take a page down for everyone. **AutoCaching's fix needs
33
+ nothing from you**; the second half is a middleware to mount where it helps.
34
+
35
+ ### An error response is never made cacheable
36
+
37
+ AutoCaching stamped its production policy -- `public, must-revalidate,
38
+ max-age=3600, s-max-age=3600` by default -- on every response that carried
39
+ none of its own, a 4xx or a 5xx included. Whether a request is answered with
40
+ an error usually depends on that very request: a header, a cookie, a
41
+ credential. A cache in front of the application keys on the url alone, so one
42
+ client's error was handed to everyone who asked for the url next, for an
43
+ hour. Seen in the wild on a marketing site's home page, 400ing for every
44
+ visitor behind an nginx `proxy_cache` because a crawler had asked once with a
45
+ header it had no business sending.
46
+
47
+ A response of status 400 and up now carries `no-store`, in development and in
48
+ production alike. `no-store` rather than no header at all, because 404 and
49
+ 410 are heuristically cacheable (RFC 9111) and a shared cache may keep them
50
+ with nothing else to go by. An application that sets its own `Cache-Control`
51
+ on an error still wins, as it does on a success.
52
+
53
+ Configurable like the other two policies, for an application that does want
54
+ its errors cached:
55
+
56
+ ```ruby
57
+ use Startback::Web::AutoCaching, error: "public, max-age=60"
58
+ ```
59
+
60
+ or through `STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL`.
61
+
62
+ ### `Web::IgnoreContentTypeWithoutBody`
63
+
64
+ What produced that 400 in the first place: a `Content-Type:
65
+ multipart/form-data; boundary=...` on a plain GET makes Rack parse a form out
66
+ of a body that is not there and raise `Rack::Multipart::EmptyContentError`,
67
+ which Sinatra turns into a 400 before the route is ever reached. The url
68
+ answers an error that has nothing to do with what it serves, and crawlers
69
+ send that header on GETs all day. Rack is inconsistent rather than strict
70
+ here: the same request with `Content-Length: 0` parses to no parameters at
71
+ all.
72
+
73
+ The new middleware drops the content type of a GET or a HEAD, so the route
74
+ runs and the page is served:
75
+
76
+ ```ruby
77
+ use Startback::Web::IgnoreContentTypeWithoutBody
78
+ ```
79
+
80
+ Opt-in, like every middleware here. An application with a GET route that
81
+ loads a body -- `Web::Api#loaded_body` dispatches on the request's content
82
+ type -- would see that body become unsupported. A GET that reads a content
83
+ type at all is unusual, content negotiation being `Accept`'s job.
84
+
1
85
  ## 2.1.1 - 2026-10-23
2
86
 
3
87
  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 = 3
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,15 +42,29 @@ 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
37
51
  DEVELOPMENT_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_DEVELOPMENT_CACHE_CONTROL'] || \
38
52
  "no-cache, no-store, max-age=0, must-revalidate"
39
53
 
40
- # Cache-Control header to use in production mode
54
+ # Cache-Control header to use in production mode. `s-maxage`, and not
55
+ # `s-max-age`: the latter is not a directive any cache knows, so it was
56
+ # silently dropped and a shared cache had nothing but `max-age` to go by
57
+ # -- the very knob an application needs to keep a page in its reverse
58
+ # proxy for an hour while the browser comes back in a minute.
41
59
  PRODUCTION_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL'] ||\
42
- "public, must-revalidate, max-age=3600, s-max-age=3600"
60
+ "public, must-revalidate, max-age=3600, s-maxage=3600"
61
+
62
+ # Cache-Control header to use on an error response, in either mode
63
+ ERROR_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL'] ||\
64
+ "no-store"
65
+
66
+ # Lowest status this middleware considers an error
67
+ ERROR_STATUS = 400
43
68
 
44
69
  def initialize(app, development = nil, cache_headers = {})
45
70
  development, cache_headers = nil, development if development.is_a?(Hash)
@@ -50,7 +75,7 @@ module Startback
50
75
 
51
76
  def call(env)
52
77
  status, headers, body = @app.call(env)
53
- [status, patch_response_headers(headers), body]
78
+ [status, patch_response_headers(status, headers), body]
54
79
  end
55
80
 
56
81
  protected
@@ -58,9 +83,14 @@ module Startback
58
83
  # Rack::Headers is used so that the defaults set here are actually
59
84
  # overriden by the downstream application, whatever the case it uses
60
85
  # 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)
86
+ def patch_response_headers(status, hs)
87
+ Rack::Headers[defaults_for(status)].merge(hs)
88
+ end
89
+
90
+ def defaults_for(status)
91
+ return @cache_headers[:error] if status >= ERROR_STATUS
92
+
93
+ development? ? @cache_headers[:development] : @cache_headers[:production]
64
94
  end
65
95
 
66
96
  def development?
@@ -78,6 +108,9 @@ module Startback
78
108
  },
79
109
  production: {
80
110
  "cache-control" => PRODUCTION_CACHE_CONTROL
111
+ },
112
+ error: {
113
+ "cache-control" => ERROR_CACHE_CONTROL
81
114
  }
82
115
  }
83
116
  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
@@ -30,7 +30,7 @@ module Startback
30
30
 
31
31
  it 'sets the production Cache-Control' do
32
32
  get '/'
33
- expect(last_response['Cache-Control']). to eql("public, must-revalidate, max-age=3600, s-max-age=3600")
33
+ expect(last_response['Cache-Control']). to eql("public, must-revalidate, max-age=3600, s-maxage=3600")
34
34
  end
35
35
  end
36
36
 
@@ -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-maxage=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.3
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-29 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: