debugbundle 0.1.2 → 0.1.4
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/lib/debugbundle/rack/relay_middleware.rb +1 -1
- data/lib/debugbundle/rails/railtie.rb +3 -0
- data/lib/debugbundle/relay/handler.rb +95 -39
- data/lib/debugbundle/version.rb +1 -1
- data/spec/relay_spec.rb +62 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2430cc8a51d6a72fa6e52896ac7b7c1be9ccc810fab56e154aae1bc76bc4aa6
|
|
4
|
+
data.tar.gz: 04a32dd64f1bb5b39ebb636c5db76eee2148ae7be3b888d15e0e351734dd2084
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 960d4640460f35d27c18cdf20529a9c57f0d68fcd1225d3d6d571579d6007103ecb9be21596db8946dc88e6e3aa857f8bdb191f9724fe76baa6dfd701d53d25f
|
|
7
|
+
data.tar.gz: c6fec66551b3b37eb6b84829f0bb567813d6ae81a16e752851f02cabffb6d5b4911b28576b07ad6ea6e024f6a0a3daa9e5db78bfd96e21e063521ae732554fe0
|
|
@@ -19,7 +19,7 @@ module DebugBundle
|
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
body = response.body ? JSON.generate(response.body) : ''
|
|
22
|
-
[response.status, { 'Content-Type' => 'application/json' }, [body]]
|
|
22
|
+
[response.status, { 'Content-Type' => 'application/json' }.merge(response.headers || {}), [body]]
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
private
|
|
@@ -23,6 +23,9 @@ if defined?(Rails::Railtie)
|
|
|
23
23
|
app.middleware.use(DebugBundle::Rack::Middleware, client: client)
|
|
24
24
|
if DebugBundle::Rails.relay_route_enabled?(app)
|
|
25
25
|
app.routes.append do
|
|
26
|
+
match DebugBundle::Rails.relay_path(app),
|
|
27
|
+
to: DebugBundle::Rails::RelayEndpoint.new(app: app),
|
|
28
|
+
via: :options
|
|
26
29
|
post DebugBundle::Rails.relay_path(app), to: DebugBundle::Rails::RelayEndpoint.new(app: app)
|
|
27
30
|
end
|
|
28
31
|
end
|
|
@@ -16,7 +16,7 @@ module DebugBundle
|
|
|
16
16
|
DEFAULT_MAX_BODY_BYTES = 262_144
|
|
17
17
|
DEFAULT_RATE_LIMIT_PER_MINUTE = 60
|
|
18
18
|
|
|
19
|
-
Response = Struct.new(:status, :body, keyword_init: true)
|
|
19
|
+
Response = Struct.new(:status, :body, :headers, keyword_init: true)
|
|
20
20
|
|
|
21
21
|
class Handler
|
|
22
22
|
def initialize(
|
|
@@ -51,57 +51,94 @@ module DebugBundle
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def handle(request)
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
method = request.fetch(:method, 'POST').to_s.upcase
|
|
56
55
|
headers = normalize_headers(request[:headers] || {})
|
|
57
|
-
return Response.new(status: 403, body: nil) unless origin_allowed?(headers)
|
|
58
|
-
unless json_content_type?(headers['content-type'])
|
|
59
|
-
return Response.new(status: 400,
|
|
60
|
-
body: invalid_body('Relay requests must use Content-Type: application/json.'))
|
|
61
|
-
end
|
|
62
|
-
|
|
63
56
|
raw_body = request[:body].to_s
|
|
64
|
-
return Response.new(status:
|
|
65
|
-
return Response.new(status: 429, body: nil) if rate_limited?(request[:ip_address] || request[:ip])
|
|
66
|
-
|
|
67
|
-
decoded = JSON.parse(raw_body)
|
|
68
|
-
batch = decoded.fetch('batch')
|
|
69
|
-
unless batch.is_a?(Array)
|
|
70
|
-
return Response.new(status: 400,
|
|
71
|
-
body: invalid_body('Relay request body must include a batch array.'))
|
|
72
|
-
end
|
|
57
|
+
return Response.new(status: 403, body: nil, headers: {}) unless origin_allowed?(headers)
|
|
73
58
|
|
|
74
|
-
|
|
75
|
-
|
|
59
|
+
response_headers = source_origin(headers) ? cors_headers(source_origin(headers)) : {}
|
|
60
|
+
early_response = request_validation_response(
|
|
61
|
+
method: method,
|
|
62
|
+
headers: headers,
|
|
63
|
+
raw_body: raw_body,
|
|
64
|
+
ip_address: request[:ip_address] || request[:ip]
|
|
65
|
+
)
|
|
66
|
+
return with_headers(early_response, response_headers) if early_response
|
|
76
67
|
|
|
77
|
-
|
|
78
|
-
sanitized = sanitize_event(candidate)
|
|
79
|
-
if sanitized
|
|
80
|
-
accepted << sanitized
|
|
81
|
-
else
|
|
82
|
-
errors << "batch[#{index}]: Invalid browser relay event payload."
|
|
83
|
-
end
|
|
84
|
-
end
|
|
68
|
+
accepted, errors = sanitize_batch(raw_body)
|
|
85
69
|
|
|
86
70
|
deliver(accepted) unless accepted.empty?
|
|
87
71
|
|
|
88
|
-
|
|
89
|
-
return Response.new(status: 400,
|
|
90
|
-
body: { 'accepted' => accepted.length,
|
|
91
|
-
'rejected' => errors.length, 'errors' => errors })
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
Response.new(status: 202, body: { 'accepted' => accepted.length, 'rejected' => 0, 'errors' => [] })
|
|
72
|
+
with_headers(batch_response(accepted, errors), response_headers)
|
|
95
73
|
rescue JSON::ParserError
|
|
96
|
-
|
|
74
|
+
with_headers(invalid_json_response, response_headers || {})
|
|
97
75
|
rescue KeyError
|
|
98
|
-
|
|
76
|
+
with_headers(missing_batch_response, response_headers || {})
|
|
99
77
|
rescue StandardError
|
|
100
|
-
Response.new(status: 500, body: nil)
|
|
78
|
+
with_headers(Response.new(status: 500, body: nil), response_headers || {})
|
|
101
79
|
end
|
|
102
80
|
|
|
103
81
|
private
|
|
104
82
|
|
|
83
|
+
def with_headers(response, headers)
|
|
84
|
+
Response.new(status: response.status, body: response.body, headers: headers.merge(response.headers || {}))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def request_validation_response(method:, headers:, raw_body:, ip_address:)
|
|
88
|
+
return Response.new(status: 204, body: nil) if method == 'OPTIONS'
|
|
89
|
+
return Response.new(status: 405, body: nil) unless method == 'POST'
|
|
90
|
+
return invalid_content_type_response unless json_content_type?(headers['content-type'])
|
|
91
|
+
return Response.new(status: 413, body: nil) if raw_body.bytesize > @max_body_bytes
|
|
92
|
+
return Response.new(status: 429, body: nil) if rate_limited?(ip_address)
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def invalid_content_type_response
|
|
98
|
+
Response.new(
|
|
99
|
+
status: 400,
|
|
100
|
+
body: invalid_body('Relay requests must use Content-Type: application/json.')
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def invalid_json_response
|
|
105
|
+
Response.new(status: 400, body: invalid_body('Relay request body must be valid JSON.'))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def missing_batch_response
|
|
109
|
+
Response.new(status: 400, body: invalid_body('Relay request body must include a batch array.'))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def batch_response(accepted, errors)
|
|
113
|
+
return invalid_batch_response(accepted, errors) unless errors.empty?
|
|
114
|
+
|
|
115
|
+
Response.new(
|
|
116
|
+
status: 202,
|
|
117
|
+
body: { 'accepted' => accepted.length, 'rejected' => 0, 'errors' => [] }
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def invalid_batch_response(accepted, errors)
|
|
122
|
+
Response.new(
|
|
123
|
+
status: 400,
|
|
124
|
+
body: {
|
|
125
|
+
'accepted' => accepted.length,
|
|
126
|
+
'rejected' => errors.length,
|
|
127
|
+
'errors' => errors
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def cors_headers(origin)
|
|
133
|
+
{
|
|
134
|
+
'Access-Control-Allow-Origin' => origin,
|
|
135
|
+
'Access-Control-Allow-Methods' => 'POST, OPTIONS',
|
|
136
|
+
'Access-Control-Allow-Headers' => 'content-type',
|
|
137
|
+
'Access-Control-Max-Age' => '600',
|
|
138
|
+
'Vary' => 'Origin'
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
105
142
|
def deliver(events)
|
|
106
143
|
service_name = @service || events.first.dig('service', 'name') || 'service'
|
|
107
144
|
|
|
@@ -118,6 +155,21 @@ module DebugBundle
|
|
|
118
155
|
end
|
|
119
156
|
end
|
|
120
157
|
|
|
158
|
+
def sanitize_batch(raw_body)
|
|
159
|
+
decoded = JSON.parse(raw_body)
|
|
160
|
+
batch = decoded.fetch('batch')
|
|
161
|
+
raise KeyError unless batch.is_a?(Array)
|
|
162
|
+
|
|
163
|
+
batch.each_with_index.with_object([[], []]) do |(candidate, index), (accepted, errors)|
|
|
164
|
+
sanitized = sanitize_event(candidate)
|
|
165
|
+
if sanitized
|
|
166
|
+
accepted << sanitized
|
|
167
|
+
else
|
|
168
|
+
errors << "batch[#{index}]: Invalid browser relay event payload."
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
121
173
|
def sanitize_event(candidate)
|
|
122
174
|
return nil unless candidate.is_a?(Hash)
|
|
123
175
|
|
|
@@ -175,7 +227,7 @@ module DebugBundle
|
|
|
175
227
|
end
|
|
176
228
|
|
|
177
229
|
def origin_allowed?(headers)
|
|
178
|
-
origin =
|
|
230
|
+
origin = source_origin(headers)
|
|
179
231
|
return false if origin.nil? || origin.empty?
|
|
180
232
|
|
|
181
233
|
return @allowed_origins.include?(normalize_origin(origin)) if @allowed_origins.any?
|
|
@@ -188,6 +240,10 @@ module DebugBundle
|
|
|
188
240
|
false
|
|
189
241
|
end
|
|
190
242
|
|
|
243
|
+
def source_origin(headers)
|
|
244
|
+
headers['origin'] || origin_from_referer(headers['referer'])
|
|
245
|
+
end
|
|
246
|
+
|
|
191
247
|
def origin_from_referer(referer)
|
|
192
248
|
return nil if referer.to_s.empty?
|
|
193
249
|
|
data/lib/debugbundle/version.rb
CHANGED
data/spec/relay_spec.rb
CHANGED
|
@@ -73,6 +73,46 @@ RSpec.describe DebugBundle::Relay::Handler do
|
|
|
73
73
|
expect(response.status).to eq(403)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
it 'answers allowed cross-origin preflight without delivering events' do
|
|
77
|
+
handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
|
|
78
|
+
allowed_origins: ['https://web.example.com'])
|
|
79
|
+
response = handler.handle(
|
|
80
|
+
method: 'OPTIONS',
|
|
81
|
+
headers: {
|
|
82
|
+
'host' => 'api.example.com',
|
|
83
|
+
'origin' => 'https://web.example.com',
|
|
84
|
+
'access-control-request-method' => 'POST',
|
|
85
|
+
'access-control-request-headers' => 'content-type'
|
|
86
|
+
},
|
|
87
|
+
body: '',
|
|
88
|
+
ip_address: '127.0.0.1'
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(response.status).to eq(204)
|
|
92
|
+
expect(response.headers).to include(
|
|
93
|
+
'Access-Control-Allow-Origin' => 'https://web.example.com',
|
|
94
|
+
'Access-Control-Allow-Methods' => 'POST, OPTIONS'
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'adds CORS headers to accepted cross-origin relay posts' do
|
|
99
|
+
handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
|
|
100
|
+
allowed_origins: ['https://web.example.com'])
|
|
101
|
+
response = handler.handle(
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'host' => 'api.example.com',
|
|
105
|
+
'origin' => 'https://web.example.com',
|
|
106
|
+
'content-type' => 'application/json'
|
|
107
|
+
},
|
|
108
|
+
body: JSON.generate('batch' => [browser_event]),
|
|
109
|
+
ip_address: '127.0.0.1'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(response.status).to eq(202)
|
|
113
|
+
expect(response.headers).to include('Access-Control-Allow-Origin' => 'https://web.example.com', 'Vary' => 'Origin')
|
|
114
|
+
end
|
|
115
|
+
|
|
76
116
|
it 'supports a shared rate-limit store interface' do
|
|
77
117
|
Dir.mktmpdir do |directory|
|
|
78
118
|
store = Class.new do
|
|
@@ -172,7 +212,29 @@ RSpec.describe DebugBundle::Relay::Handler do
|
|
|
172
212
|
|
|
173
213
|
expect(status).to eq(202)
|
|
174
214
|
expect(headers).to include('Content-Type' => 'application/json')
|
|
215
|
+
expect(headers).to include('Access-Control-Allow-Origin' => 'https://app.example.com')
|
|
175
216
|
expect(JSON.parse(body.fetch(0))).to include('accepted' => 1)
|
|
176
217
|
end
|
|
177
218
|
end
|
|
219
|
+
|
|
220
|
+
it 'exposes Rack preflight headers' do
|
|
221
|
+
middleware = DebugBundle::Rack::RelayMiddleware.new(
|
|
222
|
+
nil,
|
|
223
|
+
handler: described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
|
|
224
|
+
allowed_origins: ['https://web.example.com'])
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
status, headers, body = middleware.call(
|
|
228
|
+
'REQUEST_METHOD' => 'OPTIONS',
|
|
229
|
+
'HTTP_HOST' => 'api.example.com',
|
|
230
|
+
'HTTP_ORIGIN' => 'https://web.example.com',
|
|
231
|
+
'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST',
|
|
232
|
+
'REMOTE_ADDR' => '127.0.0.1',
|
|
233
|
+
'rack.input' => StringIO.new('')
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
expect(status).to eq(204)
|
|
237
|
+
expect(headers).to include('Access-Control-Allow-Origin' => 'https://web.example.com')
|
|
238
|
+
expect(body.fetch(0)).to eq('')
|
|
239
|
+
end
|
|
178
240
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: debugbundle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- DebugBundle
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-05-
|
|
10
|
+
date: 2026-05-29 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: base64
|