utopia 3.0.3 → 3.0.5

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.
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2016-2025, by Samuel Williams.
4
+ # Copyright, 2016-2026, by Samuel Williams.
5
5
 
6
6
  require "net/smtp"
7
7
  require "mail"
8
+ require "console"
9
+ require "stringio"
10
+ require "yaml"
8
11
 
9
12
  require_relative "../middleware"
10
13
  require_relative "../request"
11
- require_relative "handler"
14
+ require_relative "application_errors"
12
15
 
13
16
  module Utopia
14
17
  module Exceptions
@@ -23,20 +26,33 @@ module Utopia
23
26
 
24
27
  DEFAULT_FROM = (ENV["USER"] || "utopia").freeze
25
28
  DEFAULT_SUBJECT = "%{exception} [PID %{pid} : %{cwd}]".freeze
29
+ ATTACHMENT_SIZE_LIMIT = 64*1024
30
+ SENSITIVE_FIELD = /authorization|cookie|credential|password|private[_-]?key|referer|referrer|secret|session|token|variables|api[_-]?key/i
31
+ REDACTED = "[REDACTED]".freeze
26
32
 
27
33
  # @param to [String] The address to email error reports to.
28
34
  # @param from [String] The from address for error reports.
29
35
  # @param subject [String] The subject template which can access attributes defined by `#attributes_for`.
30
36
  # @param delivery_method [Object] The delivery method as required by the mail gem.
31
- # @param dump_environment [Boolean] Attach request attributes as `attributes.yaml` to the error report.
32
- def initialize(app, to: "postmaster", from: DEFAULT_FROM, subject: DEFAULT_SUBJECT, delivery_method: LOCAL_SMTP, dump_environment: false)
37
+ # @param dump_body [Boolean] Attach a bounded rewindable request body to the error report.
38
+ # @param dump_environment [Boolean] Include application state and attach it as `state.yaml`.
39
+ # @param attachment_size_limit [Integer] The maximum size of each attachment.
40
+ # @param redact [Regexp | Nil] A pattern matching structured field names whose values should be redacted.
41
+ def initialize(app, to: "postmaster", from: DEFAULT_FROM, subject: DEFAULT_SUBJECT, delivery_method: LOCAL_SMTP, dump_body: false, dump_environment: false, attachment_size_limit: ATTACHMENT_SIZE_LIMIT, redact: SENSITIVE_FIELD)
33
42
  super(app)
34
43
 
35
44
  @to = to
36
45
  @from = from
37
46
  @subject = subject
38
47
  @delivery_method = delivery_method
48
+ @dump_body = dump_body
39
49
  @dump_environment = dump_environment
50
+ @attachment_size_limit = Integer(attachment_size_limit)
51
+ @redact = redact
52
+
53
+ if @attachment_size_limit < 0
54
+ raise ArgumentError, "attachment_size_limit must not be negative!"
55
+ end
40
56
  end
41
57
 
42
58
  # Freeze this object and its internal state.
@@ -48,7 +64,10 @@ module Utopia
48
64
  @from.freeze
49
65
  @subject.freeze
50
66
  @delivery_method.freeze
67
+ @dump_body.freeze
51
68
  @dump_environment.freeze
69
+ @attachment_size_limit.freeze
70
+ @redact.freeze
52
71
 
53
72
  super
54
73
  end
@@ -59,7 +78,7 @@ module Utopia
59
78
  def call(request)
60
79
  begin
61
80
  return @delegate.call(request)
62
- rescue => exception
81
+ rescue *APPLICATION_ERRORS => exception
63
82
  request.exception = exception
64
83
  send_notification exception, request
65
84
 
@@ -99,30 +118,32 @@ module Utopia
99
118
  def generate_body(exception, request)
100
119
  io = StringIO.new
101
120
 
102
- io.puts "#{request.method} #{request.url}"
103
-
104
- # TODO embed the request body if it's textual?
105
- # TODO dump and embed `utopia.variables`?
121
+ # Do not include the raw query string, as it may contain sensitive values:
122
+ io.puts "#{request.method} #{request.url.path.encoded}"
106
123
 
107
124
  io.puts
108
125
 
109
126
  REQUEST_ATTRIBUTES.each do |key|
110
- value = request.send(key)
127
+ value = redact(key, request.send(key))
111
128
  io.puts "request.#{key}: #{value.inspect}"
112
129
  end
113
130
 
