studio-engine 0.91.1 → 0.91.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b177cff9fc53b20d566685aa744f1dc41f481017f3014c9a00ef71a43620365a
4
- data.tar.gz: 100753f4d2f9dd2f936c8285a2b3aac844d2538099b066ef15013ca77f407392
3
+ metadata.gz: 270fe9f569b46f626593afa13ce7cf925f23e783541f76e06d6a465d06837278
4
+ data.tar.gz: 40f8eefcec2e4eee79baa652da57aca97a6b49b794fab4fb3ca166dd3f27333d
5
5
  SHA512:
6
- metadata.gz: 76433dc917873ff167918968f9ec85d1d6765606e58f3c6502183cab9fca55b3b19a4bc74652f93c74222c3799e4cedbc411a7ce79c6d4d8ff207524982dca20
7
- data.tar.gz: 3c69e3e3ed94f63952f9cbf2c2e75446b3d95a7d63c51e5b7331dce801bc5ae5329761b456f6cd968e276d6874daf453dcce551c20b87960c49a604eda81d80e
6
+ metadata.gz: ae76749cc7fd004b7a93b2108d0af84e21645b1801f50be212d0d5346d503f8d3355fea7c3350ab74a059edbcfc4d60163b245ae34c7f4c68a249e1115b5588f
7
+ data.tar.gz: 683b4be2cb7b68b01c167db0e3973aefe9b0dd133b8ddb200f5d76f387d19dc1bff6378204fc0e34c4716e1b08bd9e0d08b4f572e40d3b2c15d007e97a6ba48e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.91.2 — 2026-10-06
8
+
9
+ ### Security
10
+
11
+ - **A return path beginning `/\` no longer redirects off-site.** Browsers read
12
+ `/\evil.com` as `//evil.com`, and the engine's path sanitizers checked only for
13
+ one leading `/` and not two, so a magic link minted with `return_to=/\evil.com`
14
+ sent the visitor to another host after sign-in (or, under Rails'
15
+ `raise_on_open_redirects`, answered 500). One predicate, `Studio::LocalPath.local?`,
16
+ now holds the rule (one leading `/`, no backslash, no control character) and
17
+ every engine site uses it: `Studio::LinkToken.sanitize_path`, the link-consume
18
+ redirect, `Studio::Booking.local_path?`, and the link-preview fallback image.
19
+ Each site keeps its fallback: a refused return path lands on the default page.
20
+ Consumers that sanitize a return path themselves should call
21
+ `Studio::LocalPath.local?` instead of a bare `start_with?("/")` check.
22
+
7
23
  ## 0.91.1 — 2026-10-06
8
24
 
9
25
  ### Fixed
@@ -163,10 +163,10 @@ module Studio
163
163
  user.update!(email_verified_at: Time.current)
164
164
  end
165
165
 
166
- # Only same-origin absolute paths survive; everything else collapses to nil.
166
+ # Only local paths survive (Studio::LocalPath); everything else collapses to nil.
167
167
  def safe_path(path)
168
168
  p = path.to_s
169
- p.start_with?("/") && !p.start_with?("//") ? p : nil
169
+ Studio::LocalPath.local?(p) ? p : nil
170
170
  end
171
171
  end
172
172
  end
@@ -183,7 +183,7 @@ module Studio
183
183
  def static_image
184
184
  path = Studio.link_preview_fallback_image.to_s
185
185
  return nil if path.empty?
186
- return path unless path.start_with?("/") && !path.start_with?("//")
186
+ return path unless Studio::LocalPath.local?(path)
187
187
 
188
188
  @static_files ||= Concurrent::Map.new
189
189
  present = @static_files.compute_if_absent(path) do
@@ -203,7 +203,7 @@ module Studio
203
203
  # per path like static_image.
204
204
  def static_image_dimensions
205
205
  path = static_image
206
- return nil if path.nil? || !path.start_with?("/") || path.start_with?("//")
206
+ return nil unless Studio::LocalPath.local?(path)
207
207
 
208
208
  @static_dimensions ||= Concurrent::Map.new
209
209
  dims = @static_dimensions.compute_if_absent(path) do
@@ -11,6 +11,8 @@
11
11
  #
