From 037f40e77d0f861a4d84fdace171f0c9661b7171 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:30:51 +0200 Subject: [PATCH 1/2] fix(oauth): show and sign a client's host in ASCII, so a look-alike reads as punycode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registered client's redirect host, its client_uri and a metadata document's host went to the consent page, the box page and the signed offer as url.Hostname() gave them, so https://сlaude.ai/cb with a Cyrillic с arrived as a look-alike clientHost. Every host shown or signed now goes through asciiHost: lower case ASCII, a name in punycode via golang.org/x/net/idna (UTS 46 lookup), an address as it is. A redirect, a client_id URL or a client_uri with no ASCII form is refused. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- internal/oauth/authorize.go | 13 +++-- internal/oauth/cimd.go | 5 +- internal/oauth/clients.go | 13 +++-- internal/oauth/host.go | 45 +++++++++++++++++ internal/oauth/host_test.go | 98 +++++++++++++++++++++++++++++++++++++ internal/oauth/page.go | 2 +- 7 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 internal/oauth/host.go create mode 100644 internal/oauth/host_test.go diff --git a/go.mod b/go.mod index 537b92b..324c466 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/oauth/authorize.go b/internal/oauth/authorize.go index ff2a6f0..b780dbe 100644 --- a/internal/oauth/authorize.go +++ b/internal/oauth/authorize.go @@ -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") @@ -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, @@ -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()} @@ -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 +} diff --git a/internal/oauth/cimd.go b/internal/oauth/cimd.go index 75e27f4..f650aac 100644 --- a/internal/oauth/cimd.go +++ b/internal/oauth/cimd.go @@ -200,6 +200,9 @@ func checkCIMDURL(raw string) (*url.URL, error) { if _, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil { return nil, errCIMDURL // an address, not a name } + if _, ok := asciiHost(host); !ok { + return nil, errCIMDURL + } if u.Path == "" || u.Path == "/" { return nil, errCIMDURL } @@ -226,7 +229,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 { diff --git a/internal/oauth/clients.go b/internal/oauth/clients.go index f30fa2d..a3f99d7 100644 --- a/internal/oauth/clients.go +++ b/internal/oauth/clients.go @@ -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) @@ -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 @@ -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 { diff --git a/internal/oauth/host.go b/internal/oauth/host.go new file mode 100644 index 0000000..a280992 --- /dev/null +++ b/internal/oauth/host.go @@ -0,0 +1,45 @@ +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: +// lower case ASCII, a name in its punycode form, 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 IP address stays as it is. ok is false for a host +// with no ASCII form, which FTW Cloud refuses. +func asciiHost(host string) (string, bool) { + if host == "" { + return "", false + } + if _, err := netip.ParseAddr(host); err == nil { + return host, true + } + a, err := idna.Lookup.ToASCII(host) + if err != nil || a == "" || strings.HasPrefix(a, ".") || strings.Contains(a, "..") { + return "", false + } + return a, true +} + +// 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()) + if !ok { + return "" + } + if p := u.Port(); p != "" { + return net.JoinHostPort(h, p) + } + if _, err := netip.ParseAddr(h); err == nil && u.Hostname() != u.Host { + return "[" + h + "]" + } + return h +} diff --git a/internal/oauth/host_test.go b/internal/oauth/host_test.go new file mode 100644 index 0000000..bce9cf6 --- /dev/null +++ b/internal/oauth/host_test.go @@ -0,0 +1,98 @@ +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, an address as it +// is. One 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", + "127.0.0.1": "127.0.0.1", + "::1": "::1", + } { + if got, ok := asciiHost(in); !ok || got != want { + t.Errorf("%q: %q %v, want %q", in, got, ok, want) + } + } + for _, bad := range []string{"", "a_b.example", "xn--a.example", "exa‍mple.com", "ex ample.com", "-bad.example", "a..b", ".a.example"} { + if got, ok := asciiHost(bad); ok { + t.Errorf("%q has an ASCII form %q", bad, got) + } + } +} + +// 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‍mple.com/cb", "https://a_b.example/cb", "https://xn--a.example/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") + } +} diff --git a/internal/oauth/page.go b/internal/oauth/page.go index a1595cb..53145bc 100644 --- a/internal/oauth/page.go +++ b/internal/oauth/page.go @@ -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]) From 108284f88abbd17c4b89ad19083de65cb07b26b8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 11:29:02 +0200 Subject: [PATCH 2/2] fix(oauth): refuse a trailing dot and hosts a browser reads as numbers, canonicalise addresses, keep ASCII hosts that worked Review of #24: - asciiHost refuses a trailing dot, an empty or over-long label, a zone, and a name whose last label a browser's URL parser reads as an IPv4 number (2130706433, 0177.0.0.1, 0x7f.1). An address goes to its canonical form (::0001 is ::1). - A host already in ASCII is not held to the IDNA rules, so a client registered with an underscore or a "--" in its redirect host still passes checkRedirect at authorize; only a host with letters outside ASCII goes through UTS 46 lookup. - A client_id document URL must name its host as asciiHost gives it. Co-Authored-By: Claude Opus 5.5 --- internal/oauth/cimd.go | 4 ++- internal/oauth/host.go | 69 +++++++++++++++++++++++++++--------- internal/oauth/host_test.go | 70 +++++++++++++++++++++++++++++++------ 3 files changed, 116 insertions(+), 27 deletions(-) diff --git a/internal/oauth/cimd.go b/internal/oauth/cimd.go index f650aac..df37160 100644 --- a/internal/oauth/cimd.go +++ b/internal/oauth/cimd.go @@ -200,7 +200,9 @@ func checkCIMDURL(raw string) (*url.URL, error) { if _, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil { return nil, errCIMDURL // an address, not a name } - if _, ok := asciiHost(host); !ok { + // 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 == "/" { diff --git a/internal/oauth/host.go b/internal/oauth/host.go index a280992..43376ef 100644 --- a/internal/oauth/host.go +++ b/internal/oauth/host.go @@ -9,36 +9,73 @@ import ( "golang.org/x/net/idna" ) -// asciiHost is a host as the owner is shown it and the offer signs it: -// lower case ASCII, a name in its punycode form, 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 IP address stays as it is. ok is false for a host -// with no ASCII form, which FTW Cloud refuses. +// 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 == "" { + if host == "" || strings.HasSuffix(host, ".") { return "", false } - if _, err := netip.ParseAddr(host); err == nil { - return host, true + if addr, err := netip.ParseAddr(host); err == nil { + if addr.Zone() != "" { + return "", false + } + return addr.String(), true } - a, err := idna.Lookup.ToASCII(host) - if err != nil || a == "" || strings.HasPrefix(a, ".") || strings.Contains(a, "..") { + 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()) - if !ok { + switch { + case !ok: return "" - } - if p := u.Port(); p != "" { - return net.JoinHostPort(h, p) - } - if _, err := netip.ParseAddr(h); err == nil && u.Hostname() != u.Host { + case u.Port() != "": + return net.JoinHostPort(h, u.Port()) + case strings.Contains(h, ":"): return "[" + h + "]" } return h diff --git a/internal/oauth/host_test.go b/internal/oauth/host_test.go index bce9cf6..367be92 100644 --- a/internal/oauth/host_test.go +++ b/internal/oauth/host_test.go @@ -15,28 +15,78 @@ import ( "github.com/srcfl/ftw/go/protocol" ) -// A host is shown and signed in ASCII: a name in punycode, an address as it -// is. One with no ASCII form is refused. +// 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", - "127.0.0.1": "127.0.0.1", - "::1": "::1", + "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{"", "a_b.example", "xn--a.example", "exa‍mple.com", "ex ample.com", "-bad.example", "a..b", ".a.example"} { + 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(®) + 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. @@ -52,7 +102,7 @@ func TestALookAlikeHostIsShownAndSignedAsPunycode(t *testing.T) { _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } - for _, bad := range []string{"https://exa‍mple.com/cb", "https://a_b.example/cb", "https://xn--a.example/cb"} { + 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) }