utopia 3.0.4 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e9f5a6eb41a075cebcde2daf0e8811e1af414776f7807a14e07e9ed228c7657
4
- data.tar.gz: 1d566c0e5b68123699f6f33185857ce00e6eb3b1659eae5c0f0b896443a36527
3
+ metadata.gz: '0314485e770b9085839422a7484c179a39e447d81b15b211d70fbb4361fdc8b2'
4
+ data.tar.gz: 6e583362bc02ce48424f8e3adf7b9528bfd0990ea4ff946879d4bfbc95a52624
5
5
  SHA512:
6
- metadata.gz: dc90dd50e914c27b28463992bbe33b0ef52eb077bd666519b876575202cee39babfed39720e2c22b66f3c456460bdcd16446876bb2827714b9665ed72c655ce0
7
- data.tar.gz: 18b31ba92269e3340cc385e2a8b953bbe7b75b0db1f7323dd4e00d55faa03ebf71a097a940de69c84183a5115d2e982ba0a8aa2f480ca495a8d2119a93d2aa4f
6
+ metadata.gz: 4a81a495790a61faea6d2ab3873fd4043807134a4dec40b50177318802eefc14484e8dc218373fbf32b8bfe441ff96038ccaa21093acda9610d1ebc3ad6e87bb
7
+ data.tar.gz: 35d548a73022d0307b8a631d3a8c755b1eef9fcb0edb3f7b224ac2ef57b2334018928f3c8a47e4fea6812882ce794973a639af563b5a941627f3cafcce8ca971
checksums.yaml.gz.sig CHANGED
Binary file
@@ -167,7 +167,7 @@ This template would typically be designed with supporting `_page.xnode` and `_he
167
167
 
168
168
  ## Session
169
169
 
170
- The {ruby Utopia::Session} middleware provides session storage using encrypted client-side cookies. The session management uses symmetric private key encryption to store data on the client and avoid tampering.
170
+ The {ruby Utopia::Session} middleware provides session storage using authenticated, encrypted client-side cookies. The session management uses symmetric private key encryption to store data on the client and prevent tampering.
171
171
 
172
172
  ```ruby
173
173
  use Utopia::Session,
@@ -177,7 +177,7 @@ use Utopia::Session,
177
177
  secure: true
