fix: Keep all declared request body content types instead of last-wins

This commit is contained in:
Tony West committed 2026-10-01 20:29:13 -04:00
1 parent 97eab8c4ba
commit 6d262a369d
9 files changed
+1217 -185

No files matched your search

+35
View File
@@ -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":"[email protected]","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 '<name>bishopfox</name><size>1</size>'
```
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.
+275
View File
@@ -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 <quoted>" 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}
}
+267
View File
@@ -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, "<name>bishopfox</name>"; 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))
}
})
}
+20 -6
View File
@@ -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)
+46 -23
View File
@@ -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)
+2
View File
@@ -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.")
+153 -143
View File
@@ -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)
}
}
}
+341 -13
View File
@@ -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 '<name>bishopfox</name>'",
}
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"])
}
}
+78
View File
@@ -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