[object Object]

← back to Cli Printing Press

fix(cli): reject reserved placeholder hosts in spec validation (#984)

5f8dae13e1984d46a2bd7f9bf3bf83279fa4c8d6 · 2026-05-10 16:49:18 -0700 · Trevin Chow

* fix(cli): reject reserved placeholder hosts in spec validation

Browser-sniff emitters and LLM-generated YAML can ship paths like
https://example.com/resource as real endpoint URLs (#818, OLX retro).
The placeholder compiles cleanly into the runtime client and only fails
at first live call. Validate() now rejects absolute URL fields on
resources, sub-resources, and endpoints whose bare host is one of the
RFC 2606 / RFC 6761 reserved documentation names so the leak fails loudly
at spec.ParseBytes regardless of source.

Subdomained forms (api.example.com, geocoding-api.example.com) keep
passing so existing fixtures and the openapi/docspec base_url fallback
are unaffected.

Closes #818

* fix(cli): also reject reserved placeholder host in spec-level base_url

Extends the validator to cover APISpec.BaseURL at the top level so a
browser-sniff emitter that places base_url: https://example.com at the
root cannot slip past the per-resource/per-endpoint checks added in the
previous commit.

Adds rejection test cases for example.test, example.invalid, and bare
example (the three reserved hosts not previously exercised). Migrates
three inline test fixtures from bare example.com / example.test to
api.example.com so existing tests keep parsing through the strengthened
validator.

Refs #818

Files touched

Diff

commit 5f8dae13e1984d46a2bd7f9bf3bf83279fa4c8d6
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 16:49:18 2026 -0700

    fix(cli): reject reserved placeholder hosts in spec validation (#984)
    
    * fix(cli): reject reserved placeholder hosts in spec validation
    
    Browser-sniff emitters and LLM-generated YAML can ship paths like
    https://example.com/resource as real endpoint URLs (#818, OLX retro).
    The placeholder compiles cleanly into the runtime client and only fails
    at first live call. Validate() now rejects absolute URL fields on
    resources, sub-resources, and endpoints whose bare host is one of the
    RFC 2606 / RFC 6761 reserved documentation names so the leak fails loudly
    at spec.ParseBytes regardless of source.
    
    Subdomained forms (api.example.com, geocoding-api.example.com) keep
    passing so existing fixtures and the openapi/docspec base_url fallback
    are unaffected.
    
    Closes #818
    
    * fix(cli): also reject reserved placeholder host in spec-level base_url
    
    Extends the validator to cover APISpec.BaseURL at the top level so a
    browser-sniff emitter that places base_url: https://example.com at the
    root cannot slip past the per-resource/per-endpoint checks added in the
    previous commit.
    
    Adds rejection test cases for example.test, example.invalid, and bare
    example (the three reserved hosts not previously exercised). Migrates
    three inline test fixtures from bare example.com / example.test to
    api.example.com so existing tests keep parsing through the strengthened
    validator.
    
    Refs #818
---
 internal/cli/public_param_audit_test.go |   6 +-
 internal/openapi/parser_test.go         |   6 +-
 internal/spec/spec.go                   |  58 +++++++++++
 internal/spec/spec_test.go              | 169 ++++++++++++++++++++++++++++++++
 testdata/openapi/jsonapi-petstore.yaml  |   2 +-
 5 files changed, 234 insertions(+), 7 deletions(-)

diff --git a/internal/cli/public_param_audit_test.go b/internal/cli/public_param_audit_test.go
index 9a33ebc4..29191a15 100644
--- a/internal/cli/public_param_audit_test.go
+++ b/internal/cli/public_param_audit_test.go
@@ -18,7 +18,7 @@ func TestPublicParamAuditJSONInventoriesDecisionRequiredParams(t *testing.T) {
 name: public-param-test
 description: Public param test
 version: "0.1.0"
-base_url: https://example.test
+base_url: https://api.example.com
 auth:
   type: none
 resources:
@@ -67,7 +67,7 @@ func TestPublicParamAuditStrictFailsOnlyForUnreviewedCandidates(t *testing.T) {
 name: public-param-test
 description: Public param test
 version: "0.1.0"
-base_url: https://example.test
+base_url: https://api.example.com
 auth:
   type: none
 resources:
@@ -124,7 +124,7 @@ func TestPublicParamAuditStrictPassesWhenFlagNameIsInSpec(t *testing.T) {
 name: public-param-test
 description: Public param test
 version: "0.1.0"
-base_url: https://example.test
+base_url: https://api.example.com
 auth:
   type: none
 resources:
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index f76d1d79..663bae21 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -745,7 +745,7 @@ info:
   title: Hybrid
   version: "1.0"
 servers:
-  - url: https://example.com
+  - url: https://api.example.com
 components:
   securitySchemes:
     OAuth2:
@@ -791,7 +791,7 @@ info:
   title: Malformed
   version: "1.0"
 servers:
-  - url: https://example.com
+  - url: https://api.example.com
 components:
   securitySchemes:
     OAuth2:
@@ -830,7 +830,7 @@ info:
   title: Sentry
   version: "1.0"
 servers:
-  - url: https://example.com
+  - url: https://api.example.com
 components:
   securitySchemes:
     auth_token:
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 4cf8b3a9..82f0da2b 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -1548,6 +1548,9 @@ func (s *APISpec) Validate() error {
 	if s.BaseURL == "" && s.BasePath == "" {
 		return fmt.Errorf("base_url is required")
 	}
+	if err := validateReservedPlaceholderHost("base_url", s.BaseURL); err != nil {
+		return err
+	}
 	if len(s.Resources) == 0 {
 		return fmt.Errorf("at least one resource is required")
 	}
@@ -1590,6 +1593,9 @@ func (s *APISpec) Validate() error {
 		if len(r.Endpoints) == 0 && len(r.SubResources) == 0 {
 			return fmt.Errorf("resource %q has no endpoints", name)
 		}
+		if err := validateReservedPlaceholderHost(fmt.Sprintf("resource %q base_url", name), r.BaseURL); err != nil {
+			return err
+		}
 		for eName, e := range r.Endpoints {
 			if e.Method == "" {
 				return fmt.Errorf("resource %q endpoint %q: method is required", name, eName)
@@ -1597,6 +1603,12 @@ func (s *APISpec) Validate() error {
 			if e.Path == "" {
 				return fmt.Errorf("resource %q endpoint %q: path is required", name, eName)
 			}
+			if err := validateReservedPlaceholderHost(fmt.Sprintf("resource %q endpoint %q path", name, eName), e.Path); err != nil {
+				return err
+			}
+			if err := validateReservedPlaceholderHost(fmt.Sprintf("resource %q endpoint %q base_url", name, eName), e.BaseURL); err != nil {
+				return err
+			}
 			if err := validateEndpointPublicParamNames(e); err != nil {
 				return fmt.Errorf("resource %q endpoint %q: %w", name, eName, err)
 			}
@@ -1608,6 +1620,9 @@ func (s *APISpec) Validate() error {
 			if len(sub.Endpoints) == 0 {
 				return fmt.Errorf("resource %q sub-resource %q has no endpoints", name, subName)
 			}
+			if err := validateReservedPlaceholderHost(fmt.Sprintf("resource %q sub-resource %q base_url", name, subName), sub.BaseURL); err != nil {
+				return err
+			}
 			for eName, e := range sub.Endpoints {
 				if e.Method == "" {
 					return fmt.Errorf("resource %q sub-resource %q endpoint %q: method is required", name, subName, eName)
@@ -1615,6 +1630,12 @@ func (s *APISpec) Validate() error {
 				if e.Path == "" {
 					return fmt.Errorf("resource %q sub-resource %q endpoint %q: path is required", name, subName, eName)
 				}
+				if err := validateReservedPlaceholderHost(fmt.Sprintf("resource %q sub-resource %q endpoint %q path", name, subName, eName), e.Path); err != nil {
+					return err
+				}
+				if err := validateReservedPlaceholderHost(fmt.Sprintf("resource %q sub-resource %q endpoint %q base_url", name, subName, eName), e.BaseURL); err != nil {
+					return err
+				}
 				if err := validateEndpointPublicParamNames(e); err != nil {
 					return fmt.Errorf("resource %q sub-resource %q endpoint %q: %w", name, subName, eName, err)
 				}
@@ -1627,6 +1648,43 @@ func (s *APISpec) Validate() error {
 	return nil
 }
 
+// reservedPlaceholderHosts captures the IETF reserved documentation/test
+// hostnames from RFC 2606 §3 and RFC 6761 §6.4. When one of these appears as
+// the bare host of an endpoint URL (no subdomain), it almost always indicates
+// an unresolved placeholder from a sniff or LLM emitter that would otherwise
+// compile into the runtime client and fail at first call. Subdomains
+// (api.example.com, geocoding-api.example.com) remain allowed because the
+// codebase intentionally uses them as obviously-fake-but-parseable test
+// fixtures, and the openapi/docspec parsers fall back to api.example.com when
+// a source spec omits its servers block.
+var reservedPlaceholderHosts = map[string]bool{
+	"example.com":     true,
+	"example.org":     true,
+	"example.net":     true,
+	"example.test":    true,
+	"example.invalid": true,
+	"example":         true,
+}
+
+// validateReservedPlaceholderHost reports a clear error when rawURL is an
+// absolute URL whose bare host is reserved for documentation. Empty values,
+// relative paths, and subdomained hosts pass.
+func validateReservedPlaceholderHost(label, rawURL string) error {
+	rawURL = strings.TrimSpace(rawURL)
+	if rawURL == "" {
+		return nil
+	}
+	u, err := url.Parse(rawURL)
+	if err != nil || !u.IsAbs() || u.Host == "" {
+		return nil
+	}
+	host := strings.ToLower(u.Hostname())
+	if !reservedPlaceholderHosts[host] {
+		return nil
+	}
+	return fmt.Errorf("%s: %q points to reserved placeholder host %q (RFC 2606); this indicates an unresolved URL from spec emission (browser-sniff, docs-derived, or hand-authored). Supply a real URL, drop the endpoint, or mark it as a stub before generation", label, rawURL, host)
+}
+
 func (s *APISpec) NormalizeAuthEnvVarSpecs() {
 	if s == nil {
 		return
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index cd3daba6..f9c081a7 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -3505,3 +3505,172 @@ func validTierRoutingSpec() *APISpec {
 		},
 	}
 }
+
+// TestValidateRejectsReservedPlaceholderHost guards against the OLX-style
+// regression in #818 where a browser-sniff emitter shipped
+// `https://example.com/resource` as a real endpoint path, compiling cleanly
+// into the runtime client and failing only on first live call. The validator
+// must reject any absolute URL field whose bare host is one of the IETF
+// reserved documentation hostnames (RFC 2606 / RFC 6761), while leaving
+// relative paths and subdomained hosts (api.example.com, used as legitimate
+// test scaffolding throughout the codebase) untouched.
+func TestValidateRejectsReservedPlaceholderHost(t *testing.T) {
+	baseValid := func() APISpec {
+		return APISpec{
+			Name:    "demo",
+			BaseURL: "https://api.example.com",
+			Auth:    AuthConfig{Type: "none"},
+			Resources: map[string]Resource{
+				"items": {
+					Endpoints: map[string]Endpoint{
+						"list": {Method: "GET", Path: "/items"},
+					},
+				},
+			},
+		}
+	}
+
+	cases := []struct {
+		name       string
+		mutate     func(s *APISpec)
+		wantErr    string
+		wantNoErr  bool
+		wantHostIn string
+	}{
+		{
+			name: "spec-level base_url with bare example.com host is rejected",
+			mutate: func(s *APISpec) {
+				s.BaseURL = "https://example.com"
+			},
+			wantHostIn: "example.com",
+		},
+		{
+			name: "endpoint path with bare example.com host is rejected",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.Path = "https://example.com/resource"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantHostIn: "example.com",
+		},
+		{
+			name: "endpoint path with example.org host is rejected",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.Path = "http://example.org/v1/things"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantHostIn: "example.org",
+		},
+		{
+			name: "endpoint base_url override with example.net host is rejected",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.BaseURL = "https://example.net"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantHostIn: "example.net",
+		},
+		{
+			name: "endpoint path with example.test host is rejected",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.Path = "https://example.test/resource"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantHostIn: "example.test",
+		},
+		{
+			name: "endpoint path with example.invalid host is rejected",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.Path = "https://example.invalid/resource"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantHostIn: "example.invalid",
+		},
+		{
+			name: "endpoint path with bare example host is rejected",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.Path = "https://example/resource"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantHostIn: `"example"`,
+		},
+		{
+			name: "resource base_url override with example.com host is rejected",
+			mutate: func(s *APISpec) {
+				r := s.Resources["items"]
+				r.BaseURL = "https://example.com"
+				s.Resources["items"] = r
+			},
+			wantHostIn: "example.com",
+		},
+		{
+			name: "sub-resource endpoint with placeholder host is rejected",
+			mutate: func(s *APISpec) {
+				s.Resources["items"] = Resource{
+					Endpoints: map[string]Endpoint{
+						"list": {Method: "GET", Path: "/items"},
+					},
+					SubResources: map[string]Resource{
+						"reviews": {
+							Endpoints: map[string]Endpoint{
+								"get": {Method: "GET", Path: "https://example.com/reviews"},
+							},
+						},
+					},
+				}
+			},
+			wantHostIn: "example.com",
+		},
+		{
+			name:      "relative path passes",
+			mutate:    func(s *APISpec) {},
+			wantNoErr: true,
+		},
+		{
+			name: "subdomained example.com base_url passes",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.BaseURL = "https://api.example.com/v2"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantNoErr: true,
+		},
+		{
+			name: "subdomained example.com path passes",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.Path = "https://geocoding-api.example.com/v1/search"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantNoErr: true,
+		},
+		{
+			name: "query string containing example.com passes (relative path)",
+			mutate: func(s *APISpec) {
+				ep := s.Resources["items"].Endpoints["list"]
+				ep.Path = "/proxy?url=https://example.com/x"
+				s.Resources["items"].Endpoints["list"] = ep
+			},
+			wantNoErr: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			s := baseValid()
+			tc.mutate(&s)
+			err := s.Validate()
+			if tc.wantNoErr {
+				assert.NoError(t, err)
+				return
+			}
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tc.wantHostIn)
+			assert.Contains(t, err.Error(), "reserved placeholder host")
+		})
+	}
+}
diff --git a/testdata/openapi/jsonapi-petstore.yaml b/testdata/openapi/jsonapi-petstore.yaml
index a50cefd3..44991d44 100644
--- a/testdata/openapi/jsonapi-petstore.yaml
+++ b/testdata/openapi/jsonapi-petstore.yaml
@@ -4,7 +4,7 @@ info:
   version: 1.0.0
   description: Minimal JSON:API spec used as a generator-side fixture for envelope flattening, page[cursor] pagination, and x-prefix auth tests.
 servers:
-  - url: https://example.com/api
+  - url: https://api.example.com/api
 paths:
   /pets:
     get:

← cb1697ed fix(cli): force UTF-8 stdio in verify-skill Python subproces  ·  back to Cli Printing Press  ·  fix(generator): route receiver JSON helper through filters ( e952e807 →