Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0367b0e
Accept self-registered redirects only on loopback, cursor:// and clau…
jeremy Oct 23, 2026
5363306
Require a host on app-scheme redirects, and http for loopback
jeremy Oct 23, 2026
4530eba
Merge oauth-token-digests: refuse non-scalar OAuth parameters (#3236)
jeremy Oct 24, 2026
5e36b45
Merge oauth-token-digests: main, with the crass 1.0.8 bump
jeremy Oct 24, 2026
0330e09
Merge oauth-token-digests: filter the code parameter by its whole name
jeremy Oct 24, 2026
6b22ef7
Merge oauth-token-digests: redact retired refresh tokens from logged SQL
jeremy Oct 24, 2026
d9c1872
Pin that DCR refuses callbacks on hosts 37signals runs
jeremy Oct 24, 2026
a581962
Document OAUTH_DCR_ALLOWED_HTTPS_HOSTS for self-hosted installs
jeremy Oct 24, 2026
6066a97
Merge oauth-token-digests: its migrations can be run again after stop…
jeremy Oct 25, 2026
a91924a
Merge oauth-token-digests
jeremy Oct 25, 2026
f82eba9
Merge oauth-token-digests into oauth-dcr-allowlist
jeremy Oct 26, 2026
e4f9098
Merge oauth-cimd into oauth-dcr-allowlist, so they land on main in order
jeremy Oct 26, 2026
3556921
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
5f6d27d
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
feb6354
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
b55334b
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
2f78a60
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
338ef90
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
c94afd5
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
2ef264e
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
e9c8514
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
755d7ab
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
6d5ed9a
Merge oauth-cimd into oauth-dcr-allowlist
jeremy Oct 26, 2026
d989db0
Merge oauth-cimd: registration actor, exact-id revocation, quoted max…
jeremy Oct 26, 2026
f07c950
Merge oauth-cimd: review fixes from below
jeremy Oct 26, 2026
324b5cb
Merge oauth-cimd: review fixes from below
jeremy Oct 26, 2026
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
19 changes: 10 additions & 9 deletions app/controllers/oauth/clients_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def bounded_redirect_uris?(uris)

def validate_redirect_uri_origins
unless performed? || all_registrable_uris?(params[:redirect_uris])
oauth_error "invalid_redirect_uri", "Only https or local loopback redirect URIs are allowed for dynamic registration"
oauth_error "invalid_redirect_uri", registrable_uris_description
end
end

Expand All @@ -82,21 +82,22 @@ def all_registrable_uris?(uris)

def registrable_uri?(uri)
parsed = URI.parse(uri)
parsed.fragment.nil? && (loopback_uri?(parsed) || https_uri?(parsed))
Oauth.dcr_redirect_shape?(parsed) && Oauth.dcr_vetted_https_redirect?(parsed)
rescue URI::InvalidURIError
false
end

def registered_client_name
(params[:client_name] if params[:client_name].is_a?(String)).presence || "MCP Client"
end
def registrable_uris_description
schemes = Oauth::DCR_CUSTOM_SCHEMES.map { |scheme| "#{scheme}://" }.join(" or ")
hosts = Oauth.dcr_allowed_https_hosts

def loopback_uri?(parsed)
parsed.scheme == "http" && Oauth.plain_authority?(parsed) && Oauth.loopback_host?(parsed.host)
"Dynamic registration accepts local loopback redirect URIs (http://127.0.0.1, http://[::1] or http://localhost), " \
"#{schemes} app redirects, " \
"#{hosts.any? ? "and https redirects on #{hosts.join(", ")}" : "and no https redirects on this server"}"
end

def https_uri?(parsed)
parsed.scheme == "https" && Oauth.plain_authority?(parsed) && !Oauth.loopback_host?(parsed.host)
def registered_client_name
(params[:client_name] if params[:client_name].is_a?(String)).presence || "MCP Client"
end

def validated_scopes
Expand Down
55 changes: 55 additions & 0 deletions app/models/oauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,61 @@ def self.cleanup_step(step)
raise
end

# Dynamic client registration is open and unauthenticated, so a remote
# callback is the sink an attacker who registers a client would point codes
# at. Self-registered clients may therefore name only:
#
# - an http loopback redirect (RFC 8252 §7.3), served on the user's device;
# - a private-use scheme from DCR_CUSTOM_SCHEMES (RFC 8252 §7.1), dispatched
# by the operating system to an app on the user's device;
# - an https redirect on a vetted vendor host (dcr_allowed_https_hosts).
#
# Only cursor and claude are accepted as schemes: they are the desktop MCP
# clients confirmed to send one. Dotted reverse-DNS schemes get no pass on
# shape, since some dotted schemes route to a remote handler (z39.50r,
# iris.beep). Add a scheme or a host only when a real client is confirmed
# to need it. The consent screen, which always shows where the code goes,
# stays the backstop.
DCR_CUSTOM_SCHEMES = %w[ cursor claude ]
Comment thread
jeremy marked this conversation as resolved.

# The vetted https callback hosts, from OAUTH_DCR_ALLOWED_HTTPS_HOSTS in
# production. None by default, so an install that never names one accepts
# only on-device redirects.
def self.dcr_allowed_https_hosts
Array(Rails.configuration.x.oauth.dcr_allowed_https_hosts).map(&:downcase)
end

def self.dcr_allowed_https_host?(host)
dcr_allowed_https_hosts.include?(host.to_s.downcase)
end

# Whether a redirect has the shape a self-registered client may hold: no
# fragment, and http loopback, https on a plain host, or an allowlisted
# private-use scheme with a target. Whether its https host is vetted is
# judged only when a registration adds it (Oauth::Client), so a client
# stays valid after its host leaves the list.
def self.dcr_redirect_shape?(uri)
uri.fragment.nil? && \
case uri.scheme&.downcase
when "http" then plain_authority?(uri) && loopback_host?(uri.host)
when "https" then plain_authority?(uri) && !loopback_host?(uri.host)
when *DCR_CUSTOM_SCHEMES then private_use_redirect?(uri)
Comment thread
jeremy marked this conversation as resolved.
else false
end
end

# Hierarchical (cursor://host/path, never the opaque cursor:path, which
# can't carry the callback's query), with no userinfo, and naming a plain
# host, which consent shows as where the code goes.
def self.private_use_redirect?(uri)
uri.opaque.nil? && uri.userinfo.nil? && plain_host?(uri.host.to_s)
end

# A remote https redirect a registration may add: an allowlisted host.
def self.dcr_vetted_https_redirect?(uri)
uri.scheme&.downcase != "https" || dcr_allowed_https_host?(uri.host)
end

def self.table_name_prefix
"oauth_"
end
Expand Down
31 changes: 24 additions & 7 deletions app/models/oauth/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class Oauth::Client < ApplicationRecord
validates :token_endpoint_auth_method, inclusion: { in: AUTH_METHODS }
validate :redirect_uris_are_valid
validate :protected_resource_is_valid
validate :added_https_hosts_are_vetted, if: -> { dynamically_registered? && (new_record? || redirect_uris_changed?) }

# A confidential client's secret is stored as a digest (TokenDigester). The
# raw secret exists only in memory on the record that generated it, for the
Expand Down Expand Up @@ -296,25 +297,41 @@ def validate_redirect_uri(uri)
errors.add :redirect_uris, "must not contain fragments"
end

if self_registered? && !valid_loopback_uri?(parsed) && !valid_https_uri?(parsed)
errors.add :redirect_uris, "must be an https or local loopback URI for dynamically registered clients"
if self_registered? && !Oauth.dcr_redirect_shape?(parsed)
errors.add :redirect_uris, "must be a local loopback, https or allowed app-scheme URI for dynamically registered clients"
end
rescue URI::InvalidURIError
errors.add :redirect_uris, "includes an invalid URI"
end

# http only: an app scheme naming a loopback host (cursor://localhost) is
# dispatched to an app, not served on loopback (RFC 8252 §7.3).
def loopback_uri?(uri)
Oauth.loopback_host?(URI.parse(uri).host)
parsed = URI.parse(uri)
parsed.scheme&.downcase == "http" && Oauth.loopback_host?(parsed.host)
rescue URI::InvalidURIError
false
end

def valid_loopback_uri?(parsed)
parsed.scheme == "http" && Oauth.plain_authority?(parsed) && Oauth.loopback_host?(parsed.host)
# Judged only on the https hosts a write adds, never on those the client
# already holds, so taking a host off the list leaves every client that
# registered it working (Oauth.dcr_allowed_https_hosts).
def added_https_hosts_are_vetted
unvetted = https_hosts(redirect_uris) - https_hosts(new_record? ? [] : redirect_uris_was)
unvetted.reject! { |host| Oauth.dcr_allowed_https_host?(host) }

if unvetted.any?
errors.add :redirect_uris, "names an https host not allowed for dynamic registration: #{unvetted.join(", ")}"
end
end

def valid_https_uri?(parsed)
parsed.scheme == "https" && Oauth.plain_authority?(parsed) && !Oauth.loopback_host?(parsed.host)
def https_hosts(uris)
Array(uris).filter_map do |uri|
parsed = URI.parse(uri.to_s)
parsed.host.to_s.downcase if parsed.scheme&.downcase == "https"
rescue URI::InvalidURIError
nil
end.uniq
end

# Only the port may vary, and only for an http loopback redirect (RFC 8252
Expand Down
4 changes: 2 additions & 2 deletions app/views/oauth/authorizations/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
<p class="margin-none"><%= @client.name %></p>
<% if @client.dynamically_registered? %>
<% redirect = URI.parse(@redirect_uri) %>
<% if Oauth.loopback_host?(redirect.host) %>
<% if redirect.scheme&.downcase == "http" && Oauth.loopback_host?(redirect.host) %>
<p class="txt-small txt-muted margin-none">Registered by a local tool. Only authorize if you trust it.</p>
<% else %>
<p class="txt-small txt-muted margin-none">Self-registered app that will receive access at <strong><%= redirect.port == redirect.default_port ? redirect.host : "#{redirect.host}:#{redirect.port}" %></strong>. Only authorize if you trust it.</p>
<p class="txt-small txt-muted margin-none">Self-registered app that will receive access at <strong><%= "#{redirect.scheme.downcase}://" unless redirect.scheme.casecmp?("https") %><%= redirect.port == redirect.default_port ? redirect.host : "#{redirect.host}:#{redirect.port}" %></strong>. Only authorize if you trust it.</p>
<% end %>
<% elsif @client.metadata_document? %>
<p class="txt-small txt-muted margin-none">Name set by the app at <strong><%= URI.parse(@client.client_id).host %></strong>.</p>
Expand Down
1 change: 1 addition & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
config.x.oauth.acceptance_enabled = ENV["OAUTH_DARK"].blank?
config.x.oauth.cimd_enabled = true
config.x.oauth.cimd_allowed_hosts = %w[ mcp.claude.ai claude.ai claude.com chatgpt.com ]
config.x.oauth.dcr_allowed_https_hosts = ENV.fetch("OAUTH_DCR_ALLOWED_HTTPS_HOSTS", "").split(/[\s,]+/).compact_blank

# Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs)
config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" }
Expand Down
6 changes: 6 additions & 0 deletions config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@
config.x.oauth.cimd_enabled = ENV["OAUTH_CIMD_ENABLED"] == "true"
config.x.oauth.cimd_allowed_hosts = ENV.fetch("OAUTH_CIMD_ALLOWED_HOSTS", "").split

# Dynamic client registration accepts https redirects only on these hosts
# (OAUTH_DCR_ALLOWED_HTTPS_HOSTS, separated by spaces or commas), beside
# loopback and the cursor:// and claude:// app schemes. Unset, it accepts
# no https redirect at all. See Oauth.dcr_redirect_shape?.
config.x.oauth.dcr_allowed_https_hosts = ENV.fetch("OAUTH_DCR_ALLOWED_HTTPS_HOSTS", "").split(/[\s,]+/).compact_blank
Comment thread
jeremy marked this conversation as resolved.

# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end
1 change: 1 addition & 0 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
config.x.oauth.acceptance_enabled = true
config.x.oauth.cimd_enabled = true
config.x.oauth.cimd_allowed_hosts = %w[ mcp.claude.ai claude.ai claude.com chatgpt.com ]
config.x.oauth.dcr_allowed_https_hosts = %w[ connector.example.com ]

# Enable multi-tenant mode for tests
config.x.multi_tenant.enabled = true
Expand Down
8 changes: 8 additions & 0 deletions docs/docker-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ To turn it on, set `OAUTH_ACCEPTANCE_ENABLED=true`.

If you need to stop new connections in an emergency, also set `OAUTH_ISSUANCE_ENABLED=false`: nothing new can connect and no token is refreshed, while access tokens already issued keep working until they expire, an hour after issue. People can always disconnect apps from their profile, whichever way these are set.

Apps that register themselves (dynamic client registration, which Cursor, Claude Desktop and most MCP clients use) may send people back only to a callback Fizzy accepts:

- a callback on the person's own device: `http://127.0.0.1`, `http://[::1]` or `http://localhost`, on any port;
- the `cursor://` and `claude://` desktop app schemes;
- an `https` callback on a host listed in `OAUTH_DCR_ALLOWED_HTTPS_HOSTS`, separated by spaces or commas, for example `OAUTH_DCR_ALLOWED_HTTPS_HOSTS="claude.ai claude.com chatgpt.com www.cursor.com grok.com"`. Each entry matches that exact host only, not its subdomains.

`OAUTH_DCR_ALLOWED_HTTPS_HOSTS` is empty unless you set it, so web-based clients such as claude.ai and ChatGPT can't register until you list their callback hosts. Apps registered before you change the list keep their callbacks; the list is checked only at registration.

## Importing an existing Fizzy account

You can move an account between Fizzy instances by exporting it on the old instance and uploading the export zip to the new one during signup.
Expand Down
5 changes: 5 additions & 0 deletions saas/config/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ env:
# Configuration, not a credential, and inert on its own: nothing is fetched until
# OAUTH_CIMD_ENABLED is "true", which no destination sets.
OAUTH_CIMD_ALLOWED_HOSTS: "mcp.claude.ai claude.ai claude.com chatgpt.com"
# The vendor hosts whose https callbacks dynamic client registration accepts, beside loopback and the
# cursor:// and claude:// schemes: Claude's web and desktop connectors (claude.ai, claude.com), ChatGPT
# (chatgpt.com), Cursor and the Grok Bot (www.cursor.com), and Grok's own connector (grok.com). Add a host
# only when a real client is confirmed to need it.
OAUTH_DCR_ALLOWED_HTTPS_HOSTS: "claude.ai claude.com chatgpt.com www.cursor.com grok.com"

proxy:
ssl: true
Expand Down
163 changes: 163 additions & 0 deletions test/integration/oauth_dcr_redirect_allowlist_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
require "test_helper"

class OauthDcrRedirectAllowlistTest < ActionDispatch::IntegrationTest
include OauthAvailabilityTestHelper

test "registration accepts https only on an allowed host" do
with_dcr_allowed_https_hosts "claude.ai", "chatgpt.com" do
register "https://claude.ai/api/mcp/auth_callback"
assert_response :created

register "https://connector.example.com/callback"
assert_response :bad_request
assert_equal "invalid_redirect_uri", response.parsed_body["error"]
assert_match "https redirects on claude.ai, chatgpt.com", response.parsed_body["error_description"]
end
end

test "one unallowed host refuses the whole registration" do
with_dcr_allowed_https_hosts "claude.ai" do
assert_no_difference "Oauth::Client.count" do
register "http://127.0.0.1:8888/callback", "https://claude.ai/api/mcp/auth_callback", "https://elsewhere.example/callback"
end

assert_response :bad_request
assert_equal "invalid_redirect_uri", response.parsed_body["error"]
end
end

# Consent names a self-registered app by where its access returns, so a
# callback on a host we run would read as ours. The allowlist names only
# vendor hosts (production's, from saas/config/deploy.yml), so ours are
# refused, in any case or subdomain.
test "registration refuses https callbacks on hosts 37signals runs" do
with_dcr_allowed_https_hosts "claude.ai", "claude.com", "chatgpt.com", "www.cursor.com", "grok.com" do
%w[
https://app.fizzy.do/callback
https://FIZZY.do/callback
https://3.basecamp.com/callback
https://basecamp.com/callback
https://launchpad.37signals.com/callback
https://app.hey.com/callback
].each do |uri|
assert_no_difference "Oauth::Client.count" do
register uri
end

assert_response :bad_request, uri
assert_equal "invalid_redirect_uri", response.parsed_body["error"], uri
end
end
end

test "with no allowed hosts, registration takes loopback and app schemes but no https" do
with_dcr_allowed_https_hosts do
register "https://claude.ai/api/mcp/auth_callback"
assert_response :bad_request
assert_match "no https redirects on this server", response.parsed_body["error_description"]

register "http://127.0.0.1:8888/callback", "http://[::1]:8888/callback", "http://localhost:8888/callback"
assert_response :created
end
end

test "registration accepts the cursor and claude app schemes and no others" do
register "cursor://anysphere.cursor-mcp/oauth/callback", "claude://claude.ai/mcp-auth-callback/sdk"
assert_response :created

%w[ com.example.app://callback vscode://callback cursor:callback ].each do |uri|
register uri
assert_response :bad_request, uri
assert_equal "invalid_redirect_uri", response.parsed_body["error"], uri
end
end

test "an app-scheme client gets its code at its app redirect" do
sign_in_as :david
register "cursor://anysphere.cursor-mcp/oauth/callback"
client = Oauth::Client.find_by!(client_id: response.parsed_body["client_id"])

untenanted do
get new_oauth_authorization_path, params: {
client_id: client.client_id,
redirect_uri: "cursor://anysphere.cursor-mcp/oauth/callback",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
scope: "read"
}
end
assert_response :success
assert_includes response.headers["Content-Security-Policy"], "cursor:"
assert_select "strong", text: "cursor://anysphere.cursor-mcp"

untenanted do
post oauth_authorization_path, params: {
client_id: client.client_id,
redirect_uri: "cursor://anysphere.cursor-mcp/oauth/callback",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
scope: "read",
state: "xyz123"
}
end

assert_response :redirect
location = URI.parse(response.location)
assert_equal "cursor", location.scheme
assert_equal "anysphere.cursor-mcp", location.host
assert Rack::Utils.parse_query(location.query)["code"].present?
end

test "an app scheme naming a loopback host gets no pre-consent redirect" do
sign_in_as :david
register "cursor://localhost/callback"
assert_response :created
client = Oauth::Client.find_by!(client_id: response.parsed_body["client_id"])

untenanted do
get new_oauth_authorization_path, params: {
client_id: client.client_id,
redirect_uri: "cursor://localhost/callback",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain",
scope: "read"
}
end

assert_response :bad_request
assert_nil response.location
end

test "a client registered before its host left the allowlist still authorizes" do
sign_in_as :david
client = Oauth::Client.create!(name: "Hosted", redirect_uris: %w[ https://connector.example.com/callback ], dynamically_registered: true)

with_dcr_allowed_https_hosts do
untenanted do
post oauth_authorization_path, params: {
client_id: client.client_id,
redirect_uri: "https://connector.example.com/callback",
response_type: "code",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
scope: "read",
state: "xyz123"
}
end
end

assert_response :redirect
assert_match %r{\Ahttps://connector\.example\.com/callback\?}, response.location
assert Rack::Utils.parse_query(URI.parse(response.location).query)["code"].present?
end

private
def register(*redirect_uris)
untenanted do
post oauth_clients_path, params: { client_name: "Connector", redirect_uris: redirect_uris }, as: :json
end
end
end
Loading
Loading