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:
Tony West committed 2026-05-13 19:31:22 -04:00
1 parent 7ef8abdb8e
commit 3c2a3b807d
2 files changed
+121 -32

No files matched your search

+45 -26
View File
@@ -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
View File
@@ -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{}