mirror of
https://github.com/BishopFox/sj.git
synced 2026-11-07 10:26:01 -10:00
added fix to auto-extract JS-wrapped specs in automate/endpoints/prepare; fixed POST body leak, Content-Type carryover, and multipart boundary issues
This commit is contained in:
1 parent
7ef8abdb8e
commit
3c2a3b807d
2 files changed
+121
-32
No files matched your search
+45
-26
@@ -160,38 +160,14 @@ func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T) {
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(ct, "application/javascript") {
|
||||
bodyBytes, bodyString, _ := MakeRequest(client, "GET", url, timeout, nil)
|
||||
bodyBytes, _, _ := MakeRequest(client, "GET", url, timeout, nil)
|
||||
if bodyBytes != nil {
|
||||
regexPattern := regexp.MustCompile(`(?s)(?:let|const|var)\s+(\w+)\s*=\s*({.*?});`)
|
||||
matches := regexPattern.FindAllStringSubmatch(bodyString, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
jsonContent := []byte(match[2])
|
||||
if jsonContent, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
|
||||
checkSpec := UnmarshalSpec(jsonContent)
|
||||
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
|
||||
printInfo("\nFound operation definitions embedded in JavaScript file at %s\n", url)
|
||||
return true, checkSpec
|
||||
}
|
||||
// Swagger UI commonly nests the spec one level deep under
|
||||
// keys like swaggerDoc (swagger-ui-express / NestJS),
|
||||
// spec (Swagger UI Bundle config), or openapi.
|
||||
var wrapper map[string]json.RawMessage
|
||||
if err := json.Unmarshal(jsonContent, &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"swaggerDoc", "spec", "openapi"} {
|
||||
inner, ok := wrapper[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
checkSpec := UnmarshalSpec(inner)
|
||||
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
|
||||
printInfo("\nFound operation definitions embedded in JavaScript file at %s\n", url)
|
||||
return true, checkSpec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +186,49 @@ func init() {
|
||||
bruteCmd.Flags().BoolVarP(&endpointOnly, "endpoint-only", "e", false, "Only return the identified endpoint")
|
||||
}
|
||||
|
||||
// ExtractJSONFromJSSpec scans a Swagger-UI-style JavaScript bundle for an
|
||||
// OpenAPI/Swagger document assigned to a top-level var/let/const. If the
|
||||
// captured value is itself a wrapper object (swagger-ui-express / swagger-ui
|
||||
// bundle config), it unwraps one level via the swaggerDoc/spec/openapi keys.
|
||||
// Returns the extracted JSON bytes and true on success, or the original
|
||||
// bytes and false on failure.
|
||||
func ExtractJSONFromJSSpec(bodyBytes []byte) ([]byte, bool) {
|
||||
re := regexp.MustCompile(`(?s)(?:let|const|var)\s+(\w+)\s*=\s*({.*?});`)
|
||||
matches := re.FindAllStringSubmatch(string(bodyBytes), -1)
|
||||
for _, m := range matches {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
candidate := []byte(m[2])
|
||||
if looksLikeAPISpec(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
var wrapper map[string]json.RawMessage
|
||||
if err := json.Unmarshal(candidate, &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"swaggerDoc", "spec", "openapi"} {
|
||||
if inner, ok := wrapper[key]; ok && looksLikeAPISpec(inner) {
|
||||
return inner, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return bodyBytes, false
|
||||
}
|
||||
|
||||
func looksLikeAPISpec(b []byte) bool {
|
||||
var probe struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Swagger string `json:"swagger"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &probe); err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(probe.OpenAPI, "3") ||
|
||||
strings.HasPrefix(probe.OpenAPI, "2") ||
|
||||
strings.HasPrefix(probe.Swagger, "2")
|
||||
}
|
||||
|
||||
func ExtractSpecFromJS(bodyBytes []byte) []byte {
|
||||
var openApiIndex int
|
||||
var specClose int
|
||||
|
||||
+76
-6
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -49,6 +50,19 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
}
|
||||
slices.Sort(pathKeys)
|
||||
|
||||
// Snapshot CLI-supplied headers so per-endpoint Content-Type
|
||||
// mutations from EnforceSingleContentType don't leak across endpoints.
|
||||
userHeaders := append([]string(nil), Headers...)
|
||||
var userContentType string
|
||||
for _, h := range userHeaders {
|
||||
if strings.HasPrefix(strings.ToLower(h), "content-type:") {
|
||||
if idx := strings.Index(h, ":"); idx >= 0 {
|
||||
userContentType = strings.TrimSpace(h[idx+1:])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var errorDescriptions = make(map[any]string)
|
||||
for _, pathName := range pathKeys {
|
||||
pathItem := paths[pathName]
|
||||
@@ -68,6 +82,11 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
continue
|
||||
default:
|
||||
if opMap, ok := op.(map[string]interface{}); ok {
|
||||
// Reset header state so the previous endpoint's
|
||||
// Content-Type doesn't leak into this one.
|
||||
Headers = append([]string(nil), userHeaders...)
|
||||
contentType = userContentType
|
||||
|
||||
if responses, ok := opMap["responses"].(map[string]interface{}); ok {
|
||||
for status, respItem := range responses {
|
||||
if respMap, ok := respItem.(map[string]interface{}); ok {
|
||||
@@ -225,23 +244,42 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
if cType == "application/json" {
|
||||
bodyBytes, err := json.Marshal(example)
|
||||
if err == nil {
|
||||
bodyData = string(bodyBytes)
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: application/json\" -d '%s'", bodyBytes)
|
||||
}
|
||||
}
|
||||
if cType == "application/xml" || cType == "text/xml" {
|
||||
if obj, ok := example.(map[string]interface{}); ok {
|
||||
xml := XmlFromObject(obj)
|
||||
bodyData = xml
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, xml)
|
||||
}
|
||||
}
|
||||
if cType == "application/x-www-form-urlencoded" || cType == "multipart/form-data" {
|
||||
if cType == "application/x-www-form-urlencoded" {
|
||||
if obj, ok := example.(map[string]interface{}); ok {
|
||||
var formParts []string
|
||||
for k, v := range obj {
|
||||
formParts = append(formParts, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
formData := strings.Join(formParts, "&")
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, formData)
|
||||
bodyData = strings.Join(formParts, "&")
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, bodyData)
|
||||
}
|
||||
}
|
||||
if cType == "multipart/form-data" {
|
||||
if obj, ok := example.(map[string]interface{}); ok {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for k, v := range obj {
|
||||
_ = mw.WriteField(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
_ = mw.Close()
|
||||
bodyData = buf.String()
|
||||
// Replace the bare multipart/form-data Content-Type
|
||||
// (set earlier in the loop) with a boundary-aware one.
|
||||
EnforceSingleContentType(mw.FormDataContentType())
|
||||
for k, v := range obj {
|
||||
curl += fmt.Sprintf(" -F \"%s=%v\"", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,9 +303,8 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
switch os.Args[1] {
|
||||
case "automate":
|
||||
var postBodyData string
|
||||
if strings.ToLower(method) == "post" && strings.Contains(curl, "-d") {
|
||||
dataIndex := strings.Index(curl, "'")
|
||||
postBodyData = curl[dataIndex+1 : len(curl)-1]
|
||||
if strings.ToLower(method) == "post" {
|
||||
postBodyData = bodyData
|
||||
}
|
||||
|
||||
_, resp, sc := MakeRequest(client, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
|
||||
@@ -540,6 +577,15 @@ func GenerateExample(node *SchemaNode) interface{} {
|
||||
}
|
||||
|
||||
func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.Client) {
|
||||
// Swagger UI bundles serve the spec wrapped in JavaScript (e.g.
|
||||
// swagger-ui-init.js). Detect that and unwrap before parsing so the
|
||||
// downstream YAML/JSON unmarshal succeeds.
|
||||
if looksLikeJSSpec(bodyBytes) {
|
||||
if extracted, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
|
||||
bodyBytes = extracted
|
||||
}
|
||||
}
|
||||
|
||||
// Ingests the specification file
|
||||
spec := SafelyUnmarshalSpec(bodyBytes)
|
||||
|
||||
@@ -807,6 +853,30 @@ func SetScheme(swaggerURL string) (scheme string) {
|
||||
return scheme
|
||||
}
|
||||
|
||||
// looksLikeJSSpec is a cheap content sniff so we only run the JS-extraction
|
||||
// regex against bodies that plausibly contain JavaScript. Normal JSON
|
||||
// (starts with `{`/`[`) and YAML (starts with `openapi:`/`swagger:`/`---`/`#`)
|
||||
// fall through unchanged.
|
||||
func looksLikeJSSpec(b []byte) bool {
|
||||
if strings.HasSuffix(strings.ToLower(swaggerURL), ".js") ||
|
||||
strings.HasSuffix(strings.ToLower(localFile), ".js") ||
|
||||
strings.ToLower(format) == "js" {
|
||||
return true
|
||||
}
|
||||
trimmed := bytes.TrimLeft(b, " \t\r\n")
|
||||
prefixes := [][]byte{
|
||||
[]byte("var "), []byte("let "), []byte("const "),
|
||||
[]byte("(function"), []byte("window."),
|
||||
[]byte("//"), []byte("/*"),
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if bytes.HasPrefix(trimmed, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SafelyUnmarshalSpec(data []byte) map[string]interface{} {
|
||||
|
||||
var doc map[string]interface{}
|
||||
|
||||
Reference in new issue
Block a user