← 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
M internal/cli/public_param_audit_test.goM internal/openapi/parser_test.goM internal/spec/spec.goM internal/spec/spec_test.goM testdata/openapi/jsonapi-petstore.yaml
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 →