From 17664a1d1484041f12bf8c7d24a1ce9dcb6834ea Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sun, 18 Oct 2026 17:33:29 -0400 Subject: [PATCH] fix(levelcode): `next` refuses this app's /ai/ endpoints in every spelling the router answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of the account-site half (onetime #100, comment 4308831990), which asked for the same exclusion here: safe_next refused /ai/auth/… and /ai/csrf as written, but Rails also answers /ai/csrf.json and /ai/csrf/, and /ai/%63srf named it too. The first segment after /ai/ is read as the router reads a name (lower case, up to the first character that is not a letter, digit or underscore), as written and percent-decoded, and refused when it is one of the "ai" scope's routes: auth, authorize_editor, checkout, billing, signout, csrf (NEXT_SERVER_NAMES). The SPA's isServerName is the same rule; the two validators agree on 40,000 generated values. Specs: eleven more refused spellings, and pages whose name only starts like an endpoint's or has one further down still land. --- app/controllers/levelcode/web_controller.rb | 17 ++++++++++++++--- spec/requests/levelcode/web_spec.rb | 13 ++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/controllers/levelcode/web_controller.rb b/app/controllers/levelcode/web_controller.rb index a1bcc571..4490977a 100644 --- a/app/controllers/levelcode/web_controller.rb +++ b/app/controllers/levelcode/web_controller.rb @@ -40,6 +40,8 @@ class WebController < ApplicationController # be written in: RFC 3986's path and query characters and well-formed percent escapes (#safe_next). NEXT_MAX = 512 NEXT_CHARS = %r{\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/?]|%\h\h)+\z} + # What this app serves under /ai/ itself (config/routes.rb, the "ai" scope): never a page to land on. + NEXT_SERVER_NAMES = %w[auth authorize_editor checkout billing signout csrf].freeze # POST /ai/auth/email (JSON) — passwordless step 1: email a 6-digit code. def email_request @@ -266,6 +268,14 @@ def deliver_signed_in(user) end end + # Is this first segment after /ai/ one of this app's own endpoints, as written or decoded? A name is read as the + # router would: in lower case, up to the first character that is not a letter, digit or underscore (`csrf.json` + # and `csrf;x` are `csrf`). Read both ways, `%63srf` and `checkout%A9` are endpoints too. The SPA's isServerName. + def next_server_name?(segment) + name = ->(s) { s.scrub.downcase[/\A[a-z0-9_]*/] } + NEXT_SERVER_NAMES.include?(name.(segment)) || NEXT_SERVER_NAMES.include?(name.(URI.decode_uri_component(segment))) + end + # The login page again after a failed sign-in, still bringing the visitor to the page they asked for. def login_again(error) query = { error: error } @@ -300,8 +310,9 @@ def web_editor_home # SPA's safeNext (src/levelcode/next.ts in onetime): a page under /ai/, written only in the characters a URL # path and query allow (so no blank, control character, backslash, fragment or non-ASCII), with a query kept # (`/ai/pricing?plan=pro`). Refused: `//` (a host to a browser), a percent-encoded slash or backslash, a dot - # segment, plain or encoded (`/ai/%2e%2e/x` is /x to a browser), and the server's own endpoints under /ai/auth/ - # and /ai/csrf, which are not pages. It is only ever a same-site path, so it takes the visitor nowhere this + # segment, plain or encoded (`/ai/%2e%2e/x` is /x to a browser), and the server's own endpoints + # (NEXT_SERVER_NAMES), which are not pages, in every spelling the router answers: /ai/csrf.json, /ai/csrf/, + # /ai/%63srf, /ai/CSRF, /ai/auth/…. It is only ever a same-site path, so it takes the visitor nowhere this # site's own links could not. def safe_next(raw) return nil unless raw.is_a?(String) && raw.length.between?(5, NEXT_MAX) && raw.start_with?("/ai/") @@ -310,7 +321,7 @@ def safe_next(raw) path = raw.split("?", 2).first return nil if path.split("/").any? { |segment| %w[. ..].include?(segment.gsub(/%2e/i, ".")) } - return nil if path.start_with?("/ai/auth/") || path == "/ai/csrf" + return nil if next_server_name?(path.split("/")[2].to_s) uri = URI.parse(raw) return nil if uri.scheme || uri.host || uri.userinfo diff --git a/spec/requests/levelcode/web_spec.rb b/spec/requests/levelcode/web_spec.rb index 3223195d..94dad68b 100644 --- a/spec/requests/levelcode/web_spec.rb +++ b/spec/requests/levelcode/web_spec.rb @@ -543,7 +543,10 @@ def verify(extra = {}) '/ai/../users/edit', '/ai/%2e%2e/users/edit', '/ai/%2E./x', '/ai/.%2e/users/edit', '/ai/%2e./users/edit', '/ai/%2e/x', '/ai/docs/%2e%2e/%2e%2e/users/edit', '/other/path', 'javascript:alert(1)', '/ai/x#y', "/ai/x\nSet-Cookie:a=b", '/ai/' + ('a' * 600), '/ai/caf' + [ 0xe9 ].pack('U') + '/x', '/ai/