Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/controllers/api/levelcode/v1/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ module V1
# - `Authorization: Bearer <editor access JWT>` — 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
Expand Down Expand Up @@ -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
Expand Down
77 changes: 71 additions & 6 deletions app/controllers/levelcode/web_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<redirect_uri>?code=<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

Expand All @@ -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
Expand All @@ -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])
Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand All @@ -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)
Expand Down Expand Up @@ -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)] : {}
Expand Down
17 changes: 9 additions & 8 deletions app/services/levelcode/editor_callback.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
107 changes: 107 additions & 0 deletions spec/requests/api/levelcode/v1/session_cookie_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading