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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/quic-go/quic-go v0.63.0
github.com/srcfl/ftw/go/protocol v0.0.0-20261016083708-8fdc990b05ac
golang.org/x/crypto v0.57.0
golang.org/x/net v0.58.0
golang.org/x/sys v0.48.0
modernc.org/sqlite v1.59.0
rsc.io/qr v0.2.0
Expand All @@ -36,7 +37,6 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.42.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
Expand Down
13 changes: 10 additions & 3 deletions internal/oauth/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) {
}
u, _ := url.Parse(redirect)
s.authorizePage(w, http.StatusBadRequest, pageError{Title: "This request cannot go on",
Text: "FTW Cloud cannot connect this assistant: " + desc + ".", Back: back, BackHost: u.Host})
Text: "FTW Cloud cannot connect this assistant: " + desc + ".", Back: back, BackHost: asciiHostPort(u)})
}
if len(req.state) > 1024 {
fail("invalid_request", "state is too long")
Expand Down Expand Up @@ -136,7 +136,8 @@ func (s *Server) serveAuthorize(w http.ResponseWriter, r *http.Request) {
host := c.Host
if !isCIMD(c.ID) {
if u, err := url.Parse(redirect); err == nil {
host = u.Hostname()
// In ASCII: checkRedirect refused a host without that form.
host, _ = asciiHost(u.Hostname())
}
}
con := consent{ClientID: c.ID, ClientName: c.Name, ClientHost: host, Verified: c.Verified, RedirectURI: redirect,
Expand Down Expand Up @@ -276,7 +277,7 @@ func (s *Server) view(c consent) requestView {
}
return requestView{Offer: c.Offer, Status: status, Code: c.Code,
Client: clientView{ID: c.ClientID, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified},
Redirect: redirectView{Host: u.Hostname(), Local: u.Scheme == "http" && isLoopback(u.Hostname())},
Redirect: redirectView{Host: redirectHost(u), Local: u.Scheme == "http" && isLoopback(u.Hostname())},
Scopes: splitScope(c.Scope),
Agent: agentKeyView{Key: base64.RawURLEncoding.EncodeToString(c.AgentKey), ID: cloudwire.DeviceID(c.AgentKey)},
Expires: c.Expires.UnixMilli()}
Expand Down Expand Up @@ -464,3 +465,9 @@ func (s *Server) event(w http.ResponseWriter, rid, browser string, c consent) (e
return false
}
}

// redirectHost is a redirect's host alone, in ASCII.
func redirectHost(u *url.URL) string {
h, _ := asciiHost(u.Hostname())
return h
}
7 changes: 6 additions & 1 deletion internal/oauth/cimd.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ func checkCIMDURL(raw string) (*url.URL, error) {
if _, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
return nil, errCIMDURL // an address, not a name
}
// The document's URL names its host as the owner will see it: in
// lower case ASCII already.
if a, ok := asciiHost(host); !ok || a != host {
return nil, errCIMDURL
}
if u.Path == "" || u.Path == "/" {
return nil, errCIMDURL
}
Expand All @@ -226,7 +231,7 @@ func (f *fetcher) get(ctx context.Context, clientID string) (clientMetadata, err
return e.md, e.err
}
}
host := strings.ToLower(u.Hostname())
host, _ := asciiHost(u.Hostname())
hl := f.hosts[host]
if hl == nil {
if len(f.hosts) >= cimdHosts {
Expand Down
13 changes: 10 additions & 3 deletions internal/oauth/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ func (s *Server) resolveClient(ctx context.Context, id string) (client, error) {
return client{}, err
}
u, _ := url.Parse(id)
return client{ID: id, Name: md.ClientName, Host: u.Hostname(), Verified: verifiedClient(id), RedirectURIs: md.RedirectURIs,
host, _ := asciiHost(u.Hostname()) // checkCIMDURL refused one without
return client{ID: id, Name: md.ClientName, Host: host, Verified: verifiedClient(id), RedirectURIs: md.RedirectURIs,
AuthMethod: authNone}, nil
}
c, ok, err := s.st.client(id)
Expand All @@ -59,19 +60,23 @@ func (s *Server) resolveClient(ctx context.Context, id string) (client, error) {
}
host := ""
if u, err := url.Parse(c.URI); err == nil {
host = u.Hostname()
host, _ = asciiHost(u.Hostname())
}
return client{ID: c.ID, Name: c.Name, Host: host, RedirectURIs: c.RedirectURIs, AuthMethod: c.AuthMethod, secret: c.Secret}, nil
}

// checkRedirect holds a redirect URI to the rules: https, or http to this
// computer's loopback for a client that runs on it. No fragment and no
// credentials. Custom schemes are refused, as the MCP spec asks.
// credentials. Custom schemes are refused, as the MCP spec asks, and so is
// a host with no ASCII form (asciiHost).
func checkRedirect(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.User != nil || u.Fragment != "" || strings.Contains(raw, "#") || u.Host == "" || len(raw) > 2048 {
return fmt.Errorf("redirect URI %q is not allowed", raw)
}
if _, ok := asciiHost(u.Hostname()); !ok {
return fmt.Errorf("redirect URI %q has a host with no ASCII form", raw)
}
switch u.Scheme {
case "https":
return nil
Expand Down Expand Up @@ -182,6 +187,8 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request) {
if reg.ClientURI != "" {
if u, err := url.Parse(reg.ClientURI); err != nil || u.Scheme != "https" {
reg.ClientURI = ""
} else if _, ok := asciiHost(u.Hostname()); !ok {
reg.ClientURI = ""
}
}
if s.st.countClients() >= maxClients {
Expand Down
82 changes: 82 additions & 0 deletions internal/oauth/host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package oauth

import (
"net"
"net/netip"
"net/url"
"strings"

"golang.org/x/net/idna"
)

// asciiHost is a host as the owner is shown it and the offer signs it,
// in lower case ASCII. A name with letters outside ASCII goes to its
// punycode form (UTS 46 lookup), so a look-alike spelt with another
// script's letters, such as a Cyrillic с in сlaude.ai, reads as the
// different name it is. An address goes to its canonical form. ok is false
// for a host with no ASCII form, an empty label, a trailing dot, an address
// with a zone, or a name whose last label a browser reads as a number and
// so as an IPv4 address (2130706433, 0177.0.0.1, 0x7f.1).
//
// A name already in ASCII is not checked against the IDNA rules: an
// underscore or a "--" in a label misleads no one, and clients registered
// with one keep working.
func asciiHost(host string) (string, bool) {
if host == "" || strings.HasSuffix(host, ".") {
return "", false
}
if addr, err := netip.ParseAddr(host); err == nil {
if addr.Zone() != "" {
return "", false
}
return addr.String(), true
}
a := strings.ToLower(host)
if strings.ContainsFunc(a, func(r rune) bool { return r >= 0x80 }) {
var err error
if a, err = idna.Lookup.ToASCII(host); err != nil {
return "", false
}
}
labels := strings.Split(a, ".")
for _, l := range labels {
if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' || strings.ContainsFunc(l, notHostByte) {
return "", false
}
}
if len(a) > 253 || isNumber(labels[len(labels)-1]) {
return "", false
}
return a, true
}

// notHostByte is anything but a letter, a digit, a hyphen or an underscore.
func notHostByte(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_')
}

// isNumber reports whether a browser's URL parser reads a label as an IPv4
// number: decimal or octal digits, or 0x and hex digits.
func isNumber(l string) bool {
if rest, ok := strings.CutPrefix(l, "0x"); ok {
return !strings.ContainsFunc(rest, func(r rune) bool {
return !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f')
})
}
return !strings.ContainsFunc(l, func(r rune) bool { return r < '0' || r > '9' })
}

// asciiHostPort is a URL's host and port with the host in ASCII, for a page
// that names where a link goes. Empty when the host has no ASCII form.
func asciiHostPort(u *url.URL) string {
h, ok := asciiHost(u.Hostname())
switch {
case !ok:
return ""
case u.Port() != "":
return net.JoinHostPort(h, u.Port())
case strings.Contains(h, ":"):
return "[" + h + "]"
}
return h
}
148 changes: 148 additions & 0 deletions internal/oauth/host_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package oauth

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"github.com/fxamacker/cbor/v2"
"github.com/srcfl/ftw-cloud/internal/keys"
"github.com/srcfl/ftw/go/protocol"
)

// A host is shown and signed in ASCII: a name in punycode when it has
// letters outside ASCII, an address in its canonical form. One a browser
// would read otherwise, or with no ASCII form, is refused.
func TestHostsAreASCII(t *testing.T) {
for in, want := range map[string]string{
"claude.ai": "claude.ai",
"Claude.AI": "claude.ai",
"сlaude.ai": "xn--laude-0ye.ai", // a Cyrillic с
"bücher.example": "xn--bcher-kva.example",
"xn--laude-0ye.ai": "xn--laude-0ye.ai",
"a_b.example": "a_b.example", // registered before, still works
"ab--cd.example": "ab--cd.example", // likewise
"127.0.0.1": "127.0.0.1",
"::1": "::1",
"::0001": "::1",
"2001:DB8:0:0::1": "2001:db8::1",
"1.2.3.example": "1.2.3.example",
"example.0x10z": "example.0x10z",
"localhost": "localhost",
"router.local": "router.local",
"x.example.museum1": "x.example.museum1",
} {
if got, ok := asciiHost(in); !ok || got != want {
t.Errorf("%q: %q %v, want %q", in, got, ok, want)
}
}
for _, bad := range []string{
"", "claude.ai.", "a..b", ".a.example", "-bad.example", "bad-.example", "ex ample.com", "exa\u200dmple.com",
"2130706433", "0177.0.0.1", "0x7f.0.0.1", "0x7f.1", "127.1", "1.2.3.4.5", "example.123", "example.0x1f", "0x",
"fe80::1%en0", "a\u202eb.example", strings.Repeat("a", 64) + ".example",
} {
if got, ok := asciiHost(bad); ok {
t.Errorf("%q has an ASCII form %q", bad, got)
}
}
}

// A metadata document's URL names its host in lower case ASCII already.
func TestADocumentURLNamesItsHostInASCII(t *testing.T) {
for _, u := range []string{"https://сlaude.ai/client.json", "https://Example.com/client.json", "https://claude.ai./client.json"} {
if _, err := checkCIMDURL(u); err == nil {
t.Errorf("%s was taken", u)
}
}
if _, err := checkCIMDURL("https://xn--laude-0ye.ai/client.json"); err != nil {
t.Fatalf("a punycode host was refused: %v", err)
}
}

// Clients registered with an underscore or a "--" in a redirect host keep
// working: the authorize step checks the redirect again and takes them.
func TestRedirectsInASCIIStillWork(t *testing.T) {
g := newRig(t)
for _, redirect := range []string{"https://a_b.example/cb", "https://ab--cd.example/cb"} {
resp, err := http.Post(g.srv.URL+"/oauth/register", "application/json",
strings.NewReader(`{"client_name":"X","redirect_uris":["`+redirect+`"]}`))
if err != nil {
t.Fatal(err)
}
var reg map[string]any
_ = json.NewDecoder(resp.Body).Decode(&reg)
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("%s: %d %v", redirect, resp.StatusCode, reg)
}
f := g.start(reg["client_id"].(string), redirect, "")
if want := strings.TrimSuffix(strings.TrimPrefix(redirect, "https://"), "/cb"); f.view.Client.Host != want {
t.Fatalf("%s shows %q", redirect, f.view.Client.Host)
}
}
}

// A registered client whose redirect looks like claude.ai in another script
// reaches the owner, the box page and the signed offer as the punycode name
// it is; a redirect with no ASCII form does not register.
func TestALookAlikeHostIsShownAndSignedAsPunycode(t *testing.T) {
g := newRig(t)
register := func(body string) (int, map[string]any) {
resp, err := http.Post(g.srv.URL+"/oauth/register", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
for _, bad := range []string{"https://exa\u200dmple.com/cb", "https://claude.ai./cb", "https://2130706433/cb", "https://0177.0.0.1/cb"} {
if status, out := register(`{"client_name":"X","redirect_uris":["` + bad + `"]}`); status != http.StatusBadRequest {
t.Fatalf("%q registered: %d %v", bad, status, out)
}
}

lookAlike := "https://сlaude.ai/cb"
status, reg := register(`{"client_name":"Claude","client_uri":"https://сlaude.ai","redirect_uris":["` + lookAlike + `"]}`)
if status != http.StatusCreated {
t.Fatalf("register: %d %v", status, reg)
}
f := g.start(reg["client_id"].(string), lookAlike, "")
if f.view.Client.Host != "xn--laude-0ye.ai" {
t.Fatalf("the box page shows %q", f.view.Client.Host)
}

raw, err := base64.RawURLEncoding.DecodeString(f.view.Offer)
if err != nil {
t.Fatal(err)
}
var so protocol.SignedOffer
if err := cbor.Unmarshal(raw, &so); err != nil {
t.Fatal(err)
}
pub, _ := g.keys.OfferKey(context.Background())
o, err := protocol.OpenOffer(map[string][]byte{keys.OfferKid(pub): pub}, so, g.now().UnixMilli())
if err != nil {
t.Fatal(err)
}
if o.ClientHost != "xn--laude-0ye.ai" {
t.Fatalf("the offer signs %q", o.ClientHost)
}

q, _ := g.authorizeParams(reg["client_id"].(string), lookAlike, "")
resp, err := noFollow.Get(g.srv.URL + "/oauth/authorize?" + q.Encode())
if err != nil {
t.Fatal(err)
}
page, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if bytes.Contains(page, []byte("сlaude")) || !bytes.Contains(page, []byte("xn--laude-0ye.ai")) {
t.Fatal("the consent page shows the look-alike, not its punycode")
}
}
2 changes: 1 addition & 1 deletion internal/oauth/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type pageError struct {
func (s *Server) consentPage(w http.ResponseWriter, rid string, c consent) {
u, _ := url.Parse(c.RedirectURI)
d := pageData{Nonce: randomToken(16), RID: rid, Name: c.ClientName, Host: c.ClientHost, Verified: c.Verified, FromDocument: isCIMD(c.ClientID),
RedirectHost: u.Host, Local: u.Scheme == "http" && isLoopback(u.Hostname()),
RedirectHost: asciiHostPort(u), Local: u.Scheme == "http" && isLoopback(u.Hostname()),
Code: showCode(c.Code), Link: appLink(rid, c.Offer)}
for _, sc := range splitScope(c.Scope) {
d.Scopes = append(d.Scopes, scopeWords[sc])
Expand Down
Loading