114
131
  request.query_parameters.each do |key, value|
132
+ value = redact(key, value)
115
133
  io.puts "request.query_parameters.#{key}: #{value.inspect}"
116
134
  end
117
135
 
118
136
  io.puts
119
137
 
120
138
  request.headers.each do |key, value|
139
+ value = redact(key, value)
121
140
  io.puts "header[#{key.inspect}]: #{value.inspect}"
122
141
  end
123
142
 
124
- self.current_state(request).each do |key, value|
125
- io.puts "state.#{key}: #{value.inspect}"
143
+ if @dump_environment
144
+ filtered_state(request).each do |key, value|
145
+ io.puts "state.#{key}: #{value.inspect}"
146
+ end
126
147
  end
127
148
 
128
149
  io.puts
@@ -150,12 +171,14 @@ module Utopia
150
171
  mail.text_part = Mail::Part.new
151
172
  mail.text_part.body = generate_body(exception, request)
152
173
 
153
- if body = extract_body(request) and body.size > 0
154
- mail.attachments["body.bin"] = body
174
+ if @dump_body
175
+ if body = extract_body(request, @attachment_size_limit)
176
+ mail.attachments["body.bin"] = body
177
+ end
155
178
  end
156
179
 
157
180
  if @dump_environment
158
- mail.attachments["state.yaml"] = YAML.dump(self.current_state(request))
181
+ attach(mail, "state.yaml", YAML.dump(filtered_state(request)))
159
182
  end
160
183
 
161
184
  return mail
@@ -168,8 +191,7 @@ module Utopia
168
191
 
169
192
  mail.deliver
170
193
  rescue => mail_exception
171
- $stderr.puts mail_exception.to_s
172
- $stderr.puts mail_exception.backtrace
194
+ Console.warn(self, "Failed to deliver exception notification.", error: mail_exception)
173
195
  end
174
196
 
175
197
  def current_state(request)
@@ -181,11 +203,51 @@ module Utopia
181
203
  }
182
204
  end
183
205
 
184
- def extract_body(request)
206
+ def filtered_state(request)
207
+ redact(nil, current_state(request))
208
+ end
209
+
210
+ def redact(name, value)
211
+ if @redact && name
212
+ if @redact.match?(name.to_s)
213
+ return REDACTED
214
+ end
215
+ end
216
+
217
+ case value
218
+ when Hash
219
+ return value.to_h do |key, item|
220
+ [key, redact(key, item)]
221
+ end
222
+ when Array
223
+ return value.map{|item| redact(nil, item)}
224
+ else
225
+ return value
226
+ end
227
+ end
228
+
229
+ def attach(mail, name, content)
230
+ if content.bytesize <= @attachment_size_limit
231
+ mail.attachments[name] = content
232
+ end
233
+ end
234
+
235
+ def extract_body(request, size_limit)
185
236
  body = request.body
186
237
 
187
238
  if body&.rewindable? && body.rewind
188
- return body.join
239
+ buffer = String.new.b
240
+
241
+ body.each do |chunk|
242
+ # Do not retain a partial body when the complete attachment would exceed the limit:
243
+ if chunk.bytesize > size_limit - buffer.bytesize
244
+ return nil
245
+ end
246
+
247
+ buffer << chunk
248
+ end
249
+
250
+ return buffer unless buffer.empty?
189
251
  end
190
252
  end
191
253
  end
data/lib/utopia/path.rb CHANGED
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2009-2025, by Samuel Williams.
4
+ # Copyright, 2009-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/url/path"
7
7
 
8
8
  module Utopia
9
- # Represents a path as an array of path components. Useful for efficient URL manipulation.
9
+ # Represents an application path as a traversal through a tree.
10
+ #
11
+ # Each component names a node and `/` represents the edge between adjacent nodes. A leading empty component anchors the traversal at the root, while a trailing empty component preserves an explicit final edge and denotes a directory:
12
+ #
13
+ # - `["foo", "bar"]` represents `foo/bar`.
14
+ # - `["", "foo", "bar"]` represents `/foo/bar`.
15
+ # - `["", "foo", "bar", ""]` represents `/foo/bar/`.
16
+ #
17
+ # The structural root is represented by `[""]` and contains no traversed edge. It is intentionally distinct from parsing `/`, which preserves the explicit edge as `["", ""]`. Both serialize as `/`, but they retain different structural representations. In particular, the structural root maps to an empty local path so it can be resolved relative to an application root.
10
18
  class Path