12
12
  # `gv=true` is what makes Google serve the embeddable page, so the frame and the
13
13
  # popup add it; the "open the booking page" link does not.
14
+ require_relative "local_path"
15
+
14
16
  module Studio
15
17
  module Booking
16
18
  EMBED_PARAM = "gv=true"
@@ -116,10 +118,10 @@ module Studio
116
118
  # hrefs (the footer's booking links, `studio_booking_link`). It begins with
117
119
  # one "/" and carries no control character. That refuses a scheme
118
120
  # ("javascript:", "https:") and a protocol-relative "//host", which a browser
119
- # reads as another site.
121
+ # reads as another site. The rule is Studio::LocalPath's, shared with every
122
+ # other engine path sanitizer.
120
123
  def local_path?(path)
121
- string = path.to_s
122
- string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
124
+ Studio::LocalPath.local?(path)
123
125
  end
124
126
 
125
127
  # Studio.booking_path: nil, a local path, or a callable receiving the view.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "local_path"
4
5
 
5
6
  module Studio
6
7
  # Pure-Ruby helpers behind the Studio::Link model — token minting, the kind
@@ -53,12 +54,13 @@ module Studio
53
54
  email.to_s.strip.downcase
54
55
  end
55
56
 
56
- # Only same-origin absolute paths survive; protocol-relative ("//evil"),
57
+ # Only local paths survive (Studio::LocalPath: one leading "/", no
58
+ # backslash, no control character); protocol-relative ("//evil", "/\evil"),
57
59
  # absolute URLs, and blanks collapse to nil so callers fall back to a safe
58
- # default redirect. Mirrors the MagicLink service's sanitizer.
60
+ # default redirect.
59
61
  def sanitize_path(path)
60
62
  p = path.to_s
61
- p.start_with?("/") && !p.start_with?("//") ? p : nil
63
+ Studio::LocalPath.local?(p) ? p : nil
62
64
  end
63
65
  end
64
66
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The one rule for "is this a path on THIS site?", used wherever the engine
5
+ # writes a caller-supplied path into a redirect or an href: a sign-in's
6
+ # return_to, a link's target, the booking page, the link-preview fallback image.
7
+ #
8
+ # A local path begins with exactly one "/" and carries no control character
9
+ # and no backslash. Each clause closes a way a browser leaves the site:
10
+ # "https://x", "javascript:x" - no leading "/": a scheme or a relative path
11
+ # "//x" - protocol-relative: another host
12
+ # "/\x", "/\/x" - browsers (the WHATWG URL parser) read "\" as
13
+ # "/", so these are "//x" in disguise
14
+ # "/\tx", "/\nx" - the URL parser strips tab and newline, so a
15
+ # control character can hide a second "/"
16
+ # Pure Ruby, so it loads and unit-tests without Rails.
17
+ module LocalPath
18
+ # A control character (C0 or DEL) or a backslash anywhere in the path.
19
+ UNSAFE_CHARACTER = /[\x00-\x1f\x7f\\]/
20
+
21
+ module_function
22
+
23
+ def local?(path)
24
+ string = path.to_s
25
+ string.start_with?("/") && !string.start_with?("//") && !string.match?(UNSAFE_CHARACTER)
26
+ end
27
+ end
28
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.91.1"
2
+ VERSION = "0.91.2"
3
3
  end
data/lib/studio.rb CHANGED
@@ -12,6 +12,7 @@ require "active_support/core_ext/module/attribute_accessors"
12
12
  require "active_support/core_ext/numeric/time"
13
13
  require "active_support/core_ext/integer/time"
14
14
  require "studio/version"
15
+ require "studio/local_path"
15
16
  require "studio/log_rotation"
16
17
  require "studio/ip_locations"
17
18
  require "studio/geo"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.91.1
4
+ version: 0.91.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -614,6 +614,7 @@ files:
614
614
  - lib/studio/link_preview.rb
615
615
  - lib/studio/link_resolution.rb
616
616
  - lib/studio/link_token.rb
617
+ - lib/studio/local_path.rb
617
618
  - lib/studio/log_rotation.rb
618
619
  - lib/studio/mail_transport.rb
619
620
  - lib/studio/name_parts.rb