Skip to content

Commit 3a28eea

Browse files
committed
internal/resolver: add ParseTarget for target URI validation
Fixes #8747 Add ParseTarget to internal/resolver. The function parses a gRPC target string into a resolver.Target and verifies that a resolver is registered for the parsed scheme using a caller-supplied lookup function. Scheme lookup rules: - Registered scheme (hierarchical or opaque form): accepted immediately. - Unregistered scheme in hierarchical URI (scheme://...): rejected; the caller chose this scheme explicitly, so no silent fallback occurs. - Opaque URI (e.g. host:port) or empty-scheme URI with an unregistered scheme: retried by prepending defaultScheme + ":///" if provided. Accepting a builder func instead of calling resolver.Get directly lets clientconn.go pass cc.getResolver, which also checks resolvers registered via dial options. Update clientconn.go and balancer/rls/config.go to use ParseTarget, removing the duplicate parsing and resolver-lookup logic from both. RELEASE NOTES: n/a
1 parent 3379b53 commit 3a28eea

File tree

5 files changed

+287
-46
lines changed

5 files changed

+287
-46
lines changed

balancer/rls/config.go

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ import (
2222
"bytes"
2323
"encoding/json"
2424
"fmt"
25-
"net/url"
2625
"time"
2726

2827
"google.golang.org/grpc/balancer"
2928
"google.golang.org/grpc/balancer/rls/internal/keys"
3029
"google.golang.org/grpc/internal"
3130
"google.golang.org/grpc/internal/pretty"
3231
rlspb "google.golang.org/grpc/internal/proto/grpc_lookup_v1"
32+
iresolver "google.golang.org/grpc/internal/resolver"
3333
"google.golang.org/grpc/resolver"
3434
"google.golang.org/grpc/serviceconfig"
3535
"google.golang.org/protobuf/encoding/protojson"
@@ -195,19 +195,9 @@ func parseRLSProto(rlsProto *rlspb.RouteLookupConfig) (*lbConfig, error) {
195195
if lookupService == "" {
196196
return nil, fmt.Errorf("rls: empty lookup_service in route lookup config %+v", rlsProto)
197197
}
198-
parsedTarget, err := url.Parse(lookupService)
198+
_, err = iresolver.ParseTarget(lookupService, resolver.GetDefaultScheme(), resolver.Get)
199199
if err != nil {
200-
// url.Parse() fails if scheme is missing. Retry with default scheme.
201-
parsedTarget, err = url.Parse(resolver.GetDefaultScheme() + ":///" + lookupService)
202-
if err != nil {
203-
return nil, fmt.Errorf("rls: invalid target URI in lookup_service %s", lookupService)
204-
}
205-
}
206-
if parsedTarget.Scheme == "" {
207-
parsedTarget.Scheme = resolver.GetDefaultScheme()
208-
}
209-
if resolver.Get(parsedTarget.Scheme) == nil {
210-
return nil, fmt.Errorf("rls: unregistered scheme in lookup_service %s", lookupService)
200+
return nil, fmt.Errorf("rls: invalid target URI in lookup_service %s: %v", lookupService, err)
211201
}
212202

213203
lookupServiceTimeout, err := convertDuration(rlsProto.GetLookupServiceTimeout())

balancer/rls/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func (s) TestParseConfigErrors(t *testing.T) {
263263
"lookupService": "badScheme:///target"
264264
}
265265
}`),
266-
wantErr: "rls: unregistered scheme in lookup_service",
266+
wantErr: "rls: invalid target URI in lookup_service",
267267
},
268268
{
269269
desc: "invalid lookup service timeout",

clientconn.go

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323
"errors"
2424
"fmt"
2525
"math"
26-
"net/url"
2726
"slices"
2827
"strings"
2928
"sync"
@@ -1798,54 +1797,33 @@ func (cc *ClientConn) connectionError() error {
17981797
func (cc *ClientConn) initParsedTargetAndResolverBuilder() error {
17991798
logger.Infof("original dial target is: %q", cc.target)
18001799

1801-
var rb resolver.Builder
1802-
parsedTarget, err := parseTarget(cc.target)
1803-
if err == nil {
1804-
rb = cc.getResolver(parsedTarget.URL.Scheme)
1805-
if rb != nil {
1806-
cc.parsedTarget = parsedTarget
1807-
cc.resolverBuilder = rb
1808-
return nil
1809-
}
1800+
// Try the target as given first. cc.getResolver checks both globally
1801+
// registered resolvers and any resolver registered via dial options.
1802+
if parsedTarget, err := iresolver.ParseTarget(cc.target, "", cc.getResolver); err == nil {
1803+
cc.parsedTarget = parsedTarget
1804+
cc.resolverBuilder = cc.getResolver(parsedTarget.URL.Scheme)
1805+
return nil
18101806
}
18111807

18121808
// We are here because the user's dial target did not contain a scheme or
18131809
// specified an unregistered scheme. We should fallback to the default
18141810
// scheme, except when a custom dialer is specified in which case, we should
1815-
// always use passthrough scheme. For either case, we need to respect any overridden
1816-
// global defaults set by the user.
1811+
// always use passthrough scheme. For either case, we need to respect any
1812+
// overridden global defaults set by the user.
18171813
defScheme := cc.dopts.defaultScheme
18181814
if internal.UserSetDefaultScheme {
18191815
defScheme = resolver.GetDefaultScheme()
18201816
}
18211817

1822-
canonicalTarget := defScheme + ":///" + cc.target
1823-
1824-
parsedTarget, err = parseTarget(canonicalTarget)
1818+
parsedTarget, err := iresolver.ParseTarget(defScheme+":///"+cc.target, "", cc.getResolver)
18251819
if err != nil {
18261820
return err
18271821
}
1828-
rb = cc.getResolver(parsedTarget.URL.Scheme)
1829-
if rb == nil {
1830-
return fmt.Errorf("could not get resolver for default scheme: %q", parsedTarget.URL.Scheme)
1831-
}
18321822
cc.parsedTarget = parsedTarget
1833-
cc.resolverBuilder = rb
1823+
cc.resolverBuilder = cc.getResolver(parsedTarget.URL.Scheme)
18341824
return nil
18351825
}
18361826

1837-
// parseTarget uses RFC 3986 semantics to parse the given target into a
1838-
// resolver.Target struct containing url. Query params are stripped from the
1839-
// endpoint.
1840-
func parseTarget(target string) (resolver.Target, error) {
1841-
u, err := url.Parse(target)
1842-
if err != nil {
1843-
return resolver.Target{}, err
1844-
}
1845-
1846-
return resolver.Target{URL: *u}, nil
1847-
}
1848-
18491827
// encodeAuthority escapes the authority string based on valid chars defined in
18501828
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.
18511829
func encodeAuthority(authority string) string {

internal/resolver/target.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
*
3+
* Copyright 2026 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package resolver
20+
21+
import (
22+
"fmt"
23+
"net/url"
24+
25+
"google.golang.org/grpc/resolver"
26+
)
27+
28+
// ParseTarget parses a gRPC target string into a resolver.Target, verifying
29+
// that a resolver is registered for the parsed scheme using builder.
30+
//
31+
// Hierarchical URIs (scheme://authority/path) with a non-empty scheme are
32+
// validated directly: if builder returns nil for the scheme an error is
33+
// returned immediately with no fallback.
34+
//
35+
// For opaque URIs (e.g. "host:port" where url.URL.Opaque is non-empty),
36+
// empty-scheme URIs, and parse failures, ParseTarget retries by prepending
37+
// defaultScheme + ":///" if defaultScheme is non-empty.
38+
//
39+
// builder is a function that returns the resolver.Builder for a given scheme,
40+
// or nil if no resolver is registered. Pass resolver.Get to use the global
41+
// resolver registry, or a custom lookup function (e.g. cc.getResolver) to
42+
// also consider resolvers registered via dial options.
43+
func ParseTarget(target, defaultScheme string, builder func(string) resolver.Builder) (resolver.Target, error) {
44+
u, err := url.Parse(target)
45+
if err == nil && u.Scheme != "" {
46+
if builder(u.Scheme) != nil {
47+
// Recognised scheme (hierarchical or opaque form) — use as-is.
48+
return resolver.Target{URL: *u}, nil
49+
}
50+
if u.Opaque == "" {
51+
// Unregistered scheme in hierarchical URI form (scheme://...): the
52+
// caller explicitly chose this scheme; do not silently fall back.
53+
return resolver.Target{}, fmt.Errorf("no resolver registered for scheme %q in target %q", u.Scheme, target)
54+
}
55+
// Opaque URI (e.g. "host:port") with unregistered scheme: treat the
56+
// same as an empty-scheme URI and fall through to the retry below.
57+
}
58+
// Parse error, empty scheme, or opaque URI with unregistered scheme:
59+
// retry by prepending defaultScheme if one is provided.
60+
if defaultScheme != "" {
61+
if u2, err2 := url.Parse(defaultScheme + ":///" + target); err2 == nil && builder(u2.Scheme) != nil {
62+
return resolver.Target{URL: *u2}, nil
63+
}
64+
}
65+
if err != nil {
66+
return resolver.Target{}, fmt.Errorf("invalid target URI %q: %v", target, err)
67+
}
68+
if u.Scheme == "" {
69+
return resolver.Target{}, fmt.Errorf("target URI %q has no scheme", target)
70+
}
71+
return resolver.Target{}, fmt.Errorf("no resolver registered for scheme %q in target %q", u.Scheme, target)
72+
}

internal/resolver/target_test.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/*
2+
*
3+
* Copyright 2026 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package resolver_test
20+
21+
import (
22+
"strings"
23+
"testing"
24+
25+
iresolver "google.golang.org/grpc/internal/resolver"
26+
_ "google.golang.org/grpc/internal/resolver/passthrough" // Register passthrough resolver.
27+
"google.golang.org/grpc/resolver"
28+
_ "google.golang.org/grpc/resolver/dns" // Register dns resolver.
29+
)
30+
31+
func TestParseTarget(t *testing.T) {
32+
tests := []struct {
33+
name string
34+
target string
35+
defaultScheme string
36+
wantScheme string
37+
wantErr bool
38+
errContain string
39+
}{
40+
{
41+
name: "valid dns scheme",
42+
target: "dns:///example.com:443",
43+
wantScheme: "dns",
44+
},
45+
{
46+
name: "valid passthrough scheme",
47+
target: "passthrough:///localhost:8080",
48+
wantScheme: "passthrough",
49+
},
50+
{
51+
name: "valid dns scheme with default",
52+
target: "dns:///example.com:443",
53+
defaultScheme: "dns",
54+
wantScheme: "dns",
55+
},
56+
{
57+
name: "missing scheme falls back to default",
58+
target: "/path/to/socket",
59+
defaultScheme: "passthrough",
60+
wantScheme: "passthrough",
61+
},
62+
{
63+
name: "missing scheme without default",
64+
target: "/path/to/socket",
65+
wantErr: true,
66+
errContain: "has no scheme",
67+
},
68+
{
69+
name: "host:port retries with default scheme",
70+
target: "localhost:8080",
71+
defaultScheme: "passthrough",
72+
wantScheme: "passthrough",
73+
},
74+
{
75+
name: "host:port without default",
76+
target: "localhost:8080",
77+
wantErr: true,
78+
errContain: "no resolver registered for scheme",
79+
},
80+
{
81+
name: "unregistered scheme",
82+
target: "unknown:///example.com:443",
83+
wantErr: true,
84+
errContain: "no resolver registered for scheme",
85+
},
86+
{
87+
// Explicit hierarchical URI with unknown scheme is rejected even when
88+
// a default is provided. Only opaque URIs (host:port) fall back.
89+
name: "unregistered explicit scheme is rejected",
90+
target: "unknown:///foo",
91+
defaultScheme: "passthrough",
92+
wantErr: true,
93+
errContain: "no resolver registered for scheme",
94+
},
95+
{
96+
name: "invalid URI",
97+
target: "dns:///example\x00.com",
98+
wantErr: true,
99+
errContain: "invalid target URI",
100+
},
101+
}
102+
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
got, err := iresolver.ParseTarget(tt.target, tt.defaultScheme, resolver.Get)
106+
if (err != nil) != tt.wantErr {
107+
t.Errorf("ParseTarget(%q, %q) error = %v, wantErr %v", tt.target, tt.defaultScheme, err, tt.wantErr)
108+
return
109+
}
110+
if tt.wantErr {
111+
if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) {
112+
t.Errorf("ParseTarget(%q, %q) error = %q, want it to contain %q", tt.target, tt.defaultScheme, err, tt.errContain)
113+
}
114+
return
115+
}
116+
if got.URL.Scheme != tt.wantScheme {
117+
t.Errorf("ParseTarget(%q, %q).URL.Scheme = %q, want %q", tt.target, tt.defaultScheme, got.URL.Scheme, tt.wantScheme)
118+
}
119+
})
120+
}
121+
}
122+
123+
func TestParseTargetWithCustomBuilder(t *testing.T) {
124+
// A registry that only recognises "passthrough". This mirrors the
125+
// cc.getResolver pattern in ClientConn, which may include resolvers
126+
// registered via dial options that are invisible to resolver.Get.
127+
passthroughOnly := func(scheme string) resolver.Builder {
128+
if scheme == "passthrough" {
129+
return resolver.Get("passthrough")
130+
}
131+
return nil
132+
}
133+
134+
tests := []struct {
135+
name string
136+
target string
137+
defaultScheme string
138+
wantScheme string
139+
wantErr bool
140+
errContain string
141+
}{
142+
{
143+
name: "known scheme resolves",
144+
target: "passthrough:///service:8080",
145+
wantScheme: "passthrough",
146+
},
147+
{
148+
name: "dns not in custom registry",
149+
target: "dns:///example.com:443",
150+
wantErr: true,
151+
errContain: "no resolver registered for scheme",
152+
},
153+
{
154+
// Explicit hierarchical URI: dns is not in the custom registry, and
155+
// unlike an opaque "host:port" URI, explicit schemes are not retried.
156+
name: "unregistered explicit scheme rejected even with default",
157+
target: "dns:///example.com:443",
158+
defaultScheme: "passthrough",
159+
wantErr: true,
160+
errContain: "no resolver registered for scheme",
161+
},
162+
{
163+
// Opaque URI (host:port form) falls back to the default scheme.
164+
name: "host:port falls back to custom default",
165+
target: "service:8080",
166+
defaultScheme: "passthrough",
167+
wantScheme: "passthrough",
168+
},
169+
{
170+
name: "missing scheme without default",
171+
target: "/path",
172+
wantErr: true,
173+
errContain: "has no scheme",
174+
},
175+
{
176+
name: "missing scheme uses default",
177+
target: "/path",
178+
defaultScheme: "passthrough",
179+
wantScheme: "passthrough",
180+
},
181+
}
182+
183+
for _, tt := range tests {
184+
t.Run(tt.name, func(t *testing.T) {
185+
got, err := iresolver.ParseTarget(tt.target, tt.defaultScheme, passthroughOnly)
186+
if (err != nil) != tt.wantErr {
187+
t.Errorf("ParseTarget(%q, %q) error = %v, wantErr %v", tt.target, tt.defaultScheme, err, tt.wantErr)
188+
return
189+
}
190+
if tt.wantErr {
191+
if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) {
192+
t.Errorf("ParseTarget(%q, %q) error = %q, want it to contain %q", tt.target, tt.defaultScheme, err, tt.errContain)
193+
}
194+
return
195+
}
196+
if got.URL.Scheme != tt.wantScheme {
197+
t.Errorf("ParseTarget(%q, %q).URL.Scheme = %q, want %q", tt.target, tt.defaultScheme, got.URL.Scheme, tt.wantScheme)
198+
}
199+
})
200+
}
201+
}

0 commit comments

Comments
 (0)