mirror of
https://github.com/BishopFox/sj.git
synced 2026-11-07 10:26:01 -10:00
fix: Keep all declared request body content types instead of last-wins
This commit is contained in:
1 parent
97eab8c4ba
commit
6d262a369d
9 files changed
+1217
-185
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user