studio-engine 0.91.0 → 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: 0cf7b3c1fa31a2eb5f9746838c8c4976d026920c986504c674f760c57c73a33d
4
- data.tar.gz: c39625c99d384e68eedfee1a31dde4816e6f3aa2f004f6ab2f88c4e10d447b00
3
+ metadata.gz: 270fe9f569b46f626593afa13ce7cf925f23e783541f76e06d6a465d06837278
4
+ data.tar.gz: 40f8eefcec2e4eee79baa652da57aca97a6b49b794fab4fb3ca166dd3f27333d
5
5
  SHA512:
6
- metadata.gz: bfeb555efeae9e981014cf5afb73c8e1eb7b01c90d92dc0c1ba6838d5c1acc950ddc908846be93d15d23ac07a9f5acf277c103c3d95b4611e85b758de5c940f0
7
- data.tar.gz: 728932ef4510055ab9b8cd3d6f472abe228ee632febfe007251beb242aec114a1e11c77381a63e44167fb7817c79d8d6716d24687e4261b4c660968e23ffef8c
6
+ metadata.gz: ae76749cc7fd004b7a93b2108d0af84e21645b1801f50be212d0d5346d503f8d3355fea7c3350ab74a059edbcfc4d60163b245ae34c7f4c68a249e1115b5588f
7
+ data.tar.gz: 683b4be2cb7b68b01c167db0e3973aefe9b0dd133b8ddb200f5d76f387d19dc1bff6378204fc0e34c4716e1b08bd9e0d08b4f572e40d3b2c15d007e97a6ba48e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ 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
+
23
+ ## 0.91.1 — 2026-10-06
24
+
25
+ ### Fixed
26
+
27
+ - **`POST /login` is drawn only for apps that declare `:password`.** A passwordless
28
+ app answered it 500 for a member's address and 422 for a stranger's, so the
29
+ answer revealed who is a member; it now answers 404 for every address. `GET
30
+ /login` and `login_path` are unchanged. An app that still wants a `POST /login`
31
+ of its own draws it before `Studio.routes(self)`.
32
+
33
+ - **SSO sign-up works on passwordless apps.** "Continue as …" (`POST
34
+ /sso_continue`) created a first-time visitor with `password:
35
+ SecureRandom.hex(16)`, which raised `UnknownAttributeError` on any User without
36
+ `has_secure_password`; the engine logged it and bounced to the login page. The
37
+ engine now sets a random password only when the User answers `password=`.
38
+ - **The survey keeps its h1 for a screen reader on every step.** From question 2
39
+ on, the stepper set `hidden` on the survey head, which took the page's only
40
+ h1 out of the accessibility tree. The head now steps into a 1px visual clip
41
+ instead, holding only the title.
42
+
43
+ ### Changed
44
+
45
+ - **The survey partials declare strict locals.** `studio/surveys/_question`
46
+ takes `question:`, `index:`, `total:`, `response:` and an optional `error:`;
47
+ `_script` and `_styles` take none. A render with an unknown or missing local
48
+ now raises instead of painting a blank. The survey accent's fallback reads
49
+ `Studio::ThemeResolver::DEFAULT_PRIMARY` instead of a copied hex.
50
+
7
51
  ## 0.91.0 — 2026-10-06
8
52
 
9
53
  ### Added
data/README.md CHANGED
@@ -82,6 +82,11 @@ sign-in-link form when `:magic_link` is enabled, and the Google button only with
82
82
  and using the link creates the account. So `registration_params`' `:name` shows a
83
83
  Name field only on a password app.
84
84
 
85
+ `POST /login`, the password exchange, is drawn only when `auth_methods` includes
86
+ `:password`. A passwordless app answers it with 404 for every address, so it
87
+ cannot tell a member from a stranger. `GET /login` and `login_path` are drawn for
88
+ every app.
89
+
85
90
  ### Local log rotation (automatic — nothing to configure)
86
91
 
87
92
  The engine caps the host app's **development** log at 16 MB and its **test** log
@@ -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
@@ -55,9 +55,15 @@ class SessionsController < ApplicationController
55
55
  email: session[:sso_email],
56
56
  name: session[:sso_name],
57
57
  provider: session[:sso_provider],
58
- uid: session[:sso_uid],
59
- password: SecureRandom.hex(16)
58
+ uid: session[:sso_uid]
60
59
  )
60
+ # Only a host that takes a password gets one: has_secure_password defines
61
+ # `password=` and requires a value on create, so those apps need a random
62
+ # placeholder. A passwordless User has no such writer, and passing
63
+ # `password:` to User.new raised UnknownAttributeError, which sso_continue
64
+ # rescued into a redirect: SSO sign-up failed silently on every passwordless
65
+ # app (/tasks/engine-sso-creates-passwordless-users).
66
+ user.password = SecureRandom.hex(16) if user.respond_to?(:password=)
61
67
  Studio.configure_sso_user.call(user)
62
68
  rescue_and_log(target: user) do
63
69
  user.save!
@@ -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
@@ -1,3 +1,4 @@
1
+ <%# locals: (question:, index:, total:, response:, error: nil) %>
1
2
  <%# One survey question as a fieldset (studio/surveys/show). It is a complete
2
3
  form control without JavaScript: native radios, checkboxes and text fields
3
4
  named answers[key], with `required` so the no-JS form validates in the