11
19
  include Comparable
12
20
 
@@ -36,7 +44,7 @@ module Utopia
36
44
  @components.empty?
37
45
  end
38
46
 
39
- # Construct the root path.
47
+ # Construct the structural root path without an explicit trailing separator.
40
48
  # @returns [Path] The root path.
41
49
  def self.root
42
50
  self.new([""])
@@ -203,12 +211,6 @@ module Utopia
203
211
  end
204
212
  end
205
213
 
206
- # Remove the first component when this path is relative.
207
- # @returns [String | Nil] The removed component, or `nil` when the path is absolute.
208
- def to_relative!
209
- @components.shift if relative?
210
- end
211
-
212
214
  # Convert this object to a string.
213
215
  # @returns [String] The resulting string.
214
216
  def to_str
@@ -532,11 +534,4 @@ module Utopia
532
534
  return index
533
535
  end
534
536
  end
535
-
536
- # Coerce a value into a {Path}.
537
- # @parameter path [Utopia::Path | String] The path.
538
- # @returns [Path | Nil] The coerced path.
539
- def self.Path(path)
540
- Path.create(path)
541
- end
542
537
  end
@@ -117,28 +117,13 @@ module Utopia
117
117
  @query_parameters ||= parse_query_parameters(self.url.query)
118
118
  end
119
119
 
120
- private def parse_cookies(cookie_header)
121
- cookies = {}
122
-
123
- return cookies unless cookie_header
124
-
125
- if cookie_header.respond_to?(:to_str)
126
- cookie_header = cookie_header.to_str
127
- else
128
- cookie_header = cookie_header.to_s
129
- end
130
-
131
- cookie_header.split(/;\s*/).each do |pair|
132
- key, value = pair.split("=", 2)
133
- cookies[key] = value || ""
134
- end
135
-
136
- return cookies
137
- end
138
-
139
120
  # Decoded request cookies.
140
121
  def cookies
141
- @cookies ||= parse_cookies(self.headers["cookie"])
122
+ @cookies ||= if cookie_header = self.headers["cookie"]
123
+ cookie_header.to_h.transform_values(&:value)
124
+ else
125
+ {}
126
+ end
142
127
  end
143
128
 
144
129
  # The request user agent.
@@ -16,10 +16,6 @@ module Utopia
16
16
  @loader = block
17
17
  end
18
18
 
19
- # The loaded session values, if already loaded.
20
- # @returns [Hash | Nil] The loaded values.
21
- attr :values
22
-
23
19
  # Fetch a value by key, loading the hash if necessary.
24
20
  # @parameter key [Object] The key.
25
21
  # @returns [Object | Nil] The value.
@@ -66,10 +62,26 @@ module Utopia
66
62
  @changed
67
63
  end
68
64
 
69
- # Load and return the underlying values.
70
- # @returns [Hash] The loaded values.
71
- def load!
72
- @values ||= @loader.call
65
+ # The current time for session expiry and persistence.
66
+ # @returns [Time] The current time in UTC.
67
+ def now
68
+ Time.now.utc
69
+ end
70
+
71
+ # Persist the session values if they have changed or require updating.
72
+ # @parameter timeout [Numeric | Nil] The maximum age before an update is required.
73
+ # @yields {|values, updated_at| ...} The loaded values and their update time.
74
+ # @returns [Object | Nil] The result of the block if persistence was required.
75
+ def persist(timeout = nil)
76
+ return unless needs_update?(timeout)
77
+
78
+ values = load!
79
+ updated_at = values[:updated_at] = now
80
+
81
+ result = yield(values, updated_at)
82
+ @changed = false
83
+
84
+ return result
73
85
  end
74
86
 
75
87
  # Check whether the underlying values have been loaded.
@@ -88,11 +100,18 @@ module Utopia
88
100
  # We want to be careful here and not call load! which isn't cheap operation.
89
101
  if timeout and @values and updated_at = @values[:updated_at]
90
102
  # If the last update was too long ago, we need update:
91
- return true if updated_at < (Time.now - timeout)
103
+ return true if updated_at < (now - timeout)
92
104
  end
93
105
 
94
106
  return false
95
107
  end
108
+
109
+ private
110
+
111
+ # Load and return the underlying values.
112
+ def load!
113
+ @values ||= @loader.call(now)
114
+ end
96
115
  end
