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 +4 -4
- data/CHANGELOG.md +55 -0
- data/lib/startback/version.rb +1 -1
- data/lib/startback/web/auto_caching.rb +36 -7
- data/lib/startback/web/ignore_content_type_without_body.rb +48 -0
- data/spec/unit/web/test_auto_caching.rb +58 -0
- data/spec/unit/web/test_ignore_content_type_without_body.rb +91 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d29c7de7c7ebfb8c5cc68d73da62108b88cee7e24f0e59712601ed8ca8d776f
|
|
4
|
+
data.tar.gz: 4b3e850a0465313739cb45662983e6af17edeb3dce768f2dcb4a9227085a8362
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/startback/version.rb
CHANGED
|
@@ -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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
63
|
-
|
|
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.
|
|
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-
|
|
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:
|