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