97
116
  end
98
117
  end
@@ -24,12 +24,14 @@ module Utopia
24
24
  class PayloadError < StandardError
25
25
  end
26
26
 
27
- MAXIMUM_SIZE = 1024*32
27
+ SIZE_LIMIT = 1024*32
28
28
 
29
29
  SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze
30
30
 
31
31
  SESSION_KEY = "utopia.session".freeze
32
- CIPHER_ALGORITHM = "aes-256-cbc"
32
+ CIPHER_ALGORITHM = "aes-256-gcm"
33
+ PAYLOAD_VERSION = "v1".freeze
34
+ AUTHENTICATION_TAG_SIZE = 16
33
35
 
34
36
  # The session will expire if no requests were made within 24 hours:
35
37
  DEFAULT_EXPIRES_AFTER = 3600*24
@@ -48,8 +50,8 @@ module Utopia
48
50
  # @param http_only [Boolean] Whether client-side scripts may access the cookie.
49
51
  # @param same_site [Symbol | String | Boolean | Nil] Controls whether the cookie is sent with cross-site requests.
50
52
  # @param partitioned [Boolean] Whether the cookie uses partitioned storage.
51
- # @param maximum_size [Integer | Nil] The maximum encoded session payload size.
52
- def initialize(app, session_name: SESSION_KEY, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, domain: nil, path: "/", max_age: nil, secure: false, http_only: true, same_site: :lax, partitioned: false, maximum_size: MAXIMUM_SIZE)
53
+ # @param size_limit [Integer | Nil] The encoded session payload size limit.
54
+ def initialize(app, session_name: SESSION_KEY, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, domain: nil, path: "/", max_age: nil, secure: false, http_only: true, same_site: :lax, partitioned: false, size_limit: SIZE_LIMIT)
53
55
  super(app)
54
56
 
55
57
  @session_name = session_name
@@ -61,6 +63,7 @@ module Utopia
61
63
 
62
64
  # This generates a 32-byte key suitable for aes.
63
65
  @key = Digest::SHA2.digest(secret)
66
+ @authentication_context = "#{@cookie_name}\0#{PAYLOAD_VERSION}".b.freeze
64
67
 
65
68
  @expires_after = expires_after
66
69
  @update_timeout = update_timeout
@@ -82,7 +85,7 @@ module Utopia
82
85
  }
83
86
 
84
87
  @serialization = Serialization.new
85
- @maximum_size = maximum_size
88
+ @size_limit = size_limit
86
89
  end
87
90
 
88
91
  attr :cookie_name
@@ -100,6 +103,7 @@ module Utopia
100
103
 
101
104
  @cookie_name.freeze
102
105
  @key.freeze
106
+ @authentication_context.freeze
103
107
  @expires_after.freeze
104
108
  @update_timeout.freeze
105
109
  @cookie_defaults.freeze
@@ -139,59 +143,53 @@ module Utopia
139
143
  end
140
144
 
141
145
  def prepare_session(request)
142
- LazyHash.new do
143
- self.load_session_values(request)
146
+ LazyHash.new do |now|
147
+ self.load_session_values(request, now)
144
148
  end
145
149
  end
146
150
 
147
151
  def update_session(session_hash, headers)
148
- if session_hash.needs_update?(@update_timeout)
149
- values = session_hash.values
150
-
151
- values[:updated_at] = Time.now.utc
152
-
153
- data = encrypt(session_hash.values)
154
-
155
- commit(data, values[:updated_at], headers)
152
+ session_hash.persist(@update_timeout) do |values, updated_at|
153
+ commit(encrypt(values), updated_at, headers)
156
154
  end
157
155
  end
158
156
 
159
157
  # Constructs a valid session for the given request. These fields must match as per the checks performed in `valid_session?`:
160
- def build_initial_session(request)
158
+ def build_initial_session(request, now)
161
159
  {
162
160
  user_agent: request.user_agent,
163
- created_at: Time.now.utc,
164
- updated_at: Time.now.utc,
161
+ created_at: now,
162
+ updated_at: now,
165
163
  }
166
164
  end
167
165
 
168
166
  # Load session from user supplied cookie. If the data is invalid or otherwise fails validation, `build_iniital_session` is invoked.
169
167
  # @return hash of values.
170
- def load_session_values(request)
168
+ def load_session_values(request, now)
171
169
  # Decrypt the data from the user if possible:
