From 6d262a369d2201cf8bbff21c59fb528130f60c7e Mon Sep 17 00:00:00 2001 From: Tony West <125916820+twest-bf@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:29:13 -0400 Subject: [PATCH] fix: Keep all declared request body content types instead of last-wins --- README.md | 35 +++ cmd/bodyvariant.go | 275 ++++++++++++++++++++++ cmd/bodyvariant_test.go | 267 ++++++++++++++++++++++ cmd/log.go | 26 ++- cmd/requests.go | 69 ++++-- cmd/root.go | 2 + cmd/utils.go | 296 ++++++++++++------------ cmd/utils_test.go | 354 +++++++++++++++++++++++++++-- tests/test_multi_content_type.yaml | 78 +++++++ 9 files changed, 1217 insertions(+), 185 deletions(-) create mode 100644 cmd/bodyvariant.go create mode 100644 cmd/bodyvariant_test.go create mode 100644 tests/test_multi_content_type.yaml diff --git a/README.md b/README.md index 3c1474f..6f7f96d 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,40 @@ $ curl -X GET "https://petstore.swagger.io/v2/user/bishopfox" $ curl -X PUT "https://petstore.swagger.io/v2/user/bishopfox" -H 'Content-Type: application/json' -d '{"email":"noreply@localhost.localdomain","firstName":"bishopfox","id":1,"lastName":"bishopfox","password":"bishopfox","phone":"bishopfox","userStatus":1,"username":"bishopfox"}' ``` +### Multiple request body content types + +An operation often declares the same body under several content types. By default `sj` +sends the one most likely to be accepted, preferring `application/json`, then +`application/x-www-form-urlencoded`, then `multipart/form-data`, then XML. The choice is +deterministic, so repeated runs produce identical commands. + +Because a JSON parser and an XML parser are different attack surface, `--all-content-types` +sends every declared type instead of only the preferred one: + +```bash +$ sj prepare -l spec.yaml -T https://api.example.com -q --all-content-types + +$ curl -X POST "https://api.example.com/multi" -H 'Content-Type: application/json' -d '{"name":"bishopfox","size":1}' +$ curl -X POST "https://api.example.com/multi" -H 'Content-Type: application/x-www-form-urlencoded' -d 'name=bishopfox&size=1' +$ curl -X POST "https://api.example.com/multi" -F 'name=bishopfox' -F 'size=1' +$ curl -X POST "https://api.example.com/multi" -H 'Content-Type: application/xml' -d 'bishopfox1' +``` + +Note that this multiplies the number of requests sent to the target, and that +`--replay-proxy` receives every one of them. + +To test a single specific encoding, pass it with `-H`. When the operation declares that +type, `sj` sends the matching body under it: + +```bash +$ sj prepare -l spec.yaml -T https://api.example.com -q -H "Content-Type: application/xml" +``` + +When the operation does *not* declare it, `sj` warns and sends the preferred body under +your header anyway, which is useful for parser-differential testing. Content types `sj` +cannot encode a body for (`application/octet-stream`, `text/plain`) are skipped rather +than sent as an empty body under a misleading header. + > Use the `endpoints` command to generate a list of raw endpoints from the provided definition file. ```bash @@ -219,6 +253,7 @@ Available Commands: Flags: -A, --agent string Set the User-Agent string. (default "Swagger Jacker (github.com/BishopFox/sj)") + --all-content-types Send a separate request for every request body content type an operation declares, instead of only the preferred one. -b, --base-path string Set the API base path if not defined in the definition file (i.e. /V2/). -f, --format string Declare the format of the definition file (json/yaml/yml/js). (default "json") -H, --headers stringArray Add custom headers, separated by a colon ("Name: Value"). Multiple flags are accepted. diff --git a/cmd/bodyvariant.go b/cmd/bodyvariant.go new file mode 100644 index 0000000..f81f96b --- /dev/null +++ b/cmd/bodyvariant.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "mime/multipart" + "slices" + "strings" +) + +// The content types sj can encode a request body for. +const ( + ctJSON = "application/json" + ctForm = "application/x-www-form-urlencoded" + ctMultipart = "multipart/form-data" + ctXML = "application/xml" +) + +// bodyVariant is one declared request-body content type together with the exact +// bytes sj would send for it. An operation that declares several content types +// yields several variants and none is discarded, so the operator picks which +// one goes on the wire rather than a map iteration picking for them. +type bodyVariant struct { + declared string // normalized spec key, e.g. "multipart/form-data" + contentType string // wire Content-Type; multipart carries the boundary + body string // exact bytes to send + curlArgs string // body arguments only: " -d " or the " -F ..." set + curlBodyArg string // shell-quoted body, for the sqlmap --data rewrite; "" for multipart + omitCurlCT bool // multipart only: curl picks its own boundary, so print no -H +} + +// normalizeContentType lowercases a declared content key and drops its +// parameters, so "Application/JSON; charset=utf-8" is recognized as JSON. +// Media types are case-insensitive and may carry parameters, but the encoder +// lookup is an exact match. +func normalizeContentType(key string) string { + if idx := strings.Index(key, ";"); idx >= 0 { + key = key[:idx] + } + return strings.ToLower(strings.TrimSpace(key)) +} + +// contentTypePriority ranks the body encodings sj can produce so that a run +// sending a single variant picks the one most likely to be accepted. Lower +// sorts first; a type sj has no encoder for reports false and yields no +// variant, rather than a Content-Type header promising a body that was never +// built. +func contentTypePriority(declared string) (int, bool) { + switch { + // "*/*" is what springdoc and swagger-codegen emit for an untyped body. + // Treating it as JSON keeps those specs producing a body instead of + // regressing them to a bodiless request. + case declared == ctJSON || declared == "*/*" || strings.HasSuffix(declared, "+json"): + return 0, true + case declared == ctForm: + return 1, true + case declared == ctMultipart: + return 2, true + case declared == ctXML || declared == "text/xml" || strings.HasSuffix(declared, "+xml"): + return 3, true + } + return 0, false +} + +// newTextBodyVariant assembles a variant whose body is sent verbatim as a +// single -d argument. +func newTextBodyVariant(declared, wireType, body string) bodyVariant { + quoted := shellSingleQuote(body) + return bodyVariant{ + declared: declared, + contentType: wireType, + body: body, + curlArgs: " -d " + quoted, + curlBodyArg: quoted, + } +} + +// newMultipartBodyVariant encodes obj as a multipart body. The wire +// Content-Type carries sj's boundary, but the printed command gets only -F +// arguments and no -H: curl generates a boundary of its own, so echoing sj's +// would print a command that cannot reproduce the request. +func newMultipartBodyVariant(declared string, obj map[string]interface{}) bodyVariant { + keys := slices.Sorted(maps.Keys(obj)) + var buf bytes.Buffer + var args strings.Builder + mw := multipart.NewWriter(&buf) + for _, k := range keys { + _ = mw.WriteField(k, fmt.Sprintf("%v", obj[k])) + args.WriteString(" -F " + shellSingleQuote(fmt.Sprintf("%s=%v", k, obj[k]))) + } + _ = mw.Close() + + return bodyVariant{ + declared: declared, + contentType: mw.FormDataContentType(), + body: buf.String(), + curlArgs: args.String(), + omitCurlCT: true, + } +} + +// buildVariant encodes example under the declared content type. It reports +// false for a type sj has no encoder for, and for a structured type whose +// example is not an object: only JSON can carry a bare array or scalar body. +func buildVariant(declared string, example interface{}) (bodyVariant, bool) { + priority, ok := contentTypePriority(declared) + if !ok { + return bodyVariant{}, false + } + + if priority == 0 { + encoded, err := json.Marshal(example) + if err != nil { + return bodyVariant{}, false + } + // "*/*" names no real media type, so send the body under the type it + // was actually encoded as. A vendor type such as + // "application/vnd.api+json" is kept as declared. + wireType := declared + if declared == "*/*" { + wireType = ctJSON + } + return newTextBodyVariant(declared, wireType, string(encoded)), true + } + + obj, ok := example.(map[string]interface{}) + if !ok { + return bodyVariant{}, false + } + + switch priority { + case 1: + return newTextBodyVariant(declared, declared, encodeFormBody(obj)), true + case 2: + return newMultipartBodyVariant(declared, obj), true + case 3: + return newTextBodyVariant(declared, declared, XmlFromObject(obj)), true + } + return bodyVariant{}, false +} + +// buildBodyVariants encodes every content type an OpenAPI v3 requestBody +// declares. It mutates no package state and appends to no curl string, so the +// bytes for each content type are built independently of how the request is +// eventually emitted. +func buildBodyVariants(spec, reqBody, ctxSpec map[string]interface{}) []bodyVariant { + contentTypes, ok := reqBody["content"].(map[string]interface{}) + if !ok { + return nil + } + + var variants []bodyVariant + // Sorted rather than ranged: a bare map range over the content types made + // which body sj sent vary from one run to the next. + for _, cType := range slices.Sorted(maps.Keys(contentTypes)) { + ct, ok := contentTypes[cType].(map[string]interface{}) + if !ok { + continue + } + schema, ok := ct["schema"].(map[string]interface{}) + if !ok { + continue + } + + example := GenerateExample(ExpandSchema(spec, schema, map[string]bool{}, ctxSpec)) + if variant, ok := buildVariant(normalizeContentType(cType), example); ok { + variants = append(variants, variant) + } + } + return variants +} + +// orderVariants dedupes by declared type and orders by preference. The +// comparison falls through to the type name so the result does not depend on +// the order the variants were collected in: ranking alone leaves ties +// (application/xml and text/xml share a rank) that would otherwise reintroduce +// the nondeterminism this replaced. +func orderVariants(variants []bodyVariant) []bodyVariant { + seen := make(map[string]bool, len(variants)) + deduped := make([]bodyVariant, 0, len(variants)) + for _, v := range variants { + if seen[v.declared] { + continue + } + seen[v.declared] = true + deduped = append(deduped, v) + } + + slices.SortFunc(deduped, func(a, b bodyVariant) int { + pa, _ := contentTypePriority(a.declared) + pb, _ := contentTypePriority(b.declared) + if pa != pb { + return pa - pb + } + return strings.Compare(a.declared, b.declared) + }) + return deduped +} + +// forcedContentTypeWarned keys the "not declared" notice by the set of types +// the operation declared, so each distinct message is shown once. printWarn +// ignores quiet, and a large spec would otherwise repeat an identical warning +// for every operation that shares the same declared types. +var forcedContentTypeWarned = map[string]bool{} + +// warnForcedContentType reports, at most once per distinct set of declared +// types, that the operator's Content-Type is not among them. +func warnForcedContentType(forced, sending string, variants []bodyVariant, relabeled bool) { + declared := declaredTypes(variants) + if forcedContentTypeWarned[declared] { + return + } + forcedContentTypeWarned[declared] = true + + if relabeled { + printWarn("Content-Type '%s' is not declared by operations offering [%s]; sending their %s body under it.", forced, declared, sending) + return + } + printWarn("Content-Type '%s' is not declared by operations offering [%s], and a multipart body cannot be relabeled; sending it as %s.", forced, declared, sending) +} + +// declaredTypes lists the variants' content types for an operator-facing message. +func declaredTypes(variants []bodyVariant) string { + names := make([]string, 0, len(variants)) + for _, v := range variants { + names = append(names, v.declared) + } + return strings.Join(names, ", ") +} + +// selectVariants narrows the declared variants to the ones sj will actually +// send. Every declared type survives in variants; this is the single place the +// operator's choice is applied. +func selectVariants(variants []bodyVariant, forced string, all bool) []bodyVariant { + if len(variants) == 0 { + return nil + } + if all { + return variants + } + if forced == "" { + return variants[:1] + } + + wanted := normalizeContentType(forced) + for _, v := range variants { + if v.declared == wanted { + // Matched, so the header and the body now agree. The wire type + // stays the variant's own: a multipart match keeps its boundary + // rather than the operator's boundary-less string, which would + // make the body unparseable. + return []bodyVariant{v} + } + } + + // Nothing declared matches, so the operator is deliberately sending a type + // this operation does not document. Keep a body but ship it under their + // header, on the wire and in the printed command alike. A multipart body + // cannot be relabeled -- its boundary only means anything under + // multipart/form-data -- so prefer any other variant to carry the label. + choice := slices.IndexFunc(variants, func(v bodyVariant) bool { + return v.declared != ctMultipart + }) + if choice == -1 { + warnForcedContentType(forced, variants[0].declared, variants, false) + return variants[:1] + } + + warnForcedContentType(forced, variants[choice].declared, variants, true) + mislabeled := variants[choice] + mislabeled.contentType = forced + return []bodyVariant{mislabeled} +} diff --git a/cmd/bodyvariant_test.go b/cmd/bodyvariant_test.go new file mode 100644 index 0000000..5141b8b --- /dev/null +++ b/cmd/bodyvariant_test.go @@ -0,0 +1,267 @@ +package cmd + +import ( + "slices" + "strings" + "testing" +) + +func TestNormalizeContentType(t *testing.T) { + cases := map[string]string{ + "application/json": "application/json", + "Application/JSON": "application/json", + "application/json; charset=utf-8": "application/json", + " application/xml ": "application/xml", + "multipart/form-data; boundary=ab": "multipart/form-data", + "*/*": "*/*", + } + for in, want := range cases { + if got := normalizeContentType(in); got != want { + t.Errorf("normalizeContentType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestContentTypePriority(t *testing.T) { + encodable := map[string]int{ + "application/json": 0, + "application/vnd.api+json": 0, + "application/problem+json": 0, + "*/*": 0, + "application/x-www-form-urlencoded": 1, + "multipart/form-data": 2, + "application/xml": 3, + "text/xml": 3, + "application/xhtml+xml": 3, + } + for ct, want := range encodable { + got, ok := contentTypePriority(ct) + if !ok { + t.Errorf("contentTypePriority(%q): expected an encoder", ct) + continue + } + if got != want { + t.Errorf("contentTypePriority(%q) = %d, want %d", ct, got, want) + } + } + + // A type sj cannot encode must yield no variant rather than a Content-Type + // header promising a body that was never built. + for _, ct := range []string{"application/octet-stream", "text/plain", "image/png"} { + if _, ok := contentTypePriority(ct); ok { + t.Errorf("contentTypePriority(%q): expected no encoder", ct) + } + } +} + +// multiTypeSpec is an operation declaring every encodable type plus one sj has +// no encoder for. +func multiTypeSpec() (map[string]interface{}, map[string]interface{}) { + schema := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string"}, + }, + } + reqBody := map[string]interface{}{ + "content": map[string]interface{}{ + "application/json": map[string]interface{}{"schema": schema}, + "application/xml": map[string]interface{}{"schema": schema}, + "application/x-www-form-urlencoded": map[string]interface{}{"schema": schema}, + "multipart/form-data": map[string]interface{}{"schema": schema}, + "application/octet-stream": map[string]interface{}{"schema": schema}, + }, + } + return map[string]interface{}{}, reqBody +} + +func declaredOrder(variants []bodyVariant) []string { + out := make([]string, 0, len(variants)) + for _, v := range variants { + out = append(out, v.declared) + } + return out +} + +func TestBuildBodyVariantsKeepsEveryEncodableType(t *testing.T) { + oldTestString := testString + defer func() { testString = oldTestString }() + testString = "bishopfox" + + spec, reqBody := multiTypeSpec() + variants := orderVariants(buildBodyVariants(spec, reqBody, spec)) + + want := []string{"application/json", "application/x-www-form-urlencoded", "multipart/form-data", "application/xml"} + if got := declaredOrder(variants); !slices.Equal(got, want) { + t.Fatalf("declared variants = %v, want %v", got, want) + } + + byType := map[string]bodyVariant{} + for _, v := range variants { + byType[v.declared] = v + } + + if got, want := byType["application/json"].body, `{"name":"bishopfox"}`; got != want { + t.Errorf("json body = %q, want %q", got, want) + } + if got, want := byType["application/x-www-form-urlencoded"].body, "name=bishopfox"; got != want { + t.Errorf("urlencoded body = %q, want %q", got, want) + } + if got, want := byType["application/xml"].body, "bishopfox"; got != want { + t.Errorf("xml body = %q, want %q", got, want) + } + + // Multipart owns its wire format: the boundary belongs on the request but + // not in the printed command, and the body is never a -d argument. + mp := byType["multipart/form-data"] + if !strings.HasPrefix(mp.contentType, "multipart/form-data; boundary=") { + t.Errorf("multipart Content-Type = %q, want a boundary", mp.contentType) + } + if !mp.omitCurlCT { + t.Error("multipart variant should not print its own Content-Type") + } + if mp.curlBodyArg != "" { + t.Errorf("multipart curlBodyArg = %q, want empty", mp.curlBodyArg) + } + if !strings.Contains(mp.curlArgs, "-F ") || strings.Contains(mp.curlArgs, "-d ") { + t.Errorf("multipart curlArgs = %q, want -F and no -d", mp.curlArgs) + } + + // The JSON variant keeps its -d even though multipart was built alongside + // it: the old code carried a sticky multipart flag that suppressed it. + if j := byType["application/json"]; !strings.Contains(j.curlArgs, "-d ") || j.curlBodyArg == "" { + t.Errorf("json curlArgs = %q, curlBodyArg = %q: want a -d argument", j.curlArgs, j.curlBodyArg) + } +} + +// TestBuildBodyVariantsIsDeterministic is the regression test for the bare map +// range that made which body sj sent vary from one run to the next. +func TestBuildBodyVariantsIsDeterministic(t *testing.T) { + oldTestString := testString + defer func() { testString = oldTestString }() + testString = "bishopfox" + + spec, reqBody := multiTypeSpec() + want := declaredOrder(orderVariants(buildBodyVariants(spec, reqBody, spec))) + + for i := 0; i < 50; i++ { + got := declaredOrder(orderVariants(buildBodyVariants(spec, reqBody, spec))) + if !slices.Equal(got, want) { + t.Fatalf("run %d produced %v, want %v", i, got, want) + } + } +} + +// TestOrderVariantsIgnoresCollectionOrder pins that ordering comes from the +// preference table and the type name, not from the order the variants happened +// to be appended in: ranking alone leaves ties. +func TestOrderVariantsIgnoresCollectionOrder(t *testing.T) { + mk := func(types ...string) []bodyVariant { + out := make([]bodyVariant, 0, len(types)) + for _, ct := range types { + out = append(out, bodyVariant{declared: ct}) + } + return out + } + want := []string{"application/json", "multipart/form-data", "application/xml", "text/xml"} + + forward := declaredOrder(orderVariants(mk("application/json", "text/xml", "application/xml", "multipart/form-data"))) + reverse := declaredOrder(orderVariants(mk("multipart/form-data", "application/xml", "text/xml", "application/json"))) + + if !slices.Equal(forward, want) || !slices.Equal(reverse, want) { + t.Errorf("orderVariants depends on input order: forward %v, reverse %v, want %v", forward, reverse, want) + } + + // A type declared twice is sent once. + if got := declaredOrder(orderVariants(mk("application/json", "application/json"))); len(got) != 1 { + t.Errorf("duplicate declared type not deduped: %v", got) + } +} + +func TestSelectVariants(t *testing.T) { + oldWarned := forcedContentTypeWarned + defer func() { forcedContentTypeWarned = oldWarned }() + + variants := []bodyVariant{ + {declared: "application/json", contentType: "application/json"}, + {declared: "multipart/form-data", contentType: "multipart/form-data; boundary=abc"}, + {declared: "application/xml", contentType: "application/xml"}, + } + + t.Run("default takes the preferred type", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + got := selectVariants(variants, "", false) + if len(got) != 1 || got[0].declared != "application/json" { + t.Errorf("got %v, want a single application/json variant", declaredOrder(got)) + } + }) + + t.Run("all keeps every type", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + if got := selectVariants(variants, "", true); len(got) != 3 { + t.Errorf("got %v, want all 3", declaredOrder(got)) + } + }) + + t.Run("a forced type selects its variant", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + got := selectVariants(variants, "application/xml", false) + if len(got) != 1 || got[0].declared != "application/xml" { + t.Fatalf("got %v, want application/xml", declaredOrder(got)) + } + if got[0].contentType != "application/xml" { + t.Errorf("wire Content-Type = %q, want application/xml", got[0].contentType) + } + }) + + t.Run("a forced type is matched despite parameters", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + got := selectVariants(variants, "Application/XML; charset=utf-8", false) + if len(got) != 1 || got[0].declared != "application/xml" { + t.Errorf("got %v, want application/xml", declaredOrder(got)) + } + }) + + t.Run("forced multipart keeps its boundary", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + got := selectVariants(variants, "multipart/form-data", false) + if len(got) != 1 { + t.Fatalf("got %v, want one variant", declaredOrder(got)) + } + // Honoring the operator's boundary-less string literally would send a + // body no server could parse. + if got[0].contentType != "multipart/form-data; boundary=abc" { + t.Errorf("wire Content-Type = %q, want the boundary-bearing value", got[0].contentType) + } + }) + + t.Run("an undeclared type relabels the preferred body", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + got := selectVariants(variants, "text/plain", false) + if len(got) != 1 { + t.Fatalf("got %v, want one variant", declaredOrder(got)) + } + if got[0].declared != "application/json" { + t.Errorf("relabeled %q, want the preferred application/json body", got[0].declared) + } + if got[0].contentType != "text/plain" { + t.Errorf("wire Content-Type = %q, want the operator's text/plain", got[0].contentType) + } + }) + + t.Run("a multipart-only operation is never relabeled", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + only := []bodyVariant{{declared: "multipart/form-data", contentType: "multipart/form-data; boundary=abc"}} + got := selectVariants(only, "text/plain", false) + if len(got) != 1 || got[0].contentType != "multipart/form-data; boundary=abc" { + t.Errorf("multipart body was relabeled: %q", got[0].contentType) + } + }) + + t.Run("no declared body yields no variant", func(t *testing.T) { + forcedContentTypeWarned = map[string]bool{} + if got := selectVariants(nil, "application/json", false); got != nil { + t.Errorf("got %v, want nil", declaredOrder(got)) + } + }) +} diff --git a/cmd/log.go b/cmd/log.go index aa23c20..3a0fec5 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -13,14 +13,19 @@ type Result struct { Method string `json:"method"` Status int `json:"status"` Target string `json:"target"` + // ContentType names the request body encoding this result came from. It is + // omitted unless an operation was tested under more than one, so + // single-variant output is unchanged. + ContentType string `json:"contentType,omitempty"` } type VerboseResult struct { - Method string `json:"method"` - Preview string `json:"preview"` - Status int `json:"status"` - Target string `json:"target"` - Curl string `json:"curl"` + Method string `json:"method"` + Preview string `json:"preview"` + Status int `json:"status"` + Target string `json:"target"` + Curl string `json:"curl"` + ContentType string `json:"contentType,omitempty"` } // Diagnostic helpers — all write to stderr so stdout stays clean for piping. @@ -145,11 +150,20 @@ func logResult(sc int, target, method, errorMsg, preview string, out io.Writer) statusStr = "---" } - line := fmt.Sprintf("%s %-7s %-3s %s\n", + // The description the document gives for this status, when it declared one. + // It names what the API says the response means, which is often the only + // hint about why an endpoint refused a request. + annotation := "" + if errorMsg != "" { + annotation = " " + faint(errorMsg) + } + + line := fmt.Sprintf("%s %-7s %-3s %s%s\n", painter(sym), painter(method), painter(statusStr), target, + annotation, ) fmt.Fprint(out, line) diff --git a/cmd/requests.go b/cmd/requests.go index 3c037c9..dab1d4b 100644 --- a/cmd/requests.go +++ b/cmd/requests.go @@ -111,6 +111,50 @@ func applyHeaders(req *http.Request, userAgent string) string { return userAgent } +// approvedTarget is the URL the operator has already cleared for the operation +// being built. sj can send one URL several times -- once per declared body +// content type -- and the dangerous-keyword prompt belongs to the endpoint, not +// to each encoding of it. A redirect lands on a different URL and is still +// checked. +var approvedTarget string + +// dangerousRequestDeclined reports whether the operator chose to skip a target +// whose URL contains a dangerous keyword, prompting them if they have not +// already answered for this run. +func dangerousRequestDeclined(target string) bool { + u, err := url.Parse(target) + if err != nil || u == nil { + return false + } + + // Match whole words rather than substrings so "/store/order" is flagged + // while "/border" is not. + words := requestWords(u) + for _, v := range dangerousStrings { + if force || !words[v] || slices.ContainsFunc(safeWords, func(s string) bool { return strings.EqualFold(s, v) }) { + continue + } + if avoidDangerousRequests == "y" { + return true + } + + userChoice = "" + printWarn("Dangerous keyword '%s' detected in URL (%s). Do you still want to test this endpoint? (y/N)", v, target) + fmt.Scanln(&userChoice) + if strings.ToLower(userChoice) != "y" { + if !riskSurveyed { + avoidDangerousRequests = "y" + printWarn("Do you want to avoid all dangerous requests? (Y/n)") + fmt.Scanln(&avoidDangerousRequests) + avoidDangerousRequests = strings.ToLower(avoidDangerousRequests) + riskSurveyed = true + } + return true + } + } + return false +} + // MakeRequest returns the body bytes, body string, and status code. It wraps // MakeRequestFull for callers that do not need the Content-Type. func MakeRequest(client http.Client, method, target string, timeout int64, reqData io.Reader) ([]byte, string, int) { @@ -184,29 +228,8 @@ func MakeRequestFull(client http.Client, method, target string, timeout int64, r printWarn("Error parsing URL '%s': %v - skipping request.", target, err) return nil, "", 0, "", "" } - // Match whole words rather than substrings so "/store/order" is flagged - // while "/border" is not. - words := requestWords(u) - for _, v := range dangerousStrings { - if os.Args[1] == "automate" && !force && words[v] && !slices.ContainsFunc(safeWords, func(s string) bool { return strings.EqualFold(s, v) }) { - userChoice = "" - if avoidDangerousRequests == "y" { - return nil, "", 0, "", "" - } else { - printWarn("Dangerous keyword '%s' detected in URL (%s). Do you still want to test this endpoint? (y/N)", v, target) - fmt.Scanln(&userChoice) - if strings.ToLower(userChoice) != "y" { - if !riskSurveyed { - avoidDangerousRequests = "y" - printWarn("Do you want to avoid all dangerous requests? (Y/n)") - fmt.Scanln(&avoidDangerousRequests) - avoidDangerousRequests = strings.ToLower(avoidDangerousRequests) - riskSurveyed = true - } - return nil, "", 0, "", "" - } - } - } + if os.Args[1] == "automate" && target != approvedTarget && dangerousRequestDeclined(target) { + return nil, "", 0, "", "" } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) diff --git a/cmd/root.go b/cmd/root.go index 9838997..fb8a683 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" ) +var allContentTypes bool var apiTarget string var basePath string var customDate string @@ -67,6 +68,7 @@ func init() { rootCmd.AddCommand(prepareCmd) rootCmd.AddCommand(bruteCmd) rootCmd.AddCommand(convertCmd) + rootCmd.PersistentFlags().BoolVar(&allContentTypes, "all-content-types", false, "Send a separate request for every request body content type an operation declares, instead of only the preferred one.") rootCmd.PersistentFlags().StringVarP(&UserAgent, "agent", "A", "Swagger Jacker (github.com/BishopFox/sj)", "Set the User-Agent string.") rootCmd.PersistentFlags().StringVarP(&basePath, "base-path", "b", "", "Set the API base path if not defined in the definition file (i.e. /V2/).") rootCmd.PersistentFlags().StringVarP(&customURL, "custom-url", "c", "https://bishopfox.com", "Set a custom URL to test discovered URL parameters.") diff --git a/cmd/utils.go b/cmd/utils.go index eeb08b1..accca47 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -6,12 +6,12 @@ import ( "encoding/xml" "fmt" "maps" - "mime/multipart" "net/http" "net/url" "os" "path/filepath" "slices" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -65,7 +65,6 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep } } - var errorDescriptions = make(map[any]string) for _, pathName := range pathKeys { pathItem := paths[pathName] if ops, ok := pathItem.(map[string]interface{}); ok { @@ -89,6 +88,10 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep Headers = append([]string(nil), userHeaders...) contentType = userContentType + // Scoped to this operation: a description belongs to the + // response it was declared under, so one endpoint's "Not + // found" must never be printed against another's. + errorDescriptions := make(map[string]string) if responses, ok := opMap["responses"].(map[string]interface{}); ok { for status, respItem := range responses { if respMap, ok := respItem.(map[string]interface{}); ok { @@ -96,25 +99,21 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep errorDescriptions[status] = desc } } - } } targetURL := fmt.Sprintf("%s%s%s", apiTarget, basePath, pathName) curl := fmt.Sprintf("curl -X %s \"%s\"", strings.ToUpper(method), targetURL) - var bodyData string - // curlBodyArg is the shell-quoted rendering of bodyData that is - // appended to curl once, after every parameter is known. - var curlBodyArg string - var multipartBody bool + // variants collects every body this operation declares, from + // all three sources below. None is discarded; selectVariants + // decides which ones are sent. + var variants []bodyVariant // cookiePairs accumulates "in: cookie" parameters so they can be sent - // as a single Cookie header. hasFormData records whether any - // "in: formData" (Swagger v2) parameter contributed a body field, and - // bodyTypeRendered whether the body's Content-Type has already been - // written to the printed command. + // as a single Cookie header. formBody accumulates "in: formData" + // (Swagger v2) fields, which become one urlencoded variant. var cookiePairs []string + var formBody string var hasFormData bool - var bodyTypeRendered bool // Extracts the expected parameters from the parameters object if params, ok := opMap["parameters"].([]interface{}); ok { @@ -145,13 +144,8 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep if in == "body" { bodySchema, _ := pMap["schema"].(map[string]interface{}) expandedBody := ExpandSchema(spec, bodySchema, map[string]bool{}, paramContextSpec) - if encoded, err := json.Marshal(GenerateExample(expandedBody)); err == nil { - bodyData = string(encoded) - EnforceSingleContentType("application/json") - if !bodyTypeRendered { - curl += " -H " + shellSingleQuote("Content-Type: application/json") - bodyTypeRendered = true - } + if variant, ok := buildVariant(ctJSON, GenerateExample(expandedBody)); ok { + variants = append(variants, variant) } continue } @@ -227,7 +221,7 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep cookiePairs = append(cookiePairs, encodePair(name, pValue)) case "formData": // Swagger v2 form field: contributes to the urlencoded body. - bodyData = appendFormField(bodyData, name, pValue) + formBody = appendFormField(formBody, name, pValue) hasFormData = true } } @@ -244,11 +238,10 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep curl += " -b " + shellSingleQuote(cookieValue) } - // Swagger v2 formData params have no requestBody to set the content - // type, so declare the urlencoded type on both paths here. + // Swagger v2 formData fields have no requestBody to declare a + // content type, so they become an explicit urlencoded variant. if hasFormData { - EnforceSingleContentType("application/x-www-form-urlencoded") - curl += " -H " + shellSingleQuote("Content-Type: application/x-www-form-urlencoded") + variants = append(variants, newTextBodyVariant(ctForm, ctForm, formBody)) } // Extracts the expected parameters from the requestBody object @@ -262,78 +255,7 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep reqBodyContextSpec = contextSpec } } - - if contentTypes, ok := reqBody["content"].(map[string]interface{}); ok { - for cType := range contentTypes { - if contentType == "" { - EnforceSingleContentType(cType) - } else { - EnforceSingleContentType(contentType) - } - - if ct, ok := contentTypes[cType].(map[string]interface{}); ok { - if schema, ok := ct["schema"].(map[string]interface{}); ok { - expanded := ExpandSchema(spec, schema, map[string]bool{}, reqBodyContextSpec) - example := GenerateExample(expanded) - - if cType == "application/json" { - bodyBytes, err := json.Marshal(example) - if err == nil { - bodyData = string(bodyBytes) - curl += " -H " + shellSingleQuote("Content-Type: application/json") - } - } - if cType == "application/xml" || cType == "text/xml" { - if obj, ok := example.(map[string]interface{}); ok { - bodyData = XmlFromObject(obj) - curl += " -H " + shellSingleQuote("Content-Type: "+cType) - } - } - if cType == "application/x-www-form-urlencoded" { - if obj, ok := example.(map[string]interface{}); ok { - bodyData = encodeFormBody(obj) - curl += " -H " + shellSingleQuote("Content-Type: "+cType) - } - } - if cType == "multipart/form-data" { - if obj, ok := example.(map[string]interface{}); ok { - keys := slices.Sorted(maps.Keys(obj)) - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - for _, k := range keys { - _ = mw.WriteField(k, fmt.Sprintf("%v", obj[k])) - } - _ = mw.Close() - bodyData = buf.String() - // multipart.Writer owns this wire format, so the body is - // never appended to curl as a -d argument. - multipartBody = true - // Replace the bare multipart/form-data Content-Type - // (set earlier in the loop) with a boundary-aware one. - EnforceSingleContentType(mw.FormDataContentType()) - for _, k := range keys { - curl += " -F " + shellSingleQuote(fmt.Sprintf("%s=%v", k, obj[k])) - } - } - } - } - } - } - } - } - - // Compose the body argument once, from bodyData, so the bytes that are - // sent and the printed curl command cannot drift apart. - if bodyData != "" && !multipartBody { - curlBodyArg = shellSingleQuote(bodyData) - curl += " -d " + curlBodyArg - } - - // Update the curl command with the final targetURL (which may have been modified with query params) - // Extract and replace the URL in quotes - curlParts := strings.SplitN(curl, "\"", 3) - if len(curlParts) >= 3 { - curl = curlParts[0] + "\"" + targetURL + "\"" + curlParts[2] + variants = append(variants, buildBodyVariants(spec, reqBody, reqBodyContextSpec)...) } logURL, parseErr := url.Parse(targetURL) @@ -341,72 +263,160 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep printWarn("Error parsing URL '%s': %v - skipping endpoint.", targetURL, parseErr) continue } - switch os.Args[1] { - case "automate": - // Any method that declares a body sends it. These are the same bytes - // the printed curl command was composed from above, so what is shown - // and what goes on the wire cannot disagree. - sentBody := []byte(bodyData) - _, resp, sc, _, sentUA := MakeRequestFull(client, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody)) + if os.Args[1] == "endpoints" { + // endpoints only lists paths, so the body never matters and + // one line per path+method is printed regardless of how many + // content types the operation declares. + fmt.Println(basePath + pathName) + continue + } - tempResponsePreviewLength := responsePreviewLength - if len(resp) <= responsePreviewLength { - tempResponsePreviewLength = len(resp) + variants = orderVariants(variants) + if os.Args[1] == "prepare" && strings.EqualFold(prepareFor, "sqlmap") { + // sqlmap has no -F equivalent, so a multipart variant would + // print a command that cannot be run. + variants = slices.DeleteFunc(variants, func(v bodyVariant) bool { + return v.declared == ctMultipart + }) + } + + selected := selectVariants(variants, contentType, allContentTypes) + if len(selected) == 0 { + // No declared body: a single bodyless request, as before. + selected = []bodyVariant{{}} + } + + if os.Args[1] == "automate" { + // Ask once per operation. sj may send this same URL once per + // declared content type, and re-prompting for each one would + // be noise; approvedTarget stops MakeRequestFull asking again. + if dangerousRequestDeclined(targetURL) { + continue + } + approvedTarget = targetURL + } + + // Snapshot the state every variant starts from. The loop below + // rebuilds curl and Headers per variant so that what is printed + // and what goes on the wire describe the same request. + baseCurl := curl + baseHeaders := append([]string(nil), Headers...) + + // The body encoding is only worth reporting when an operation + // was tested under more than one. Otherwise it would add a field + // to every result row that says nothing the request did not + // already imply, and change output every consumer already parses. + reportContentType := len(selected) > 1 + + for _, v := range selected { + // Re-copy rather than assign: EnforceSingleContentType + // compacts Headers in place, so sharing a backing array would + // let one variant rewrite the snapshot the next one starts from. + Headers = append([]string(nil), baseHeaders...) + curl = baseCurl + + // A bodyless request declares no media type. Enforcing an + // empty one would append a bare "Content-Type:" that + // applyHeaders only replaces when the request carries bytes. + if v.contentType != "" { + EnforceSingleContentType(v.contentType) + if !v.omitCurlCT { + curl += " -H " + shellSingleQuote("Content-Type: "+v.contentType) + } + } + curl += v.curlArgs + + // Update the curl command with the final targetURL (which may have been modified with query params) + // Extract and replace the URL in quotes + curlParts := strings.SplitN(curl, "\"", 3) + if len(curlParts) >= 3 { + curl = curlParts[0] + "\"" + targetURL + "\"" + curlParts[2] } - var result []byte + switch os.Args[1] { + case "automate": + // These are the same bytes the printed curl command was + // composed from above, so what is shown and what goes on + // the wire cannot disagree. + sentBody := []byte(v.body) - if verbose { - result, _ = json.Marshal(VerboseResult{Method: method, Preview: resp[:tempResponsePreviewLength], Status: sc, Target: logURL.Path, Curl: curl}) - } else { - result, _ = json.Marshal(Result{Method: method, Status: sc, Target: logURL.Path}) - } + _, resp, sc, _, sentUA := MakeRequestFull(client, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody)) - if getAccessibleEndpoints { - if sc == 200 { - accessibleEndpoints = append(accessibleEndpoints, logURL.Path) + tempResponsePreviewLength := responsePreviewLength + if len(resp) <= responsePreviewLength { + tempResponsePreviewLength = len(resp) + } + + // Only annotate responses that went wrong: a 2xx + // description ("successful operation") only repeats what + // the status already says. Descriptions are keyed by the + // status as written in the document, with "default" as the + // spec's catch-all. + description := "" + if sc < 200 || sc > 299 { + desc, ok := errorDescriptions[strconv.Itoa(sc)] + if !ok { + desc = errorDescriptions["default"] + } + description = desc + } + + reportedCT := "" + if reportContentType { + reportedCT = v.declared + } + var result []byte + + if verbose { + result, _ = json.Marshal(VerboseResult{Method: method, Preview: resp[:tempResponsePreviewLength], Status: sc, Target: logURL.Path, Curl: curl, ContentType: reportedCT}) + } else { + result, _ = json.Marshal(Result{Method: method, Status: sc, Target: logURL.Path, ContentType: reportedCT}) + } + + if getAccessibleEndpoints { + if sc == 200 { + accessibleEndpoints = append(accessibleEndpoints, logURL.Path) + if jsonResultsStringArray == nil { + jsonResultsStringArray = append(jsonResultsStringArray, string(result)) + } else { + jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result)) + } + if outputFormat == "console" { + writeLog(sc, logURL.Path, strings.ToUpper(method), description, resp[:tempResponsePreviewLength]) + } + if replayClient != nil { + ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody), sentUA) + } + } + } else { if jsonResultsStringArray == nil { jsonResultsStringArray = append(jsonResultsStringArray, string(result)) } else { jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result)) } if outputFormat == "console" { - writeLog(sc, logURL.Path, strings.ToUpper(method), errorDescriptions[sc], resp[:tempResponsePreviewLength]) + writeLog(sc, logURL.Path, strings.ToUpper(method), description, resp[:tempResponsePreviewLength]) } if replayClient != nil { ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody), sentUA) } } - } else { - if jsonResultsStringArray == nil { - jsonResultsStringArray = append(jsonResultsStringArray, string(result)) - } else { - jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result)) - } - if outputFormat == "console" { - writeLog(sc, logURL.Path, strings.ToUpper(method), errorDescriptions[sc], resp[:tempResponsePreviewLength]) - } - if replayClient != nil { - ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody), sentUA) - } - } - case "endpoints": - fmt.Println(basePath + pathName) - case "prepare": - var preparedCommand string = curl - if strings.ToLower(prepareFor) == "sqlmap" { - preparedCommand = strings.Replace(preparedCommand, "curl", "sqlmap", 1) - preparedCommand = strings.Replace(preparedCommand, "-X "+strings.ToUpper(method), "--method="+strings.ToUpper(method)+" -u", 1) - if curlBodyArg != "" { - preparedCommand = strings.Replace(preparedCommand, "-d "+curlBodyArg, "--data="+curlBodyArg, 1) + case "prepare": + var preparedCommand string = curl + if strings.EqualFold(prepareFor, "sqlmap") { + preparedCommand = strings.Replace(preparedCommand, "curl", "sqlmap", 1) + preparedCommand = strings.Replace(preparedCommand, "-X "+strings.ToUpper(method), "--method="+strings.ToUpper(method)+" -u", 1) + if v.curlBodyArg != "" { + preparedCommand = strings.Replace(preparedCommand, "-d "+v.curlBodyArg, "--data="+v.curlBodyArg, 1) + } + preparedCommand = "$ " + preparedCommand + } else if strings.EqualFold(prepareFor, "curl") { + preparedCommand = "$ " + curl } - preparedCommand = "$ " + preparedCommand - } else if prepareFor == "curl" { - preparedCommand = "$ " + curl + fmt.Println(preparedCommand) } - fmt.Println(preparedCommand) } } } diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 4c9599e..2a8d17b 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -1259,20 +1259,37 @@ func TestDeclaredBodyIsSentForAllMethods(t *testing.T) { }() type captured struct { - ctype string - body string + ctype string + ctPresent bool + body string } - got := map[string]captured{} + // Keyed to a slice, not a single value: an operation that fans out across + // content types would otherwise overwrite its own capture and the extra + // requests would go unnoticed. + got := map[string][]captured{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) - got[r.Method+" "+r.URL.Path] = captured{ - ctype: r.Header.Get("Content-Type"), - body: string(body), - } + _, present := r.Header["Content-Type"] + key := r.Method + " " + r.URL.Path + got[key] = append(got[key], captured{ + ctype: r.Header.Get("Content-Type"), + ctPresent: present, + body: string(body), + }) w.WriteHeader(http.StatusOK) })) defer srv.Close() + // one asserts the operation produced exactly one request and returns it. + one := func(key string) captured { + t.Helper() + reqs := got[key] + if len(reqs) != 1 { + t.Fatalf("%s: expected exactly 1 request, got %d", key, len(reqs)) + } + return reqs[0] + } + os.Args = []string{"sj", "automate"} apiTarget = srv.URL basePath = "" @@ -1283,6 +1300,7 @@ func TestDeclaredBodyIsSentForAllMethods(t *testing.T) { accept = "" UserAgent = "sj-test" randomUserAgent = false + allContentTypes = false force = true // skip the dangerous-keyword prompt timeout = 30 testString = "bishopfox" @@ -1341,14 +1359,14 @@ func TestDeclaredBodyIsSentForAllMethods(t *testing.T) { BuildRequestsFromPaths(spec, http.Client{}, nil) // A v3 requestBody on a PUT: the regression this test exists for. - if j := got["PUT /json"]; j.body != `{"name":"bishopfox"}` { + if j := one("PUT /json"); j.body != `{"name":"bishopfox"}` { t.Errorf("PUT body not sent: got %q, want %q", j.body, `{"name":"bishopfox"}`) } else if j.ctype != "application/json" { t.Errorf("PUT Content-Type: got %q, want %q", j.ctype, "application/json") } // multipart suppresses "-d" in the printed command but must still be sent. - m := got["PUT /multipart"] + m := one("PUT /multipart") if !strings.Contains(m.ctype, "multipart/form-data; boundary=") { t.Errorf("PUT multipart Content-Type: got %q", m.ctype) } @@ -1357,7 +1375,7 @@ func TestDeclaredBodyIsSentForAllMethods(t *testing.T) { } // A Swagger v2 "in: body" param is JSON, and PUT is treated exactly as POST. - post, put := got["POST /v2body"], got["PUT /v2body"] + post, put := one("POST /v2body"), one("PUT /v2body") if post.body != put.body || post.ctype != put.ctype { t.Errorf("v2 in:body differs by method: POST %q/%q, PUT %q/%q", post.ctype, post.body, put.ctype, put.body) } @@ -1369,16 +1387,326 @@ func TestDeclaredBodyIsSentForAllMethods(t *testing.T) { } // A non-object body schema is a JSON array, not a "body=[map[...]]" form field. - if a := got["POST /v2array"]; !strings.HasPrefix(a.body, `[{`) { + if a := one("POST /v2array"); !strings.HasPrefix(a.body, `[{`) { t.Errorf("v2 in:body array not serialized as a JSON array: got %q", a.body) } // The widened Content-Type default must not touch a bodiless request. - n := got["GET /nobody"] + n := one("GET /nobody") if n.body != "" { t.Errorf("bodiless GET sent a body: got %q", n.body) } - if n.ctype != "" { + // Absent, not merely empty: enforcing a blank Content-Type would append a + // bare "Content-Type:" header that survives to the wire. + if n.ctPresent { t.Errorf("bodiless GET declared a Content-Type: got %q", n.ctype) } } + +// captureStdout runs fn with os.Stdout redirected and returns what it printed. +// The "prepare" and "endpoints" modes write their whole result to stdout, so +// there is no other way to assert on them. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + done := make(chan string, 1) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + + w.Close() + os.Stdout = old + return <-done +} + +// multiContentTypeSpec is one operation declaring every encodable body type +// plus one sj has no encoder for. +func multiContentTypeSpec() map[string]interface{} { + schema := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string"}, + }, + } + content := map[string]interface{}{} + for _, ct := range []string{ + "application/json", + "application/xml", + "application/x-www-form-urlencoded", + "multipart/form-data", + "application/octet-stream", + } { + content[ct] = map[string]interface{}{"schema": schema} + } + return map[string]interface{}{ + "paths": map[string]interface{}{ + "/multi": map[string]interface{}{ + "post": map[string]interface{}{ + "requestBody": map[string]interface{}{"content": content}, + }, + }, + }, + } +} + +// withPrepareMode sets up the globals BuildRequestsFromPaths reads and restores +// them afterwards. +func withPrepareMode(t *testing.T, mode string, fn func()) { + t.Helper() + oldArgs, oldTarget, oldBase := os.Args, apiTarget, basePath + oldHeaders, oldCT, oldAll := Headers, contentType, allContentTypes + oldPrepareFor, oldTestString, oldWarned := prepareFor, testString, forcedContentTypeWarned + defer func() { + os.Args, apiTarget, basePath = oldArgs, oldTarget, oldBase + Headers, contentType, allContentTypes = oldHeaders, oldCT, oldAll + prepareFor, testString, forcedContentTypeWarned = oldPrepareFor, oldTestString, oldWarned + }() + + os.Args = []string{"sj", mode} + apiTarget = "http://127.0.0.1:9999" + basePath = "" + Headers = nil + contentType = "" + allContentTypes = false + prepareFor = "curl" + testString = "bishopfox" + forcedContentTypeWarned = map[string]bool{} + + fn() +} + +// TestPrepareEmitsOneContentTypePerCommand is the regression test for the +// printed command accumulating a Content-Type header per declared type while +// only one body was actually sent. +func TestPrepareEmitsOneContentTypePerCommand(t *testing.T) { + spec := multiContentTypeSpec() + + var defaultOut, fanOut string + withPrepareMode(t, "prepare", func() { + defaultOut = captureStdout(t, func() { BuildRequestsFromPaths(spec, http.Client{}, nil) }) + allContentTypes = true + fanOut = captureStdout(t, func() { BuildRequestsFromPaths(spec, http.Client{}, nil) }) + }) + + defaultLines := strings.Split(strings.TrimSpace(defaultOut), "\n") + if len(defaultLines) != 1 { + t.Fatalf("expected 1 command by default, got %d:\n%s", len(defaultLines), defaultOut) + } + if !strings.Contains(defaultLines[0], "-H 'Content-Type: application/json'") { + t.Errorf("default command should use the preferred JSON body: %s", defaultLines[0]) + } + + fanLines := strings.Split(strings.TrimSpace(fanOut), "\n") + // Four encodable types; application/octet-stream has no encoder. + if len(fanLines) != 4 { + t.Fatalf("expected 4 commands under --all-content-types, got %d:\n%s", len(fanLines), fanOut) + } + + for _, line := range append(defaultLines, fanLines...) { + if n := strings.Count(line, "-H 'Content-Type:"); n > 1 { + t.Errorf("command declares %d Content-Type headers:\n%s", n, line) + } + } + + wantBodies := []string{ + "-H 'Content-Type: application/json' -d '{\"name\":\"bishopfox\"}'", + "-H 'Content-Type: application/x-www-form-urlencoded' -d 'name=bishopfox'", + "-F 'name=bishopfox'", + "-H 'Content-Type: application/xml' -d 'bishopfox'", + } + for i, want := range wantBodies { + if !strings.Contains(fanLines[i], want) { + t.Errorf("command %d:\n got %s\n want it to contain %s", i, fanLines[i], want) + } + } + + // curl generates its own multipart boundary, so echoing sj's would print a + // command that cannot reproduce the request. + if strings.Contains(fanLines[2], "Content-Type") { + t.Errorf("multipart command should not declare a Content-Type: %s", fanLines[2]) + } +} + +// TestPrepareForSqlmapSkipsMultipart pins that sj never prints a sqlmap +// command carrying -F, which is not a sqlmap option and cannot be run. +func TestPrepareForSqlmapSkipsMultipart(t *testing.T) { + spec := multiContentTypeSpec() + + var out string + withPrepareMode(t, "prepare", func() { + prepareFor = "sqlmap" + allContentTypes = true + out = captureStdout(t, func() { BuildRequestsFromPaths(spec, http.Client{}, nil) }) + }) + + if strings.Contains(out, " -F ") { + t.Errorf("sqlmap output contains an unrunnable -F argument:\n%s", out) + } + if n := strings.Count(out, "sqlmap"); n != 3 { + t.Errorf("expected 3 sqlmap commands (multipart skipped), got %d:\n%s", n, out) + } + if !strings.Contains(out, "--data='{\"name\":\"bishopfox\"}'") { + t.Errorf("sqlmap body should be rewritten to --data=:\n%s", out) + } +} + +// TestEndpointsPrintsOncePerOperation pins that fanning out across content +// types does not multiply the endpoint listing. +func TestEndpointsPrintsOncePerOperation(t *testing.T) { + spec := multiContentTypeSpec() + + var out string + withPrepareMode(t, "endpoints", func() { + allContentTypes = true + out = captureStdout(t, func() { BuildRequestsFromPaths(spec, http.Client{}, nil) }) + }) + + if got := strings.TrimSpace(out); got != "/multi" { + t.Errorf("endpoints output = %q, want %q", got, "/multi") + } +} + +// TestHybridV2BodyAndFormDataAreSeparateVariants covers an operation declaring +// both an "in: body" param and "in: formData" params. The two used to corrupt +// each other: the JSON body was written first, then form fields were appended +// onto it as "&k=v". +func TestHybridV2BodyAndFormDataAreSeparateVariants(t *testing.T) { + spec := map[string]interface{}{ + "paths": map[string]interface{}{ + "/hybrid": map[string]interface{}{ + "post": map[string]interface{}{ + "parameters": []interface{}{ + map[string]interface{}{ + "name": "payload", + "in": "body", + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"name": map[string]interface{}{"type": "string"}}, + }, + }, + map[string]interface{}{"name": "field1", "in": "formData", "type": "string"}, + }, + }, + }, + }, + } + + var out string + withPrepareMode(t, "prepare", func() { + allContentTypes = true + out = captureStdout(t, func() { BuildRequestsFromPaths(spec, http.Client{}, nil) }) + }) + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 commands, got %d:\n%s", len(lines), out) + } + if !strings.Contains(lines[0], `-d '{"name":"bishopfox"}'`) { + t.Errorf("JSON variant is not clean JSON: %s", lines[0]) + } + if !strings.Contains(lines[1], "-d 'field1=bishopfox'") { + t.Errorf("formData variant is not clean urlencoded: %s", lines[1]) + } +} + +// TestResponseDescriptionsAreScopedToTheirOperation covers the response +// description path end to end: descriptions were collected keyed by the status +// string but looked up with an int, so every lookup missed; the map was also +// shared across the whole spec, so fixing the key alone would have printed one +// endpoint's description against another's response. +func TestResponseDescriptionsAreScopedToTheirOperation(t *testing.T) { + oldArgs, oldTarget, oldBase := os.Args, apiTarget, basePath + oldFormat, oldHeaders, oldCT := outputFormat, Headers, contentType + oldForce, oldTimeout, oldPreview := force, timeout, responsePreviewLength + oldResults, oldVerbose, oldAll := jsonResultsStringArray, verbose, allContentTypes + defer func() { + os.Args, apiTarget, basePath = oldArgs, oldTarget, oldBase + outputFormat, Headers, contentType = oldFormat, oldHeaders, oldCT + force, timeout, responsePreviewLength = oldForce, oldTimeout, oldPreview + jsonResultsStringArray, verbose, allContentTypes = oldResults, oldVerbose, oldAll + }() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ok" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + os.Args = []string{"sj", "automate"} + apiTarget = srv.URL + basePath = "" + outputFormat = "console" + Headers = nil + contentType = "" + force = true + verbose = false + allContentTypes = false + timeout = 30 + responsePreviewLength = 50 + jsonResultsStringArray = nil + + responses := func(pairs map[string]string) map[string]interface{} { + out := map[string]interface{}{} + for status, desc := range pairs { + out[status] = map[string]interface{}{"description": desc} + } + return out + } + spec := map[string]interface{}{ + "paths": map[string]interface{}{ + "/alpha": map[string]interface{}{ + "get": map[string]interface{}{"responses": responses(map[string]string{"404": "Alpha is missing"})}, + }, + "/beta": map[string]interface{}{ + "get": map[string]interface{}{"responses": responses(map[string]string{"404": "Beta is missing"})}, + }, + // No 404 declared, so the spec's catch-all applies. + "/gamma": map[string]interface{}{ + "get": map[string]interface{}{"responses": responses(map[string]string{"default": "Something went wrong"})}, + }, + "/ok": map[string]interface{}{ + "get": map[string]interface{}{"responses": responses(map[string]string{"200": "successful operation"})}, + }, + }, + } + + out := captureStdout(t, func() { BuildRequestsFromPaths(spec, http.Client{}, nil) }) + + byPath := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + for _, p := range []string{"/alpha", "/beta", "/gamma", "/ok"} { + if strings.Contains(line, p+" ") || strings.HasSuffix(line, p) { + byPath[p] = line + } + } + } + + if !strings.Contains(byPath["/alpha"], "Alpha is missing") { + t.Errorf("/alpha line lost its description: %q", byPath["/alpha"]) + } + if !strings.Contains(byPath["/beta"], "Beta is missing") { + t.Errorf("/beta line lost its description: %q", byPath["/beta"]) + } + // The leak the shared map would cause. + if strings.Contains(byPath["/beta"], "Alpha is missing") { + t.Errorf("/beta showed /alpha's description: %q", byPath["/beta"]) + } + if !strings.Contains(byPath["/gamma"], "Something went wrong") { + t.Errorf("/gamma did not fall back to the default response: %q", byPath["/gamma"]) + } + // A 2xx description only repeats what the status already says. + if strings.Contains(byPath["/ok"], "successful operation") { + t.Errorf("/ok annotated a successful response: %q", byPath["/ok"]) + } +} diff --git a/tests/test_multi_content_type.yaml b/tests/test_multi_content_type.yaml new file mode 100644 index 0000000..083b0f3 --- /dev/null +++ b/tests/test_multi_content_type.yaml @@ -0,0 +1,78 @@ +openapi: 3.0.0 +info: + title: Multi Content Type Test + version: 1.0.0 +paths: + /multi: + post: + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' + application/xml: + schema: + $ref: '#/components/schemas/Widget' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Widget' + multipart/form-data: + schema: + $ref: '#/components/schemas/Widget' + application/octet-stream: + schema: + type: string + format: binary + /vendor: + post: + responses: + '200': + description: OK + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/Widget' + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Widget' + /anytype: + post: + responses: + '200': + description: OK + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/Widget' + /binary: + post: + responses: + '200': + description: OK + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + /described: + get: + responses: + '404': + description: Widget not found + default: + description: Unexpected error +components: + schemas: + Widget: + type: object + properties: + name: + type: string + size: + type: integer