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/app/controllers/levelcode/web_controller.rb b/app/controllers/levelcode/web_controller.rb index 41a61399..a1bcc571 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,11 @@ 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, 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 email = params[:email].to_s.strip.downcase @@ -57,12 +63,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 +99,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,15 +119,16 @@ 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 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 = @@ -128,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) @@ -251,10 +262,64 @@ def deliver_signed_in(user) editor_callback_with_code(@redirect_uri, code) else sign_in(user) - "/ai/account" + web_destination 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 + @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. 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 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 + + 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/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" 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 diff --git a/spec/requests/levelcode/web_spec.rb b/spec/requests/levelcode/web_spec.rb index c1511ce6..3223195d 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,126 @@ 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', '/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/