172
170
  if data = request.cookies[@cookie_name]
173
171
  begin
174
172
  if values = decrypt(data)
175
- validate_session!(request, values)
173
+ validate_session!(request, values, now)
176
174
 
177
175
  return values
178
176
  end
179
- rescue => error
180
- Console.error(self, error)
177
+ rescue PayloadError => error
178
+ Console.debug(self, "Discarding invalid session cookie!", exception: error)
181
179
  end
182
180
  end
183
181
 
184
182
  # If we couldn't create a session
185
- return build_initial_session(request)
183
+ return build_initial_session(request, now)
186
184
  end
187
185
 
188
- def validate_session!(request, values)
186
+ def validate_session!(request, values, now)
189
187
  if values[:user_agent] != request.user_agent
190
188
  raise PayloadError, "Invalid session because supplied user agent #{request.user_agent.inspect} does not match session user agent #{values[:user_agent].inspect}!"
191
189
  end
192
190
 
193
191
  if expires_at = expires(values[:updated_at])
194
- if expires_at < Time.now.utc
192
+ if expires_at < now
195
193
  raise PayloadError, "Expired session cookie, user agent submitted a cookie that should have expired at #{expires_at}."
196
194
  end
197
195
  end
@@ -199,7 +197,7 @@ module Utopia
199
197
  return true
200
198
  end
201
199
 
202
- def expires(updated_at=Time.now.utc)
200
+ def expires(updated_at)
203
201
  if @expires_after
204
202
  return updated_at + @expires_after
205
203
  end
@@ -253,38 +251,65 @@ module Utopia
253
251
  end
254
252
 
255
253
  def encrypt(hash)
256
- c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
257
- c.encrypt
254
+ cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
255
+ cipher.encrypt
256
+
257
+ cipher.key = @key
258
+ cipher.iv = initialization_vector = cipher.random_iv
259
+ cipher.auth_data = @authentication_context
260
+
261
+ encrypted_data = cipher.update(@serialization.dump(hash))
262
+ encrypted_data << cipher.final
258
263
 
259
- # your pass is what is used to encrypt/decrypt
260
- c.key = @key
261
- c.iv = iv = c.random_iv
264
+ payload = initialization_vector + encrypted_data + cipher.auth_tag(AUTHENTICATION_TAG_SIZE)
265
+ data = "#{PAYLOAD_VERSION}.#{[payload].pack("m0")}"
262
266
 
263
- e = c.update(@serialization.dump(hash))
264
- e << c.final
267
+ validate_size!(data)
265
268
 
266
- return [iv + e].pack("m0")
269
+ return data
267
270
  end
268
271
 
269
272
  def decrypt(data)
270
- if @maximum_size and data.bytesize > @maximum_size
271
- raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds maximum allowed size #{@maximum_size}bytes!"
272
- end
273
-
274
- payload = data.unpack1("m0")
275
- iv = payload.byteslice(0, 16)
276
- e = payload.byteslice(16..)
277
-
278
- c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
279
- c.decrypt
273
+ validate_size!(data)
280
274
 
281
- c.key = @key
282
- c.iv = iv
275
+ version, encoded_payload = data.split(".", 2)
283
276
 
284
- d = c.update(e)
285
- d << c.final
277
+ if version != PAYLOAD_VERSION or encoded_payload.nil?
278
+ raise PayloadError, "Unsupported session payload format!"
279
+ end
286
280
 
287
- return @serialization.load(d)
281
+ begin
282
+ cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
283
+ payload = encoded_payload.unpack1("m0")
284
+ minimum_size = cipher.iv_len + AUTHENTICATION_TAG_SIZE
285
+
286
+ if payload.bytesize < minimum_size
287
+ raise PayloadError, "Invalid session payload!"
288
+ end
289
+
290
+ initialization_vector = payload.byteslice(0, cipher.iv_len)
291
+ encrypted_data = payload.byteslice(cipher.iv_len, payload.bytesize - minimum_size)
292
+ authentication_tag = payload.byteslice(-AUTHENTICATION_TAG_SIZE, AUTHENTICATION_TAG_SIZE)
293
+
294
+ cipher.decrypt
295
+ cipher.key = @key
296
+ cipher.iv = initialization_vector
297
+ cipher.auth_tag = authentication_tag
298
+ cipher.auth_data = @authentication_context
299
+
300
+ decrypted_data = cipher.update(encrypted_data)
301
+ decrypted_data << cipher.final
302
+
303
+ return @serialization.load(decrypted_data)
304
+ rescue ArgumentError, OpenSSL::Cipher::CipherError
305
+ raise PayloadError, "Invalid session payload!"
306
+ end
307
+ end
308
+
309
+ def validate_size!(data)
310
+ if @size_limit and data.bytesize > @size_limit
311
+ raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds size limit #{@size_limit}bytes!"
312
+ end
288
313
  end
