Skip to content

Commit e2da1bc

Browse files
authored
fix(scan): tolerate ref path items when parsing openapi specs (#309)
paths was typed as a map of operation structs, so a spec with a $ref string path item (valid in openapi 3.1) failed to decode and the whole spec was dropped with no finding. decode path items into a generic tree and extract per-operation security manually, treating a non-array security value as absent so it inherits the global default rather than being reported as an anonymous endpoint.
1 parent bf8c6a8 commit e2da1bc

2 files changed

Lines changed: 156 additions & 11 deletions

File tree

internal/scan/openapi.go

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,23 +79,80 @@ const (
7979
// openapiSpec is the minimal slice of an openapi/swagger document we care about:
8080
// the version banner, info block, top-level security and the path map. unknown
8181
// fields are ignored by both json and yaml decoders.
82+
//
83+
// path items decode into a bare interface{} rather than a typed operation struct
84+
// because openapi 3.1 allows a path item to be a "$ref" to a shared item instead
85+
// of a set of operations. a strongly typed sibling map (map[string]rawOpsStruct)
86+
// makes both json and yaml fail the whole document the moment one path item is a
87+
// $ref string next to another path item with real get/post operations, which
88+
// silently drops an otherwise valid, enumerable spec. interface{} accepts either
89+
// shape without erroring, and operationSecurity below sorts out what's actually
90+
// an operation object.
8291
type openapiSpec struct {
83-
OpenAPI string `json:"openapi" yaml:"openapi"`
84-
Swagger string `json:"swagger" yaml:"swagger"`
85-
Info openapiInfo `json:"info" yaml:"info"`
86-
Security []map[string][]string `json:"security" yaml:"security"`
87-
Paths map[string]map[string]rawOps `json:"paths" yaml:"paths"`
92+
OpenAPI string `json:"openapi" yaml:"openapi"`
93+
Swagger string `json:"swagger" yaml:"swagger"`
94+
Info openapiInfo `json:"info" yaml:"info"`
95+
Security []map[string][]string `json:"security" yaml:"security"`
96+
Paths map[string]map[string]interface{} `json:"paths" yaml:"paths"`
8897
}
8998

9099
type openapiInfo struct {
91100
Title string `json:"title" yaml:"title"`
92101
Version string `json:"version" yaml:"version"`
93102
}
94103

95-
// rawOps captures the per-operation security block. a pointer so an absent block
96-
// (inherit global) is distinct from an explicit empty one (security: [] = public).
97-
type rawOps struct {
98-
Security *[]map[string][]string `json:"security" yaml:"security"`
104+
// operationSecurity pulls the per-operation security block out of a decoded path
105+
// item entry. it reports present=false when the entry isn't an operation object
106+
// at all (a $ref path item, or a security key that was never declared), which the
107+
// caller treats as "inherit global" the same as an absent security key.
108+
func operationSecurity(op interface{}) (reqs []map[string][]string, present bool) {
109+
obj, ok := op.(map[string]interface{})
110+
if !ok {
111+
return nil, false
112+
}
113+
raw, ok := obj["security"]
114+
if !ok {
115+
return nil, false
116+
}
117+
// a security key whose value isn't a list (null, or a malformed scalar or
118+
// object) is not a usable requirement block. treat it as absent and inherit
119+
// the global default rather than fabricate an anonymous high-severity finding
120+
// from garbage, which also matches how the old typed decoder handled a null.
121+
if _, ok := raw.([]interface{}); !ok {
122+
return nil, false
123+
}
124+
return toSecurityReqs(raw), true
125+
}
126+
127+
// toSecurityReqs converts a decoded "security" value (a list of scheme->scopes
128+
// requirement objects) into the typed form securityAllowsAnonymous expects.
129+
// malformed entries are skipped rather than treated as a parse failure, since by
130+
// this point the document has already passed the openapi/swagger version check.
131+
func toSecurityReqs(raw interface{}) []map[string][]string {
132+
arr, ok := raw.([]interface{})
133+
if !ok {
134+
return nil
135+
}
136+
reqs := make([]map[string][]string, 0, len(arr))
137+
for _, item := range arr {
138+
obj, ok := item.(map[string]interface{})
139+
if !ok {
140+
continue
141+
}
142+
req := make(map[string][]string, len(obj))
143+
for scheme, scopesRaw := range obj {
144+
scopesArr, _ := scopesRaw.([]interface{})
145+
scopes := make([]string, 0, len(scopesArr))
146+
for _, s := range scopesArr {
147+
if str, ok := s.(string); ok {
148+
scopes = append(scopes, str)
149+
}
150+
}
151+
req[scheme] = scopes
152+
}
153+
reqs = append(reqs, req)
154+
}
155+
return reqs
99156
}
100157

101158
// OpenAPI probes the candidate spec paths concurrently and, on the first hit,
@@ -294,8 +351,8 @@ func specToResult(spec *openapiSpec) *OpenAPIResult {
294351
}
295352
// an explicit block decides on its own; an absent one inherits global.
296353
var unauth bool
297-
if op.Security != nil {
298-
unauth = securityAllowsAnonymous(*op.Security)
354+
if reqs, present := operationSecurity(op); present {
355+
unauth = securityAllowsAnonymous(reqs)
299356
} else {
300357
unauth = globalAllowsAnon
301358
}

internal/scan/openapi_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,94 @@ func TestOpenAPI_YAMLSpec(t *testing.T) {
268268
}
269269
}
270270

271+
// a spec with a $ref path item (a shared item defined elsewhere, valid since
272+
// openapi 3.1) sitting next to a normal operation. /pet can't be resolved without
273+
// fetching the referenced document, but /users must still be enumerated: the
274+
// $ref entry must not fail the whole document's decode.
275+
const openapiJSONWithRef = `{
276+
"openapi": "3.1.0",
277+
"info": {"title": "Ref API", "version": "1.0"},
278+
"paths": {
279+
"/pet": {"$ref": "#/components/pathItems/Pet"},
280+
"/users": {"get": {"summary": "list"}}
281+
}
282+
}`
283+
284+
// TestOpenAPI_RefPathItemDoesNotDropSpec locks a real regression: the old
285+
// map[string]rawOps decoded every path item as a strict operation-set struct, so
286+
// a $ref path item (its value is a plain string, not an object) failed both the
287+
// json and yaml unmarshal for the *entire* document, and an otherwise valid,
288+
// enumerable spec was silently rejected as "not a spec".
289+
func TestOpenAPI_RefPathItemDoesNotDropSpec(t *testing.T) {
290+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
291+
if r.URL.Path == "/openapi.json" {
292+
_, _ = w.Write([]byte(openapiJSONWithRef))
293+
return
294+
}
295+
w.WriteHeader(http.StatusNotFound)
296+
}))
297+
defer srv.Close()
298+
299+
result, err := OpenAPI(srv.URL, 5*time.Second, 4, "")
300+
if err != nil {
301+
t.Fatalf("OpenAPI: %v", err)
302+
}
303+
if result == nil {
304+
t.Fatal("expected a result despite the $ref path item, got nil")
305+
}
306+
if _, ok := hasEndpoint(result, "/users", http.MethodGet); !ok {
307+
t.Errorf("expected /users GET to be enumerated, got %+v", result.Endpoints)
308+
}
309+
}
310+
311+
// a globally-secured spec whose operation carries a security key that isn't a
312+
// list (here null). the interface{} extraction must treat this as absent and let
313+
// the operation inherit the enforced global requirement, exactly as the old typed
314+
// decoder did, not read it as an empty (public) block and fabricate a high finding.
315+
const openapiJSONNullOpSecurity = `{
316+
"openapi": "3.0.1",
317+
"info": {"title": "Null Sec API", "version": "1.0"},
318+
"security": [{"bearerAuth": []}],
319+
"paths": {
320+
"/weird": {"get": {"summary": "malformed security", "security": null}}
321+
}
322+
}`
323+
324+
// TestOpenAPI_NonListOpSecurityInheritsGlobal locks the empty-vs-absent boundary
325+
// against a false positive: a non-array security value must inherit the global
326+
// requirement (authenticated, medium), not decode to an empty block (anonymous,
327+
// high). the pre-interface{} struct decoded null to a nil pointer and inherited;
328+
// the extraction has to preserve that or every malformed security key becomes a
329+
// spurious high-severity unauthenticated finding.
330+
func TestOpenAPI_NonListOpSecurityInheritsGlobal(t *testing.T) {
331+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
332+
if r.URL.Path == "/openapi.json" {
333+
_, _ = w.Write([]byte(openapiJSONNullOpSecurity))
334+
return
335+
}
336+
w.WriteHeader(http.StatusNotFound)
337+
}))
338+
defer srv.Close()
339+
340+
result, err := OpenAPI(srv.URL, 5*time.Second, 4, "")
341+
if err != nil {
342+
t.Fatalf("OpenAPI: %v", err)
343+
}
344+
if result == nil {
345+
t.Fatal("expected a result, got nil")
346+
}
347+
ep, ok := hasEndpoint(result, "/weird", http.MethodGet)
348+
if !ok {
349+
t.Fatal("expected /weird GET to be enumerated")
350+
}
351+
if ep.Unauth {
352+
t.Error("a non-list security value should inherit the global requirement, not read as public")
353+
}
354+
if result.Severity != openapiSevMedium {
355+
t.Errorf("expected medium severity, got %q", result.Severity)
356+
}
357+
}
358+
271359
// TestOpenAPI_NoSpecExposed confirms a server with no spec at any candidate path
272360
// produces no result.
273361
func TestOpenAPI_NoSpecExposed(t *testing.T) {

0 commit comments

Comments
 (0)