@@ -1,3 +1,4 @@
1
+ <%# locals: () %>
1
2
  <%# The survey stepper: progressive enhancement over studio/surveys/show.
2
3
  Plain JavaScript (no Alpine dependency), idempotent per root so a Turbo
3
4
  render that re-runs it never double-binds. Bound roots are tracked in a
@@ -137,8 +138,10 @@
137
138
  back.hidden = first;
138
139
  next.hidden = last;
139
140
  submit.hidden = !last;
140
- // The title and intro lead question 1 only; later screens are the question alone.
141
- if (head) head.hidden = !first;
141
+ // The title and intro lead question 1 only; later screens show the
142
+ // question alone. The head steps into a visual clip rather than
143
+ // `hidden`, so the page keeps its h1 for a screen reader on every step.
144
+ if (head) head.classList.toggle("studio-survey__head--later", !first);
142
145
  // The number-key shortcut only means something on a choice or scale.
143
146
  var step = steps[index];
144
147
  hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
@@ -1,3 +1,4 @@
1
+ <%# locals: () %>
1
2
  <%# Survey styles, scoped under .studio-survey. Plain CSS on the theme's CSS
2
3
  custom properties (--color-cta, --color-surface, ...) rather than Tailwind
3
4
  utilities, so the page looks the same in every host whatever its Tailwind
@@ -6,7 +7,7 @@
6
7
  <style>
7
8
  /* overflow-x: clip, not hidden: the slide-in must never widen the page, and
8
9
  clip makes no scroll container, so the sticky nav still sticks. */
9
- .studio-survey { --sv-accent: var(--color-cta, #8E82FE); display: flex; justify-content: center; padding: 1.25rem 1rem 2rem; color: var(--color-text-body); overflow-x: clip; }
10
+ .studio-survey { --sv-accent: var(--color-cta, <%= Studio::ThemeResolver::DEFAULT_PRIMARY %>); display: flex; justify-content: center; padding: 1.25rem 1rem 2rem; color: var(--color-text-body); overflow-x: clip; }
10
11
  .studio-survey [hidden] { display: none !important; }
11
12
  .studio-survey__shell { width: 100%; max-width: 36rem; }
12
13
  .studio-survey__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@@ -25,6 +26,11 @@
25
26
  .studio-survey.is-enhanced .studio-survey__head { margin-bottom: 1.25rem; }
26
27
  .studio-survey.is-enhanced .studio-survey__title { margin: 0 0 .375rem; font-size: 1rem; line-height: 1.4; font-weight: 700; color: var(--color-text-secondary); }
27
28
  .studio-survey.is-enhanced .studio-survey__head--echo .studio-survey__title { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
29
+ /* After question 1 the head steps aside visually, but its h1 stays the page's
30
+ heading for a screen reader: the head goes into a 1px clip and only the
31
+ title remains inside it. */
32
+ .studio-survey.is-enhanced .studio-survey__head--later { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
33
+ .studio-survey.is-enhanced .studio-survey__head--later > :not(.studio-survey__title) { display: none; }
28
34
  .studio-survey.is-enhanced .studio-survey__intro-copy { margin: 0; font-size: .9375rem; line-height: 1.5; color: var(--color-text-secondary); }
29
35
  .studio-survey.is-enhanced .studio-survey__meta { display: none; }
30
36
  .studio-survey__question { display: block; width: 100%; padding: 0; margin: 0 0 .5rem; font-size: 1.375rem; line-height: 1.3; font-weight: 700; color: var(--color-text); outline: none; }
@@ -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.0"
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"
@@ -146,8 +147,9 @@ module Studio
146
147
  # Which sign-in methods this app offers. The shared login/signup views render
147
148
  # a button/field per enabled method (gate with Studio.auth_method?). Order is
148
149
  # display order. Both McRitchie Studio + Turf Monster are passwordless; legacy
149
- # email+password is opt-in via :password (which also re-arms the User#authenticate
150
- # contract check — see validate_user_contract!).
150
+ # email+password is opt-in via :password, which also draws POST /login (see
151
+ # Studio.routes) and re-arms the User#authenticate contract check (see
152
+ # validate_user_contract!).
151
153
  #
152
154
  # :wallet is deliberately NOT in the default. This engine plus McRitchie Studio
153
155
  # is the BASE template for every app, web2 and web3 alike; solana-studio plus
@@ -1336,7 +1338,16 @@ module Studio
1336
1338
  def self.routes(router)
1337
1339
  router.instance_exec do
1338
1340
  get "login", to: "sessions#new"
1339
- post "login", to: "sessions#create"
1341
+
1342
+ # Email + password sign-in, drawn only for an app that declares :password.
1343
+ # SessionsController#create calls user.authenticate, which a passwordless
1344
+ # User does not define: on a passwordless app this POST answered 500 for a
1345
+ # member's address and 422 for a stranger's, which told anyone who asked
1346
+ # who the members are. Undrawn, it answers 404 for every address.
1347
+ # GET /login stays drawn for every app: it is the sign-in page, and
1348
+ # login_path names it.
1349
+ post "login", to: "sessions#create" if Studio.auth_method?(:password)
1350
+
1340
1351
  post "sso_continue", to: "sessions#sso_continue"
1341
1352
  get "sso_login", to: "sessions#sso_login"
1342
1353
  get "logout", to: "sessions#destroy"
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.0
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