289
314
  end
290
315
  end
@@ -69,9 +69,13 @@ module Utopia
69
69
  ranges = byte_ranges(request)
70
70
  size = bytesize
71
71
 
72
- # puts "Requesting ranges: #{ranges.inspect} (#{size})"
73
-
74
- if ranges == nil or ranges.size != 1
72
+ # A valid byte-range request with no satisfiable ranges cannot be fulfilled:
73
+ if ranges && ranges.empty?
74
+ response_headers[CONTENT_LENGTH] = "0"
75
+ response_headers[CONTENT_RANGE] = "bytes */#{size}"
76
+
77
+ return Response[416, response_headers, []]
78
+ elsif ranges == nil or ranges.size != 1
75
79
  # No ranges, or multiple ranges (which we don't support).
76
80
  # TODO: Support multiple byte-ranges, for now just send entire file:
77
81
  status = 200
@@ -11,8 +11,6 @@ require_relative "local_file"
11
11
  require_relative "mime_types"
12
12
  require_relative "../localization/resolver"
13
13
 
14
- require "traces/provider"
15
-
16
14
  module Utopia
17
15
  module Static
18
16
  DEFAULT_CACHE_CONTROL = "public, max-age=3600".freeze
@@ -138,15 +136,5 @@ module Utopia
138
136
  end
139
137
  end
140
138
 
141
- Traces::Provider(Static) do
142
- def respond(request, path, extension, content_type, localization: request.localization)
143
- attributes = {
144
- path: path,
145
- locale: localization&.locale,
146
- }
147
-
148
- Traces.trace("utopia.static.respond", attributes: attributes){super}
149
- end
150
- end
151
139
  end
152
140
  end
@@ -5,5 +5,5 @@
5
5
 
6
6
  # @namespace
7
7
  module Utopia
8
- VERSION = "3.0.3"
8
+ VERSION = "3.0.5"
9
9
  end
data/readme.md CHANGED
@@ -31,6 +31,15 @@ Please see the [project documentation](https://socketry.github.io/utopia/) for m
31
31
 
32
32
  Please see the [project releases](https://socketry.github.io/utopia/releases/index) for all releases.
33
33
 
34
+ ### v3.0.5
35
+
36
+ - **Breaking** Remove support for JavaScript packages installed in `lib/components`; use `node_modules` instead.
37
+ - **Breaking** Expose <code class="language-ruby">Utopia::Content::Middleware\#links</code> as the content link resolver rather than an indexed lookup method.
38
+ - **Security** Authenticate encrypted session cookies using AES-256-GCM. Existing session cookies are invalidated.
39
+ - Constrain content node local paths to the configured content root.
40
+ - **Security** Redact sensitive exception report fields and make bounded request body attachments opt-in.
41
+ - Return `416 Range Not Satisfiable` for unsatisfiable static file byte ranges.
42
+
34
43
  ### v3.0.0
35
44
 
36
45
  The 3.0.x series is considered a development release while the protocol HTTP application and controller interfaces stabilize.
data/releases.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Releases
2
2
 
3
+ ## v3.0.5
4
+
5
+ - **Breaking** Remove support for JavaScript packages installed in `lib/components`; use `node_modules` instead.
6
+ - **Breaking** Expose {ruby Utopia::Content::Middleware\#links} as the content link resolver rather than an indexed lookup method.
7
+ - **Security** Authenticate encrypted session cookies using AES-256-GCM. Existing session cookies are invalidated.
8
+ - Constrain content node local paths to the configured content root.
9
+ - **Security** Redact sensitive exception report fields and make bounded request body attachments opt-in.
10
+ - Return `416 Range Not Satisfiable` for unsatisfiable static file byte ranges.
11
+
3
12
  ## v3.0.0
4
13
 
5
14
  The 3.0.x series is considered a development release while the protocol HTTP application and controller interfaces stabilize.
data.tar.gz.sig CHANGED
Binary file