178
178
  ```
179
179
 
180
- All session data is stored on the client, but it's encrypted with a salt and the secret key. It is impossible for the client to decrypt the data without the secret stored on the server.
180
+ All session data is stored on the client, but it is encrypted and authenticated with a key derived from the secret. The client cannot read or modify the data without the secret stored on the server.
181
181
 
182
182
  When the middleware is installed, the session is available on the request:
183
183
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../../../../utopia/content/middleware"
7
+
8
+ require "traces/provider"
9
+
10
+ Traces::Provider(Utopia::Content::Middleware) do
11
+ def respond(link, request, localization: request.localization)
12
+ attributes = {
13
+ "link.key" => link.key,
14
+ "link.href" => link.href,
15
+ "link.locale" => localization&.locale,
16
+ }
17
+
18
+ Traces.trace("utopia.content.respond", attributes: attributes){super}
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../../../../utopia/static/middleware"
7
+
8
+ require "traces/provider"
9
+
10
+ Traces::Provider(Utopia::Static::Middleware) do
11
+ def respond(request, path, extension, content_type, localization: request.localization)
12
+ attributes = {
13
+ path: path,
14
+ locale: localization&.locale,
15
+ }
16
+
17
+ Traces.trace("utopia.static.respond", attributes: attributes){super}
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "utopia/content/middleware"
7
+ require_relative "utopia/static/middleware"
@@ -8,7 +8,7 @@ require "json"
8
8
  require "pathname"
9
9
 
10
10
  module Utopia
11
- # Installs JavaScript packages into the public components directory. Package contents are copied from `dist` when it exists, otherwise from the package root.
11
+ # Installs JavaScript packages from `node_modules` into the public components directory. Package contents are copied from `dist` when it exists, otherwise from the package root.
12
12
  #
13
13
  # By default, the complete source directory is installed. Projects can limit an individual package to a set of files using `utopia.components` in their `package.json` file.
14
14
  class Components
@@ -19,11 +19,6 @@ module Utopia
19
19
  @root = Pathname.new(root)
20
20
  @package_root = @root + "node_modules"
21
21
 
22
- # This is a legacy path:
23
- unless @package_root.directory?
24
- @package_root = @root + "lib/components"
25
- end
26
-
27
22
  @install_root = @root + "public/_components"
28
23
  @configuration = load_configuration
29
24
  end
@@ -82,11 +82,7 @@ module Utopia
82
82
  def text(content)
83
83
  return unless content
84
84
 
85
- if content.respond_to?(:build_markup)
86
- content.build_markup(self)
87
- else
88
- XRB::Markup.append(@output, content)
89
- end
85
+ content.build_markup(self)
90
86
  end
91
87
 
92
88
  # Write a complete tag to the output.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2015-2025, by Samuel Williams.
4
+ # Copyright, 2015-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "link"
7
7
 
@@ -204,8 +204,10 @@ module Utopia
204
204
  def each(locale)
205
205
  return to_enum(:each, locale) unless block_given?
206
206
 
207
- ordered.each do |links|
208
- yield links.find{|link| link.locale == locale}
207
+ @named.each_key do |name|
208
+ if link = lookup(name, locale)
209
+ yield link
210
+ end
209
211
  end
210
212
  end
211
213
 
@@ -1,7 +1,7 @@
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 "xrb/parsers"
7
7
  require "xrb/entities"
@@ -108,9 +108,9 @@ module Utopia
108
108
  # @returns [String] The resulting string.
109
109
  def to_s
110
110
  if @closing_tag
111
- "#{start_location}: #{@opening_tag} was not closed!"
112
- else
113
111
  "#{start_location}: #{@opening_tag} was closed by #{@closing_tag}!"
112
+ else
113
+ "#{start_location}: #{@opening_tag} was not closed!"
114
114
  end
115
115
  end
116
116
  end
@@ -16,7 +16,6 @@ require_relative "tags"
16
16
 
17
17
  require "xrb/template"
18
18
  require "concurrent/map"
19
- require "traces/provider"
20
19
 
21
20
  module Utopia
22
21
  module Content
@@ -66,11 +65,7 @@ module Utopia
66
65
  end
67
66
 
68
67
  attr :root
69
-
70
- # TODO we should remove this method and expose `@links` directly.
71
- def links(path, **options)
72
- @links.index(path, **options)
73
- end
68
+ attr :links
74
69
 
75
70
  # Load and cache a content template.
76
71
  # @parameter path [Utopia::Path | String] The path.
@@ -159,12 +154,7 @@ module Utopia
159
154
  private
160
155
 
161
156
  def lookup_content(name, parent_path)
162
- if String === name && name.index("/")
163
- name = Path.create(name)
164
- end
165
-
166
157
  if Path === name
167
- name = parent_path + name
168
158
  name_path = name.components.dup
169
159
  name_path[-1] += XNODE_EXTENSION
170
160
  else
@@ -195,6 +185,21 @@ module Utopia
195
185
  end
196
186
 
197
187
  def content_tag(name, node, parent_path: node.parent_path)
188
+ # Preserve nested names while searching the physical content hierarchy:
189
+ if String === name
190
+ if name.index("/")
191
+ name = Path.create(name)
192
+ end
193
+ end
194
+
195
+ if Path === name
196
+ cache_key = parent_path + name
197
+
198
+ return @node_cache.fetch_or_store(cache_key) do
199
+ lookup_content(name, parent_path)
200
+ end
201
+ end
202
+
198
203
  full_path = parent_path + name
199
204
 
200
205
  name = full_path.pop
@@ -216,16 +221,5 @@ module Utopia
216
221
  end
217
222
  end
218
223
 
219
- Traces::Provider(Middleware) do
220
- def respond(link, request, localization: request.localization)
221
- attributes = {
222
- "link.key" => link.key,
223
- "link.href" => link.href,
224
- "link.locale" => localization&.locale,
225
- }
226
-
227
- Traces.trace("utopia.content.middleware.respond", attributes: attributes){super}
228
- end
229
- end
230
224
  end
231
225
  end
@@ -51,14 +51,12 @@ module Utopia
51
51
  def local_path(path = ".", base = nil)
52
52
  path = Path[path]
53
53
 
54
- root = Pathname.new(@controller.root)
55
-
56
- if path.absolute?
57
- return root.join(*path.components)
58
- else
54
+ if path.relative?
59
55
  base ||= uri_path.dirname
60
- return root.join(*(base + path).components)
56
+ path = base + path
61
57
  end
58
+
59
+ return Pathname.new(path.to_url_path.local_path(@controller.root))
62
60
  end
63
61
 
64
62
  # Resolve a path relative to this node's containing URI path.
@@ -84,7 +82,7 @@ module Utopia
84
82
  def links(path = ".", **options, &block)
85
83
  path = uri_path.dirname + Path[path]
86
84
 
87
- links = @controller.links(path, **options)
85
+ links = @controller.links.index(path, **options)
88
86
 
89
87
  if block_given?
90
88
  links.each(&block)
@@ -96,7 +94,7 @@ module Utopia
96
94
  # Return localized and indexed variants related to this node.
97
95
  # @returns [Array(Link)] The related links.
98
96
  def related_links
99
- @controller.links(@uri_path.dirname, name: @uri_path.basename, indices: true)
97
+ @controller.links.index(@uri_path.dirname, name: @uri_path.basename, indices: true)
100
98
  end
101
99
 
102
100
  # Return the directory whose links are siblings of this node.
@@ -113,7 +111,7 @@ module Utopia
113
111
  # @parameter options [Hash] The options.
114
112
  # @returns [Array(Link)] The sibling links.
115
113
  def sibling_links(**options)
116
- return @controller.links(siblings_path, **options)
114
+ return @controller.links.index(siblings_path, **options)
117
115
  end
118
116
 
119
117
  # Lookup the given tag which is being rendered within the given node. Invoked by {Document}.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2015-2025, by Samuel Williams.
4
+ # Copyright, 2015-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "../http"
7
7
  require_relative "../path/matcher"
@@ -48,6 +48,8 @@ module Utopia
48
48
  # Freeze this object and its internal state.
49
49
  # @returns [self] This object.
50
50
  def freeze
51
+ return self if frozen?
52
+
51
53
  @matcher.freeze
52
54
  @block.freeze
53
55
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Utopia
7
+ module Exceptions
8
+ # Exceptions raised by application code which can be safely handled and reported.
9
+ APPLICATION_ERRORS = [StandardError, ScriptError].freeze
10
+ end
11
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2014-2025, by Samuel Williams.
4
+ # Copyright, 2014-2026, by Samuel Williams.
5
5
  # Copyright, 2025, by Olle Jonsson.
6
6
 
7
7
  require "console"
@@ -9,6 +9,7 @@ require "console"
9
9
  require_relative "../middleware"
10
10
  require_relative "../request"
11
11
  require_relative "../response"
12
+ require_relative "application_errors"
12
13
 
13
14
  module Utopia
14
15
  module Exceptions
@@ -37,7 +38,7 @@ module Utopia
37
38
  def call(request)
38
39
  begin
39
40
  return @delegate.call(request)
40
- rescue Exception => exception
41
+ rescue *APPLICATION_ERRORS => exception
41
42
  Console.warn(self, "An error occurred while processing the request.", error: exception)
42
43
 
43
44
  begin
@@ -52,7 +53,7 @@ module Utopia
52
53
  error_response.status = 500
53
54
 
54
55
  return error_response
55
- rescue Exception => exception
56
+ rescue *APPLICATION_ERRORS => exception
56
57
  # If redirection fails, we also finish with a fatal error:
57
58
  Console.error(self, "An error occurred while invoking the error handler.", error: exception)
58
59
  return Response[500, {"content-type" => "text/plain"}, ["An error occurred while processing the request."]]
@@ -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.4"
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: utopia
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.4
4
+ version: 3.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -161,14 +161,14 @@ dependencies:
161
161
  requirements:
162
162
  - - "~>"
163
163
  - !ruby/object:Gem::Version
164
- version: '0.70'
164
+ version: '0.71'
165
165
  type: :runtime
166
166
  prerelease: false
167
167
  version_requirements: !ruby/object:Gem::Requirement
168
168
  requirements:
169
169
  - - "~>"
170
170
  - !ruby/object:Gem::Version
171
- version: '0.70'
171
+ version: '0.71'
172
172
  - !ruby/object:Gem::Dependency
173
173
  name: protocol-media
174
174
  requirement: !ruby/object:Gem::Requirement
@@ -285,6 +285,9 @@ files:
285
285
  - context/server-setup.md
286
286
  - context/updating-utopia.md
287
287
  - context/what-is-xnode.md
288
+ - lib/traces/provider/utopia.rb
289
+ - lib/traces/provider/utopia/content/middleware.rb
290
+ - lib/traces/provider/utopia/static/middleware.rb
288
291
  - lib/utopia.rb
289
292
  - lib/utopia/application.rb
290
293
  - lib/utopia/components.rb
@@ -311,6 +314,7 @@ files:
311
314
  - lib/utopia/controller/rewrite.rb
312
315
  - lib/utopia/controller/variables.rb
313
316
  - lib/utopia/exceptions.rb
317
+ - lib/utopia/exceptions/application_errors.rb
314
318
  - lib/utopia/exceptions/handler.rb
315
319
  - lib/utopia/exceptions/mailer.rb
316
320
  - lib/utopia/extensions/array_split.rb
metadata.gz.sig CHANGED
Binary file