2025-12-10 08:34:28 +05:30
|
|
|
package proxy
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/tls"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
2026-03-10 10:35:28 +05:30
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
2025-12-10 08:34:28 +05:30
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/elazarl/goproxy"
|
|
|
|
|
"github.com/safedep/dry/log"
|
|
|
|
|
"github.com/safedep/pmg/proxy/certmanager"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ProxyServer manages the proxy lifecycle
|
|
|
|
|
type ProxyServer interface {
|
|
|
|
|
// Start begins listening on the configured address
|
|
|
|
|
Start() error
|
|
|
|
|
|
|
|
|
|
// Stop gracefully shuts down the proxy
|
|
|
|
|
Stop(ctx context.Context) error
|
|
|
|
|
|
|
|
|
|
// Address returns the listening address (useful when using port 0)
|
|
|
|
|
Address() string
|
|
|
|
|
|
|
|
|
|
// AddInterceptor registers an interceptor
|
|
|
|
|
AddInterceptor(interceptor Interceptor) error
|
|
|
|
|
|
|
|
|
|
// RemoveInterceptor removes an interceptor by name
|
|
|
|
|
RemoveInterceptor(name string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProxyConfig holds configuration for the proxy server
|
|
|
|
|
type ProxyConfig struct {
|
|
|
|
|
// Network configuration
|
|
|
|
|
ListenAddr string
|
|
|
|
|
|
|
|
|
|
// TLS configuration
|
|
|
|
|
CertManager certmanager.CertificateManager
|
|
|
|
|
|
|
|
|
|
// Interceptors
|
|
|
|
|
Interceptors []Interceptor
|
|
|
|
|
|
|
|
|
|
// Other configuration
|
|
|
|
|
EnableMITM bool
|
|
|
|
|
RequestTimeout time.Duration
|
|
|
|
|
ConnectTimeout time.Duration
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DefaultProxyConfig returns a configuration with sensible defaults
|
|
|
|
|
func DefaultProxyConfig() *ProxyConfig {
|
|
|
|
|
return &ProxyConfig{
|
|
|
|
|
ListenAddr: "127.0.0.1:0",
|
|
|
|
|
EnableMITM: true,
|
|
|
|
|
ConnectTimeout: 30 * time.Second,
|
|
|
|
|
RequestTimeout: 5 * time.Minute,
|
|
|
|
|
Interceptors: []Interceptor{},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type proxyServer struct {
|
|
|
|
|
config *ProxyConfig
|
|
|
|
|
proxy *goproxy.ProxyHttpServer
|
|
|
|
|
server *http.Server
|
|
|
|
|
|
|
|
|
|
listener net.Listener
|
|
|
|
|
interceptors map[string]Interceptor
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ ProxyServer = &proxyServer{}
|
|
|
|
|
|
|
|
|
|
// goproxyLoggerWrapper implements the goproxy.Logger interface and bridges to the dry/log package
|
|
|
|
|
type goproxyLoggerWrapper struct{}
|
|
|
|
|
|
|
|
|
|
func (l *goproxyLoggerWrapper) Printf(format string, v ...interface{}) {
|
|
|
|
|
log.Debugf("[GOPROXY] "+format, v...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewProxyServer creates a new proxy server with the given configuration
|
|
|
|
|
// using the goproxy library as the underlying proxy implementation
|
|
|
|
|
func NewProxyServer(config *ProxyConfig) (ProxyServer, error) {
|
|
|
|
|
if config == nil {
|
|
|
|
|
config = DefaultProxyConfig()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if config.EnableMITM && config.CertManager == nil {
|
|
|
|
|
return nil, fmt.Errorf("cert manager is required when MITM is enabled")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if config.ListenAddr == "" {
|
|
|
|
|
config.ListenAddr = "127.0.0.1:0"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
proxy := goproxy.NewProxyHttpServer()
|
|
|
|
|
proxy.Logger = &goproxyLoggerWrapper{}
|
2026-02-12 20:47:05 +05:30
|
|
|
proxy.Tr = newUpstreamTransport(config)
|
2025-12-10 08:34:28 +05:30
|
|
|
|
|
|
|
|
// Set verbose to true for verbose logging.
|
|
|
|
|
// Logging is handled by our own logger which has log level controls.
|
|
|
|
|
proxy.Verbose = true
|
|
|
|
|
|
|
|
|
|
// Configure connection timeout for upstream connections during CONNECT requests
|
|
|
|
|
proxy.ConnectDial = func(network, addr string) (net.Conn, error) {
|
|
|
|
|
dialer := &net.Dialer{
|
|
|
|
|
Timeout: config.ConnectTimeout,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return dialer.Dial(network, addr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ps := &proxyServer{
|
|
|
|
|
config: config,
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
interceptors: make(map[string]Interceptor),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, interceptor := range config.Interceptors {
|
|
|
|
|
if err := ps.AddInterceptor(interceptor); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to add interceptor %s: %w", interceptor.Name(), err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if config.EnableMITM {
|
|
|
|
|
ps.configureMITM()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ps.registerHandlers()
|
|
|
|
|
|
|
|
|
|
return ps, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 20:47:05 +05:30
|
|
|
func newUpstreamTransport(config *ProxyConfig) *http.Transport {
|
|
|
|
|
dialer := &net.Dialer{
|
|
|
|
|
Timeout: config.ConnectTimeout,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keep transport behavior close to goproxy defaults and only harden TLS:
|
|
|
|
|
// enforce server certificate verification and require TLS 1.2+.
|
2026-03-10 10:35:28 +05:30
|
|
|
// Proxy honours the environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) so
|
|
|
|
|
// that PMG works in enterprise environments that require a corporate
|
|
|
|
|
// upstream proxy to reach the internet.
|
2026-02-12 20:47:05 +05:30
|
|
|
return &http.Transport{
|
|
|
|
|
Proxy: http.ProxyFromEnvironment,
|
|
|
|
|
DialContext: dialer.DialContext,
|
|
|
|
|
TLSHandshakeTimeout: config.ConnectTimeout,
|
|
|
|
|
TLSClientConfig: &tls.Config{
|
|
|
|
|
MinVersion: tls.VersionTLS12,
|
|
|
|
|
InsecureSkipVerify: false,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 08:34:28 +05:30
|
|
|
func (ps *proxyServer) Start() error {
|
|
|
|
|
listener, err := net.Listen("tcp", ps.config.ListenAddr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to start listener: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ps.listener = listener
|
|
|
|
|
|
|
|
|
|
ps.server = &http.Server{
|
|
|
|
|
Handler: ps.proxy,
|
|
|
|
|
ReadTimeout: ps.config.RequestTimeout,
|
|
|
|
|
WriteTimeout: ps.config.RequestTimeout,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Debugf("Proxy server listening on %s", ps.Address())
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
if err := ps.server.Serve(ps.listener); err != nil && err != http.ErrServerClosed {
|
|
|
|
|
log.Errorf("Proxy server error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ps *proxyServer) Stop(ctx context.Context) error {
|
|
|
|
|
if ps.server == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Debugf("Shutting down proxy server...")
|
|
|
|
|
|
|
|
|
|
if err := ps.server.Shutdown(ctx); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to shutdown proxy server: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ps *proxyServer) Address() string {
|
|
|
|
|
if ps.listener == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ps.listener.Addr().String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ps *proxyServer) AddInterceptor(interceptor Interceptor) error {
|
|
|
|
|
ps.mu.Lock()
|
|
|
|
|
defer ps.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if _, ok := ps.interceptors[interceptor.Name()]; ok {
|
|
|
|
|
return fmt.Errorf("interceptor %s already registered", interceptor.Name())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ps.interceptors[interceptor.Name()] = interceptor
|
|
|
|
|
log.Debugf("Registered interceptor: %s", interceptor.Name())
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ps *proxyServer) RemoveInterceptor(name string) {
|
|
|
|
|
ps.mu.Lock()
|
|
|
|
|
defer ps.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
delete(ps.interceptors, name)
|
|
|
|
|
log.Debugf("Removed interceptor: %s", name)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 10:35:28 +05:30
|
|
|
// normalizeRequestURL fixes malformed URLs produced by goproxy's MITM URL reconstruction.
|
|
|
|
|
//
|
|
|
|
|
// When a client (e.g., npm) sends absolute-form Request-URIs inside a CONNECT tunnel
|
|
|
|
|
// (e.g., "POST http://registry.npmjs.org:443/-/npm/v1/security/advisories/bulk"),
|
|
|
|
|
// goproxy's MITM code checks if req.URL starts with "scheme://" and, if not, naively
|
|
|
|
|
// prepends "scheme://connectHost" to the full URI. Since the client's URI starts with
|
|
|
|
|
// "http://" but the MITM scheme is "https", the check fails and goproxy produces:
|
|
|
|
|
//
|
|
|
|
|
// https://registry.npmjs.org:443http://registry.npmjs.org:443/-/npm/v1/security/advisories/bulk
|
|
|
|
|
//
|
|
|
|
|
// This function detects the embedded absolute URI and extracts it, preserving the
|
|
|
|
|
// correct scheme from the MITM connection.
|
|
|
|
|
func normalizeRequestURL(req *http.Request) {
|
|
|
|
|
if req == nil || req.URL == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host := req.URL.Host
|
|
|
|
|
if host == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The goproxy bug produces a Host field like "registry.npmjs.org:443http:"
|
|
|
|
|
// where the embedded scheme leaks into the authority. A valid host:port
|
|
|
|
|
// never contains "http:" or "https:", so this check is precise and avoids
|
|
|
|
|
// false positives from query parameters or path segments.
|
|
|
|
|
var embeddedScheme string
|
|
|
|
|
var schemeIdx int
|
|
|
|
|
if idx := strings.Index(host, "http:"); idx > 0 {
|
|
|
|
|
embeddedScheme = "http"
|
|
|
|
|
schemeIdx = idx
|
|
|
|
|
} else if idx := strings.Index(host, "https:"); idx > 0 {
|
|
|
|
|
embeddedScheme = "https"
|
|
|
|
|
schemeIdx = idx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if embeddedScheme == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract the real host (everything before the embedded scheme)
|
|
|
|
|
realHost := host[:schemeIdx]
|
|
|
|
|
|
|
|
|
|
// Reconstruct the embedded URL from the scheme found in the host
|
|
|
|
|
// plus the path portion that Go's URL parser placed after the authority.
|
|
|
|
|
// The full original URL looks like: scheme://realHost + embeddedScheme://embeddedHost/path
|
|
|
|
|
// Go parsed the authority as "realHost + embeddedScheme:" and the path as
|
|
|
|
|
// "//embeddedHost/path", so we combine them back.
|
|
|
|
|
embeddedURL := embeddedScheme + ":" + req.URL.Path
|
|
|
|
|
if req.URL.RawQuery != "" {
|
|
|
|
|
embeddedURL += "?" + req.URL.RawQuery
|
|
|
|
|
}
|
|
|
|
|
if req.URL.Fragment != "" {
|
|
|
|
|
embeddedURL += "#" + req.URL.Fragment
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parsed, err := url.Parse(embeddedURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the embedded URL parsed to a valid host, use it. Otherwise fall back
|
|
|
|
|
// to the real host we extracted from the authority.
|
|
|
|
|
if parsed.Host == "" {
|
|
|
|
|
parsed.Host = realHost
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Preserve the MITM scheme (e.g. https) rather than the client's scheme
|
|
|
|
|
parsed.Scheme = req.URL.Scheme
|
|
|
|
|
req.URL = parsed
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 08:34:28 +05:30
|
|
|
func (ps *proxyServer) configureMITM() {
|
|
|
|
|
// Configure selective MITM based on interceptors
|
|
|
|
|
ps.proxy.OnRequest().HandleConnect(goproxy.FuncHttpsHandler(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
|
|
|
|
reqCtx, err := newRequestContextFromURL(host, "CONNECT")
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Errorf("Failed to parse CONNECT request for %s: %v", host, err)
|
|
|
|
|
return goproxy.OkConnect, host
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ps.mu.RLock()
|
|
|
|
|
shouldMITM := false
|
|
|
|
|
for _, interceptor := range ps.interceptors {
|
2026-02-12 13:48:01 +05:30
|
|
|
if !interceptor.ShouldIntercept(reqCtx) {
|
|
|
|
|
continue
|
2025-12-10 08:34:28 +05:30
|
|
|
}
|
2026-02-12 13:48:01 +05:30
|
|
|
|
|
|
|
|
mitm := true
|
|
|
|
|
if decider, ok := interceptor.(MITMDecider); ok {
|
|
|
|
|
mitm = decider.ShouldMITM(reqCtx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !mitm {
|
|
|
|
|
// Allow non-MITM interceptors (e.g., telemetry) to observe CONNECT traffic.
|
|
|
|
|
if _, err := interceptor.HandleRequest(reqCtx); err != nil {
|
|
|
|
|
log.Errorf("[%s] Interceptor %s error on CONNECT: %v", reqCtx.RequestID, interceptor.Name(), err)
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
shouldMITM = true
|
|
|
|
|
log.Debugf("[%s] Interceptor %s will handle %s", reqCtx.RequestID, interceptor.Name(), host)
|
2025-12-10 08:34:28 +05:30
|
|
|
}
|
|
|
|
|
ps.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
if shouldMITM {
|
|
|
|
|
mitmAction := &goproxy.ConnectAction{
|
|
|
|
|
Action: goproxy.ConnectMitm,
|
|
|
|
|
TLSConfig: func(host string, ctx *goproxy.ProxyCtx) (*tls.Config, error) {
|
|
|
|
|
hostname, _, err := net.SplitHostPort(host)
|
|
|
|
|
if err != nil {
|
|
|
|
|
hostname = host
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ps.config.CertManager.GetTLSConfig(hostname)
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mitmAction, host
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tunnel without interception
|
|
|
|
|
log.Debugf("[%s] Tunneling %s (no interceptor)", reqCtx.RequestID, host)
|
|
|
|
|
return goproxy.OkConnect, host
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ps *proxyServer) registerHandlers() {
|
|
|
|
|
ps.proxy.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
2026-03-10 10:35:28 +05:30
|
|
|
// Fix malformed URLs produced by goproxy's MITM URL reconstruction.
|
|
|
|
|
// When a client sends absolute-form Request-URIs (e.g., http://host:port/path)
|
|
|
|
|
// inside a CONNECT tunnel, goproxy naively prepends scheme://connectHost to the
|
|
|
|
|
// full URI, producing malformed URLs like https://host:443http://host:443/path.
|
|
|
|
|
// We detect and fix this before processing.
|
|
|
|
|
normalizeRequestURL(req)
|
|
|
|
|
|
2025-12-10 08:34:28 +05:30
|
|
|
reqCtx, err := newRequestContext(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Errorf("Failed to create request context: %v", err)
|
|
|
|
|
return req, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Debugf("[%s] %s %s", reqCtx.RequestID, req.Method, req.URL.String())
|
|
|
|
|
|
|
|
|
|
ps.mu.RLock()
|
|
|
|
|
defer ps.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
for _, interceptor := range ps.interceptors {
|
|
|
|
|
if !interceptor.ShouldIntercept(reqCtx) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := interceptor.HandleRequest(reqCtx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Errorf("[%s] Interceptor %s error: %v", reqCtx.RequestID, interceptor.Name(), err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch resp.Action {
|
|
|
|
|
case ActionBlock:
|
|
|
|
|
statusCode := resp.BlockCode
|
|
|
|
|
if statusCode == 0 {
|
|
|
|
|
statusCode = http.StatusForbidden
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
message := resp.BlockMessage
|
|
|
|
|
if message == "" {
|
|
|
|
|
message = "Blocked by proxy interceptor"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Debugf("[%s] Blocked by %s: %s", reqCtx.RequestID, interceptor.Name(), req.URL.String())
|
2026-02-06 22:41:56 +05:30
|
|
|
r := goproxy.NewResponse(req, goproxy.ContentTypeText, statusCode, message)
|
2025-12-10 08:34:28 +05:30
|
|
|
|
2026-02-06 22:41:56 +05:30
|
|
|
// goproxy v1.8.x writes the response via (*http.Response).Write for MITM traffic.
|
|
|
|
|
// Ensure the protocol version is valid (defaults to HTTP/0.0 otherwise).
|
|
|
|
|
// Ref: https://github.com/elazarl/goproxy/issues/745
|
|
|
|
|
if req.ProtoMajor > 0 {
|
|
|
|
|
r.Proto = req.Proto
|
|
|
|
|
r.ProtoMajor = req.ProtoMajor
|
|
|
|
|
r.ProtoMinor = req.ProtoMinor
|
|
|
|
|
} else {
|
|
|
|
|
r.Proto = "HTTP/1.1"
|
|
|
|
|
r.ProtoMajor = 1
|
|
|
|
|
r.ProtoMinor = 1
|
|
|
|
|
}
|
|
|
|
|
r.Close = true
|
|
|
|
|
r.Header.Set("Connection", "close")
|
|
|
|
|
r.Header.Set("Proxy-Connection", "close")
|
|
|
|
|
|
|
|
|
|
return req, r
|
2025-12-10 08:34:28 +05:30
|
|
|
|
|
|
|
|
case ActionModifyRequest:
|
|
|
|
|
if resp.ModifiedHeaders != nil {
|
|
|
|
|
req.Header = resp.ModifiedHeaders
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Debugf("[%s] Request modified by %s", reqCtx.RequestID, interceptor.Name())
|
|
|
|
|
|
|
|
|
|
case ActionModifyResponse:
|
|
|
|
|
ctx.UserData = resp.ResponseModifier
|
|
|
|
|
log.Debugf("[%s] Response modifier registered by %s", reqCtx.RequestID, interceptor.Name())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return req, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ps.proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
|
|
|
|
|
reqCtx, err := newRequestContext(ctx.Req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Errorf("Failed to create request context: %v", err)
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Debugf("[%s] Response received for %s", reqCtx.RequestID, ctx.Req.URL.String())
|
|
|
|
|
|
|
|
|
|
if resp == nil {
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modifier, ok := ctx.UserData.(ResponseModifierFunc)
|
|
|
|
|
if !ok || modifier == nil {
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: Implement response body modification
|
|
|
|
|
// This requires buffering the response body, modifying it, and creating a new response
|
|
|
|
|
// For now, lets skip it
|
|
|
|
|
|
|
|
|
|
return resp
|
|
|
|
|
})
|
|
|
|
|
}
|