From ed78b9ddc71a8399c30a7cc911d543d5a14f7f47 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 21:34:39 -0400 Subject: [PATCH 1/5] fix(levelcode): the API takes the account session cookie only from the account site's own pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LevelCode API accepts the levelcode.ai session cookie as well as an editor's bearer token, and, as a token-first API, has no CSRF token. SameSite=Lax is a site boundary, not an origin one, so a page on another origin of the same site is sent the cookie too. That is what kept the web editor off a levelcode.ai subdomain (docs/WEB.md in levelcode); app.levelcode.ai is where it is going. user_from_session now counts the cookie only when the request comes from this origin's own pages (Sec-Fetch-Site same-origin) or from the visitor (none). A browser that sends no Sec-Fetch-Site (older than 2023) is judged by Origin: none, or this origin's own. The account site's calls are same-origin, in production and through the dev server's proxy; the editors use bearer tokens. Spec (session_cookie_spec.rb, the real sign-in path, no stubbed authentication): from the editor's origin and from another site the profile, a checkout and the billing portal answer 401 and Stripe is not called — on the previous code the profile answered 200, the checkout got past sign-in to a 400, and the billing portal session was opened; the account site's own pages, a typed address, an old browser and a bearer token from the editor's page still work. --- .../api/levelcode/v1/base_controller.rb | 21 +++- .../api/levelcode/v1/session_cookie_spec.rb | 107 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 spec/requests/api/levelcode/v1/session_cookie_spec.rb diff --git a/app/controllers/api/levelcode/v1/base_controller.rb b/app/controllers/api/levelcode/v1/base_controller.rb index 7c61bc60..b9188d64 100644 --- a/app/controllers/api/levelcode/v1/base_controller.rb +++ b/app/controllers/api/levelcode/v1/base_controller.rb @@ -11,7 +11,8 @@ module V1 # - `Authorization: Bearer ` — verified via # ::Levelcode::EditorToken (sig + exp + scope + jti-not-revoked), OR # - the levelcode.ai web session cookie — i.e. an already-signed-in Devise - # user (warden), used by `/account` and `/pricing` server calls. + # user (warden), used by `/account` and `/pricing` server calls — on a + # request from this origin's own pages only (#own_page_request?). class BaseController < ActionController::API include ActionController::Cookies include Devise::Controllers::Helpers @@ -101,12 +102,30 @@ def levelcode_token_claims # --- Web session cookie ------------------------------------------------- + # The account site's session counts only on a request from the account site's own pages. SameSite=Lax is a + # SITE boundary, not an origin one: a page on another origin of the same site — the web editor at + # app.levelcode.ai, or any other subdomain — is sent the cookie too, and this API has no CSRF token, so such a + # page could start a checkout, open the billing portal or spend credits at the gateway with it. A page anywhere + # but here brings a token, as the editor does. def user_from_session + return nil unless own_page_request? + # warden is populated by Devise's middleware from the session cookie. warden = request.env["warden"] warden&.authenticate(scope: :user) end + # Did this request come from one of this origin's own pages, or from the visitor themselves (an address they + # opened)? Browsers say so in Sec-Fetch-Site. One that does not send it (older than 2023) says where a page + # sent it from in Origin, when it was a page: none, or this origin's own, counts. + def own_page_request? + fetch_site = request.headers["Sec-Fetch-Site"].to_s + return %w[same-origin none].include?(fetch_site) if fetch_site.present? + + origin = request.headers["Origin"].to_s + origin.empty? || origin == request.base_url + end + # --- Abuse caps (SPEC §5) ---------------------------------------------- # Thin stub: per-user RPM / daily token caps via Redis. Full enforcement diff --git a/spec/requests/api/levelcode/v1/session_cookie_spec.rb b/spec/requests/api/levelcode/v1/session_cookie_spec.rb new file mode 100644 index 00000000..5c42be84 --- /dev/null +++ b/spec/requests/api/levelcode/v1/session_cookie_spec.rb @@ -0,0 +1,107 @@ +require "rails_helper" + +# The account site's web session, at the LevelCode API. The API takes the levelcode.ai session cookie as well as an +# editor's bearer token, and as a token-first API it has no CSRF token. SameSite=Lax is a SITE boundary: a page on +# another origin of the same site — the web editor at app.levelcode.ai — is sent the cookie too. So the cookie counts +# only on a request from the account site's own pages (or one the visitor typed); a page anywhere else has to bring a +# token, as the editor does. +RSpec.describe "LevelCode API: the web session cookie", type: :request do + PROFILE = "/api/levelcode/v1/account/profile".freeze + + let(:user) { create(:user, email: "cookie@example.com") } + + before do + allow(Stripe::Customer).to receive(:create).and_return(Stripe::Customer.construct_from(id: "cus_test")) + sign_in user + end + + def profile(headers = {}) + get PROFILE, headers: headers + end + + it "counts on a request from the account site's own pages" do + profile("Sec-Fetch-Site" => "same-origin") + + expect(response).to have_http_status(:ok) + expect(response.parsed_body["email"]).to eq("cookie@example.com") + end + + it "counts on an address the visitor opened themselves (no page sent it)" do + profile("Sec-Fetch-Site" => "none") + + expect(response).to have_http_status(:ok) + end + + it "does not count from another origin of the same site — the web editor's page, or any subdomain" do + profile("Sec-Fetch-Site" => "same-site", "Origin" => "https://app.levelcode.ai") + + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body.dig("error", "code")).to eq("unauthorized") + end + + it "does not count from another site" do + profile("Sec-Fetch-Site" => "cross-site", "Origin" => "https://evil.example") + + expect(response).to have_http_status(:unauthorized) + end + + # (One request per example: the test sign-in holds for the request that follows it.) + it "does not let another origin start a checkout with it" do + allow(Stripe::Checkout::Session).to receive(:create) + + post "/api/levelcode/v1/checkouts", params: { plan: "pro" }.to_json, + headers: { "Sec-Fetch-Site" => "same-site", "Origin" => "https://app.levelcode.ai", "CONTENT_TYPE" => "text/plain" } + + expect(response).to have_http_status(:unauthorized) + expect(Stripe::Checkout::Session).not_to have_received(:create) + end + + it "does not let another origin open the billing portal with it" do + allow(Stripe::BillingPortal::Session).to receive(:create) + + post "/api/levelcode/v1/billings", headers: { "Sec-Fetch-Site" => "same-site", "Origin" => "https://app.levelcode.ai" } + + expect(response).to have_http_status(:unauthorized) + expect(Stripe::BillingPortal::Session).not_to have_received(:create) + end + + describe "from a browser that sends no Sec-Fetch-Site (older than 2023)" do + it "counts with no Origin" do + profile + + expect(response).to have_http_status(:ok) + end + + it "counts with this site's own Origin" do + profile("Origin" => "http://www.example.com") # the test request's own origin + + expect(response).to have_http_status(:ok) + end + + it "does not count with another origin's" do + profile("Origin" => "https://app.levelcode.ai") + + expect(response).to have_http_status(:unauthorized) + end + end + + describe "an editor's bearer token" do + around do |example| + prev = ENV["LEVELCODE_JWT_SECRET"] + ENV["LEVELCODE_JWT_SECRET"] = "test-levelcode-secret" + example.run + ensure + ENV["LEVELCODE_JWT_SECRET"] = prev + end + + it "still counts from the editor's page, where the cookie does not" do + sign_out user + token = Levelcode::EditorToken.mint_access(user) + + profile("Sec-Fetch-Site" => "same-site", "Origin" => "https://app.levelcode.ai", "Authorization" => "Bearer #{token}") + + expect(response).to have_http_status(:ok) + expect(response.parsed_body["email"]).to eq("cookie@example.com") + end + end +end From 7fe5406184fbf8f5ccd7807c62ef84ef8c135bd7 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 21:39:36 -0400 Subject: [PATCH 2/5] feat(levelcode): a sign-in on the account site lands in the web editor when it is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's model is chatgpt.com: signing in at levelcode.ai takes you to the work, not to your account. A web sign-in (no editor signing in) used to end on /ai/account. It now ends on: - the page the visitor asked for, `next` — a plain path under /ai/ (a query is kept; no scheme, host, `//`, backslash, percent-encoded slash, dot segment, blank, control character or fragment; 512 characters at most). POST /ai/auth/verify takes it in the body; GET /ai/auth/oauth/:provider takes it in the query and keeps it in the session across the provider, like `state`, and a sign-in started again without one does not inherit it; - else the web editor, when the web edition is on: its configured address (an origin and a path, the first origin when LEVELCODE_WEB_EDITOR_URL is unset) with `signin=1`, the same address as the account site's "Open LevelCode". The editor takes the tab to /ai/login with its own PKCE challenge, finds the session just made here, and is back signed in with nothing typed; - else /ai/account, as before. With the web edition off (production today) nothing changes. An editor that is signing in (redirect_uri + code_challenge) is unchanged and `next` is not looked at. The editor's "Manage account" hand-off and the Stripe returns still land on /ai/account. Specs (web_spec.rb "where a web sign-in lands"): the editor address, its path kept, its origin when none is configured; off -> /ai/account; `next` honoured editor on or off; eleven malformed `next` values ignored; an editor sign-in untouched; through OAuth: the editor, a `next` carried across the provider, not inherited by a restarted sign-in, a malformed one ignored. Eight fail on the previous controller (the other two pin what must not change). The 14 existing "mints nothing for " cases still mint nothing and never go to that address; with the editor on, the web sign-in they fall back to now lands on the editor's own address instead of /ai/account, and they say so. --- app/controllers/levelcode/web_controller.rb | 58 +++++++++- spec/requests/levelcode/web_spec.rb | 115 +++++++++++++++++++- 2 files changed, 165 insertions(+), 8 deletions(-) diff --git a/app/controllers/levelcode/web_controller.rb b/app/controllers/levelcode/web_controller.rb index 41a61399..63fe8056 100644 --- a/app/controllers/levelcode/web_controller.rb +++ b/app/controllers/levelcode/web_controller.rb @@ -19,7 +19,8 @@ module Levelcode # server told of a web editor, that page's callback — Levelcode::EditorCallback) -> # mint a single-use Levelcode::OneTimeCode bound to the editor's PKCE # code_challenge and hand back `?code=` (never tokens). - # - web: Devise sign_in(user) -> destination /ai/account. + # - web: Devise sign_in(user) -> destination: the page the visitor asked for (`next`), else the + # web editor when it is on (it signs itself in from this session), else /ai/account. class WebController < ApplicationController include AccessTracking @@ -35,6 +36,9 @@ class WebController < ApplicationController EMAIL_SEND_WINDOW = 1.hour.to_i EMAIL_SEND_MAX = 20 + # The longest `next` (a page of this site to land on after signing in) that is taken. + NEXT_MAX = 512 + # POST /ai/auth/email (JSON) — passwordless step 1: email a 6-digit code. def email_request email = params[:email].to_s.strip.downcase @@ -57,12 +61,13 @@ def email_request render json: { sent: true } end - # POST /ai/auth/verify (JSON) — step 2. Body: { email, code, redirect_uri?, code_challenge? }. + # POST /ai/auth/verify (JSON) — step 2. Body: { email, code, redirect_uri?, code_challenge?, next? }. # The editor threads redirect_uri + code_challenge as params (there is no # server-rendered login page to stash them in session first). def email_verify @redirect_uri = editor_redirect_uri(params[:redirect_uri]) @code_challenge = editor_code_challenge(params[:code_challenge]) + @next = safe_next(params[:next]) email = params[:email].to_s.strip.downcase unless Levelcode::EmailCode.verify(email, params[:code]) @@ -92,6 +97,9 @@ def oauth_start (redirect_to("/ai/login") and return) unless %w[github google].include?(provider) stash_editor_context(safe_redirect_uri(params[:redirect_uri]), params[:code_challenge].presence) + # The page the visitor asked for rides the session across the provider, like `state`. + next_page = safe_next(params[:next]) + next_page ? session[:levelcode_next] = next_page : session.delete(:levelcode_next) state = SecureRandom.urlsafe_base64(24) session[:levelcode_oauth_state] = state @@ -109,10 +117,11 @@ def oauth_start # GET /ai/auth/callback — OAuth return: verify session `state`, exchange the # code server-side, then either 302 to the editor deep-link (with a one-time - # code) or sign in and 302 to /ai/account. + # code) or sign in and 302 to where a web sign-in lands (#web_destination). def oauth_callback provider = session.delete(:levelcode_oauth_provider).to_s expected_state = session.delete(:levelcode_oauth_state).to_s + @next = safe_next(session.delete(:levelcode_next)) raw_attribution = session.delete(:levelcode_oauth_attribution) if expected_state.blank? || params[:state].to_s != expected_state @@ -251,10 +260,51 @@ def deliver_signed_in(user) editor_callback_with_code(@redirect_uri, code) else sign_in(user) - "/ai/account" + web_destination end end + # Where a web sign-in lands: the page of this site the visitor asked for (`next`); else the web editor, when it + # is on — the editor is where the work is, and the account page is a click away in it; else the account page. + def web_destination + @next || web_editor_home || "/ai/account" + end + + # The web editor, asked to sign this visitor in (`signin=1`): it takes the tab to /ai/login with its own PKCE + # challenge, finds the session just made here, and is back signed in with nothing typed. The account site's + # "Open LevelCode" link is the same address (useWebEditor in the SPA). nil when the web edition is off. + def web_editor_home + rule = Levelcode::EditorCallback.current + return nil unless rule.web_enabled? && rule.web_url.present? + + # The address is an origin and, optionally, a path (LEVELCODE_WEB_EDITOR_URL; the first origin when it is unset): + # never a query of its own. + uri = URI.parse(rule.web_url) + uri.path = "/" if uri.path.empty? + uri.query = "signin=1" + uri.to_s + rescue URI::InvalidURIError + nil + end + + # A page of this site to land on after signing in, as the SPA passes it (`next`), or nil. A path under /ai/ and + # nothing that could be read as another place: no scheme or host, no `//` (a host to a browser), no backslash + # (a slash to some), no percent-encoded slash or backslash, no dot segment, no blank, control character or + # fragment. A query is kept (`/ai/pricing?plan=pro`). It is only ever a same-site path, so it cannot take the + # visitor anywhere 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/") + return nil if raw.match?(%r{//|\\|%2f|%5c|#|[[:space:][:cntrl:]]}i) + + uri = URI.parse(raw) + return nil if uri.scheme || uri.host || uri.userinfo + return nil if uri.path.split("/").any? { |segment| %w[. ..].include?(segment) } + + raw + rescue URI::InvalidURIError + nil + end + def editor_callback_with_code(uri_str, code) uri = URI.parse(uri_str) query = uri.query.present? ? Hash[URI.decode_www_form(uri.query)] : {} diff --git a/spec/requests/levelcode/web_spec.rb b/spec/requests/levelcode/web_spec.rb index c1511ce6..0f5f4ba3 100644 --- a/spec/requests/levelcode/web_spec.rb +++ b/spec/requests/levelcode/web_spec.rb @@ -414,6 +414,8 @@ def json = JSON.parse(response.body) 'the page with a fragment' => 'https://editor.levelcode.test/callback.html?vscode-reqid=1&vscode-scheme=levelcode&vscode-authority=levelcode.levelcode-ai&vscode-path=%2Fauth%2Fcallback#x' }.each do |what, address| + # Refused as a callback, it is a web sign-in: it lands where those land with the editor on — the editor's own + # address, which it configured, asked to sign itself in — never at the address given, and with no code. it "signs in to the web account and mints nothing for #{what}" do expect(Levelcode::OneTimeCode).not_to receive(:issue) @@ -421,7 +423,7 @@ def json = JSON.parse(response.body) params: { email: 'refused-web@example.com', code: '123456', redirect_uri: address, code_challenge: 'chal' } expect(response).to have_http_status(:ok) - expect(json).to eq('redirect' => '/ai/account') + expect(json).to eq('redirect' => 'https://editor.levelcode.test/?signin=1') end end end @@ -461,7 +463,7 @@ def json = JSON.parse(response.body) redirect_uri: "#{base}/callback.html?#{vscode_query}" } expect(response).to have_http_status(:ok) - expect(json).to eq('redirect' => '/ai/account') + expect(json).to eq('redirect' => 'https://editor.levelcode.test/?signin=1') # a web sign-in, with the editor on end end end @@ -488,6 +490,111 @@ def json = JSON.parse(response.body) end end + # Where a sign-in on the account site lands, when no editor is signing in. The editor, when its web edition is on: + # the editor is where the work is, and it signs itself in from the session just made. The page the visitor asked + # for (`next`), when they asked for one. The account page otherwise. + describe 'where a web sign-in lands' do + let(:editor_home) { 'https://app.levelcode.test/?signin=1' } + + before { allow(Levelcode::EmailCode).to receive(:verify).and_return(true) } + + def verify(extra = {}) + post '/ai/auth/verify', params: { email: 'lands@example.com', code: '123456' }.merge(extra) + end + + it 'in the web editor, asked to sign itself in, when it is on — and the session is made for it to find' do + with_web_editor('https://app.levelcode.test', url: 'https://app.levelcode.test/') + + verify + + expect(json).to eq('redirect' => editor_home) + get '/api/levelcode/v1/account/profile' + expect(response).to have_http_status(:ok) + end + + it 'at the editor address configured, its path kept — and at its origin when none is' do + with_web_editor('https://app.levelcode.test', url: 'https://app.levelcode.test/editor') + verify + expect(json).to eq('redirect' => 'https://app.levelcode.test/editor?signin=1') + + with_web_editor('https://app.levelcode.test') + post '/ai/auth/verify', params: { email: 'lands-2@example.com', code: '123456' } + expect(json).to eq('redirect' => editor_home) + end + + it 'on the account page when the web edition is off' do + verify + + expect(json).to eq('redirect' => '/ai/account') + end + + it 'on the page the visitor asked for, editor on or off' do + verify(next: '/ai/pricing?plan=pro') + expect(json).to eq('redirect' => '/ai/pricing?plan=pro') + + with_web_editor('https://app.levelcode.test', url: 'https://app.levelcode.test/') + post '/ai/auth/verify', params: { email: 'lands-2@example.com', code: '123456', next: '/ai/account' } + expect(json).to eq('redirect' => '/ai/account') + end + + it 'never anywhere a `next` could make of it: anything but a plain path under /ai/ is ignored' do + with_web_editor('https://app.levelcode.test', url: 'https://app.levelcode.test/') + [ 'https://evil.example/ai/x', '//evil.example/ai/x', '/ai//evil.example', '/ai/\\evil.example', '/ai/%2F%2Fevil.example', + '/ai/../users/edit', '/other/path', 'javascript:alert(1)', '/ai/x#y', "/ai/x\nSet-Cookie:a=b", '/ai/' + ('a' * 600) ].each_with_index do |bad, i| + post '/ai/auth/verify', params: { email: "bad-next-#{i}@example.com", code: '123456', next: bad } + expect(json).to eq('redirect' => editor_home), bad.inspect + end + end + + it 'leaves an editor that is signing in alone: it gets its code, and `next` is not looked at' do + with_web_editor('https://app.levelcode.test', url: 'https://app.levelcode.test/') + allow(Levelcode::OneTimeCode).to receive(:issue).and_return('one-time-code') + + verify(redirect_uri: editor_uri, code_challenge: 'chal', next: '/ai/account') + + expect(json).to eq('redirect' => "#{editor_uri}?code=one-time-code") + end + + describe 'through a provider (OAuth)' do + before do + allow(SecureRandom).to receive(:urlsafe_base64).and_return('teststate') + allow(Levelcode::ProviderOAuth).to receive(:authorize_url).and_return('https://github.test/authorize') + allow(Levelcode::ProviderOAuth).to receive(:github_user) + .and_return(User.create!(email: 'gh-lands@example.com', password: 'x' * 20, terms_accepted: true)) + with_web_editor('https://app.levelcode.test', url: 'https://app.levelcode.test/') + end + + it 'in the web editor when it is on' do + get '/ai/auth/oauth/github' + get '/ai/auth/callback', params: { code: 'gh', state: 'teststate' } + + expect(response).to redirect_to(editor_home) + end + + it 'on the page the visitor asked for, carried across the provider' do + get '/ai/auth/oauth/github', params: { next: '/ai/pricing?plan=pro' } + get '/ai/auth/callback', params: { code: 'gh', state: 'teststate' } + + expect(response).to redirect_to('/ai/pricing?plan=pro') + end + + it 'not on a page asked for in an earlier sign-in that did not finish' do + get '/ai/auth/oauth/github', params: { next: '/ai/account' } + get '/ai/auth/oauth/github' # started again, asking for nothing + get '/ai/auth/callback', params: { code: 'gh', state: 'teststate' } + + expect(response).to redirect_to(editor_home) + end + + it 'ignores a `next` that is not a plain path under /ai/' do + get '/ai/auth/oauth/github', params: { next: '//evil.example/ai/x' } + get '/ai/auth/callback', params: { code: 'gh', state: 'teststate' } + + expect(response).to redirect_to(editor_home) + end + end + end + describe 'OAuth' do it 'GET /ai/auth/oauth/github 302s to the provider authorize URL' do allow(Levelcode::ProviderOAuth).to receive(:authorize_url).and_return('https://github.test/authorize?state=x') @@ -555,14 +662,14 @@ def json = JSON.parse(response.body) expect(Levelcode::OneTimeCode).to have_received(:issue).with(User.find_by(email: 'gh-web@example.com'), 'chal') end - it 'does not carry an address that is not the page — the sign-in ends on the web account' do + it 'does not carry an address that is not the page — the sign-in is a web one, landing on the editor\'s own address' do expect(Levelcode::OneTimeCode).not_to receive(:issue) get '/ai/auth/oauth/github', params: { redirect_uri: web_callback.sub('editor.levelcode.test', 'evil.test'), code_challenge: 'chal' } get '/ai/auth/callback', params: { code: 'gh', state: 'teststate' } - expect(response).to redirect_to('/ai/account') + expect(response).to redirect_to('https://editor.levelcode.test/?signin=1') end end From 89e4ad3bf2a584cc0f85575326c303608c7f847a Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 21:39:36 -0400 Subject: [PATCH 3/5] docs(levelcode): the web editor may be on a subdomain of the account host now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditorCallback's note said the editor (and the extension host's wildcard parent) must not be hosted on a subdomain of levelcode.ai while the API took the session cookie from any request. It takes it only from this origin's own pages now, and the /ai/* POSTs carry Rails' CSRF token, so app.levelcode.ai — the owner's choice — is fine. The note now says so, and names the two things a subdomain can still do that another domain cannot: plant a cookie for .levelcode.ai (a `__Host-` session cookie would refuse it) and run whatever the page runs (LevelCode's own code only; isolate the extension host on another domain before it runs anyone else's). --- app/services/levelcode/editor_callback.rb | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/services/levelcode/editor_callback.rb b/app/services/levelcode/editor_callback.rb index fc481eed..020aba85 100644 --- a/app/services/levelcode/editor_callback.rb +++ b/app/services/levelcode/editor_callback.rb @@ -70,14 +70,15 @@ module Levelcode # parts, never by a test on the text of an address. The parent must be a domain the deployment # owns: a public suffix (`co.uk`) is not detected, and under it anyone may register `v--x`. # - # The editor, and the extension host's wildcard parent, MUST NOT be hosted on a subdomain of the - # account host (levelcode.ai) unless cookie auth on /api/levelcode/v1 is refused for same-site - # requests. The account session cookie is SameSite=Lax, which is a site boundary and not an - # origin one: it is sent on a request from a sibling subdomain. Api::Levelcode::V1::BaseController - # takes that cookie when there is no Bearer token, and has no CSRF or Origin check. A page that - # runs other people's extensions, on such a subdomain, can send credentialed requests that the - # account's session authorises (a plan change, a billing session, credits) — CORS here stops it - # reading the answer, not causing the effect. Host them on another registrable domain. + # The editor may be on a subdomain of the account host: app.levelcode.ai is where it goes. The + # account session cookie is SameSite=Lax, a site boundary and not an origin one, so it IS sent on a + # request from a sibling subdomain; Api::Levelcode::V1::BaseController counts it only on a request + # from this origin's own pages (#own_page_request?), and the /ai/* POSTs carry Rails' CSRF token, + # so such a page cannot spend the account's session (a plan change, a billing + # session, credits). Two things a subdomain can still do that another domain cannot: set a cookie + # for the parent domain (a session cookie planted on .levelcode.ai; a `__Host-` prefix on the + # session cookie would refuse it), and run with whatever the page runs. It runs only LevelCode's own + # code; before it runs anyone else's, put the extension host on another registrable domain. class EditorCallback HOST = "levelcode.levelcode-ai" PATH = "/auth/callback" From 4068f5c73bee1452f7bb3c9a80d63ab1e5ddc770 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 22:06:03 -0400 Subject: [PATCH 4/5] fix(levelcode): `next` follows the account site's rule exactly, and a failed sign-in keeps it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the account-site half's review of this branch (onetime, src/levelcode/next.ts): - an encoded dot segment got through: /ai/%2e%2e/account is /account to a browser. Dot segments are now refused plain or encoded; - `next` could name the server's own endpoints (/ai/auth/handoff?code=…, /ai/auth/oauth/…, /ai/csrf). They are not pages; refused; - the characters are now RFC 3986's path and query characters with well-formed escapes, as the SPA's, so a non-ASCII or `<` value is refused on both sides alike; - a failed OAuth sign-in (session_expired, oauth_failed) went back to /ai/login without the page asked for, so trying again landed elsewhere. It carries `next` now. The two validators were run over the same 20,000 generated values (the dot, separator, escape, endpoint and character cases combined at random): 1,685 accepted by both, no disagreement. Specs: six more refused values in the malformed table, and the failed-sign-in case; both fail on the previous commit. --- app/controllers/levelcode/web_controller.rb | 35 +++++++++++++++------ spec/requests/levelcode/web_spec.rb | 11 ++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/controllers/levelcode/web_controller.rb b/app/controllers/levelcode/web_controller.rb index 63fe8056..a1bcc571 100644 --- a/app/controllers/levelcode/web_controller.rb +++ b/app/controllers/levelcode/web_controller.rb @@ -36,8 +36,10 @@ class WebController < ApplicationController EMAIL_SEND_WINDOW = 1.hour.to_i EMAIL_SEND_MAX = 20 - # The longest `next` (a page of this site to land on after signing in) that is taken. + # The longest `next` (a page of this site to land on after signing in) that is taken, and the characters it may + # 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} # POST /ai/auth/email (JSON) — passwordless step 1: email a 6-digit code. def email_request @@ -126,7 +128,7 @@ def oauth_callback if expected_state.blank? || params[:state].to_s != expected_state log_auth(kind: "oauth", outcome: "failure", provider: provider, reason: "session_expired") - return redirect_to("/ai/login?error=session_expired") + return redirect_to(login_again("session_expired")) end user = @@ -137,7 +139,7 @@ def oauth_callback unless user&.persisted? log_auth(kind: "oauth", outcome: "failure", provider: provider, reason: "oauth_failed") - return redirect_to("/ai/login?error=oauth_failed") + return redirect_to(login_again("oauth_failed")) end stamp_oauth_attribution!(user, raw_attribution) @@ -264,6 +266,13 @@ def deliver_signed_in(user) end 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 } + query[:next] = @next if @next + "/ai/login?#{query.to_query}" + end + # Where a web sign-in lands: the page of this site the visitor asked for (`next`); else the web editor, when it # is on — the editor is where the work is, and the account page is a click away in it; else the account page. def web_destination @@ -287,18 +296,24 @@ def web_editor_home nil end - # A page of this site to land on after signing in, as the SPA passes it (`next`), or nil. A path under /ai/ and - # nothing that could be read as another place: no scheme or host, no `//` (a host to a browser), no backslash - # (a slash to some), no percent-encoded slash or backslash, no dot segment, no blank, control character or - # fragment. A query is kept (`/ai/pricing?plan=pro`). It is only ever a same-site path, so it cannot take the - # visitor anywhere this site's own links could not. + # A page of this site to land on after signing in, as the SPA passes it (`next`), or nil. The same rule as the + # 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 + # 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/") - return nil if raw.match?(%r{//|\\|%2f|%5c|#|[[:space:][:cntrl:]]}i) + return nil unless raw.match?(NEXT_CHARS) + return nil if raw.include?("//") || raw.match?(/%2f|%5c/i) + + 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" uri = URI.parse(raw) return nil if uri.scheme || uri.host || uri.userinfo - return nil if uri.path.split("/").any? { |segment| %w[. ..].include?(segment) } raw rescue URI::InvalidURIError diff --git a/spec/requests/levelcode/web_spec.rb b/spec/requests/levelcode/web_spec.rb index 0f5f4ba3..a0e933c3 100644 --- a/spec/requests/levelcode/web_spec.rb +++ b/spec/requests/levelcode/web_spec.rb @@ -540,7 +540,9 @@ def verify(extra = {}) it 'never anywhere a `next` could make of it: anything but a plain path under /ai/ is ignored' do with_web_editor('https://app.levelcode.test', url: 'https://app.levelcode.test/') [ 'https://evil.example/ai/x', '//evil.example/ai/x', '/ai//evil.example', '/ai/\\evil.example', '/ai/%2F%2Fevil.example', - '/ai/../users/edit', '/other/path', 'javascript:alert(1)', '/ai/x#y', "/ai/x\nSet-Cookie:a=b", '/ai/' + ('a' * 600) ].each_with_index do |bad, i| + '/ai/../users/edit', '/ai/%2e%2e/users/edit', '/ai/%2E./x', '/other/path', 'javascript:alert(1)', '/ai/x#y', + "/ai/x\nSet-Cookie:a=b", '/ai/' + ('a' * 600), '/ai/caf' + [ 0xe9 ].pack('U') + '/x', '/ai/