Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions packages/pam/handlers/webapp/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package webapp

import (
"bufio"
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"

"github.com/Infisical/infisical-merge/packages/pam/session"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)

type WebAppProxyConfig struct {
TargetAddr string
Protocol string // "http" or "https"
TLSConfig *tls.Config
SessionID string
SessionLogger session.SessionLogger
}

type WebAppProxy struct {
config WebAppProxyConfig
}

func NewWebAppProxy(config WebAppProxyConfig) *WebAppProxy {
return &WebAppProxy{config: config}
}

func (p *WebAppProxy) HandleConnection(ctx context.Context, clientConn net.Conn) error {
defer clientConn.Close()

sessionID := p.config.SessionID
l := log.With().Str("sessionID", sessionID).Logger()
defer func() {
if err := p.config.SessionLogger.Close(); err != nil {
l.Error().Err(err).Msg("Failed to close session logger")
}
}()

l.Info().
Str("targetAddr", p.config.TargetAddr).
Str("protocol", p.config.Protocol).
Msg("New WebApp connection for PAM session")

reader := bufio.NewReader(clientConn)

// Loop to handle multiple HTTP requests on the same keep-alive connection
for {
select {
case <-ctx.Done():
l.Info().Msg("Context cancelled, closing WebApp proxy connection")
return ctx.Err()
default:
}

// Read request in a goroutine so we can cancel it
reqCh := make(chan *http.Request, 1)
errCh := make(chan error, 1)

go func() {
req, err := http.ReadRequest(reader)
if err != nil {
errCh <- err
} else {
reqCh <- req
}
}()

var req *http.Request
select {
case <-ctx.Done():
l.Info().Msg("Context cancelled while reading HTTP request")
return ctx.Err()
case err := <-errCh:
if errors.Is(err, io.EOF) {
l.Info().Msg("Client closed connection")
return nil
}
l.Error().Err(err).Msg("Failed to read HTTP request")
return fmt.Errorf("failed to read HTTP request: %w", err)
case req = <-reqCh:
// Successfully received request
}

requestId := uuid.New().String()
l.Info().
Str("url", req.URL.String()).
Str("method", req.Method).
Str("reqId", requestId).
Msg("Received HTTP request from tunnel")

// Read request body
reqBody, err := io.ReadAll(req.Body)
if err != nil {
l.Error().Err(err).Msg("Failed to read request body")
writeErrorResponse(clientConn, "failed to read request body")
continue
}
req.Body.Close()
Comment on lines +99 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No request body size limit (potential DoS)

io.ReadAll(req.Body) reads the entire request body into memory with no upper bound. A malicious or misbehaving client could send an extremely large body (e.g., a multi-GB upload) causing memory exhaustion on the gateway process. Consider wrapping the body with http.MaxBytesReader before reading:

Suggested change
// Read request body
reqBody, err := io.ReadAll(req.Body)
if err != nil {
l.Error().Err(err).Msg("Failed to read request body")
writeErrorResponse(clientConn, "failed to read request body")
continue
}
req.Body.Close()
// Read request body
req.Body = http.MaxBytesReader(nil, req.Body, 32*1024*1024) // 32 MB limit
reqBody, err := io.ReadAll(req.Body)
if err != nil {
l.Error().Err(err).Msg("Failed to read request body")
writeErrorResponse(clientConn, "failed to read request body")
continue
}
req.Body.Close()

Note: http.MaxBytesReader requires an http.ResponseWriter as its first argument; if that doesn't fit here, you can instead use io.LimitReader:

reqBody, err := io.ReadAll(io.LimitReader(req.Body, 32*1024*1024))


// Log the request
if logErr := p.config.SessionLogger.LogHttpEvent(session.HttpEvent{
Timestamp: time.Now(),
RequestId: requestId,
EventType: session.HttpEventRequest,
URL: req.URL.String(),
Method: req.Method,
Headers: req.Header,
Body: reqBody,
}); logErr != nil {
l.Error().Err(logErr).Msg("Failed to log HTTP request event")
}

// Connect to target and forward request
targetURL := fmt.Sprintf("%s://%s%s", p.config.Protocol, p.config.TargetAddr, req.URL.RequestURI())

proxyReq, err := http.NewRequest(req.Method, targetURL, bytes.NewReader(reqBody))
if err != nil {
l.Error().Err(err).Msg("Failed to create proxy request")
writeErrorResponse(clientConn, "failed to create proxy request")
continue
}
proxyReq.Header = req.Header.Clone()

transport := &http.Transport{
DisableKeepAlives: false,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSClientConfig: p.config.TLSConfig,
}
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
// Don't follow redirects — let the client handle them
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
Comment on lines +132 to +145
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP transport and client created per request

A new http.Transport and http.Client is instantiated on every iteration of the loop, which means:

  1. Connection pooling is effectively disabled — a new TCP/TLS handshake is performed for every proxied request
  2. MaxIdleConns: 10 and DisableKeepAlives: false have no practical effect since the transport is discarded after each request
  3. Under load, this exhausts file descriptors and significantly increases latency

The transport and client should be created once per proxy instance (e.g., in NewWebAppProxy or at the start of HandleConnection) and reused across requests:

// In WebAppProxy struct
type WebAppProxy struct {
    config    WebAppProxyConfig
    transport *http.Transport
    client    *http.Client
}

func NewWebAppProxy(config WebAppProxyConfig) *WebAppProxy {
    transport := &http.Transport{
        DisableKeepAlives: false,
        MaxIdleConns:      10,
        IdleConnTimeout:   30 * time.Second,
        TLSClientConfig:   config.TLSConfig,
    }
    client := &http.Client{
        Transport: transport,
        Timeout:   30 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
    return &WebAppProxy{config: config, transport: transport, client: client}
}


resp, err := client.Do(proxyReq)
if err != nil {
l.Error().Err(err).Msg("Failed to forward request to target")
writeErrorResponse(clientConn, fmt.Sprintf("failed to reach target: %s", err.Error()))
continue
}

// Tee the body for logging
var bodyCopy bytes.Buffer
resp.Body = struct {
io.ReadCloser
}{
ReadCloser: io.NopCloser(io.TeeReader(resp.Body, &bodyCopy)),
}

// Write response back to tunnel client
resp.Header.Del("Connection")
if err := resp.Write(clientConn); err != nil {
if errors.Is(err, io.EOF) {
l.Info().Msg("Client closed connection during response write")
} else {
l.Error().Err(err).Msg("Failed to write response to connection")
}
resp.Body.Close()
return fmt.Errorf("failed to write response: %w", err)
}
resp.Body.Close()

// Log the response
if logErr := p.config.SessionLogger.LogHttpEvent(session.HttpEvent{
Timestamp: time.Now(),
RequestId: requestId,
EventType: session.HttpEventResponse,
Status: resp.Status,
Headers: resp.Header,
Body: bodyCopy.Bytes(),
}); logErr != nil {
l.Error().Err(logErr).Msg("Failed to log HTTP response event")
}

l.Info().
Str("reqId", requestId).
Str("status", resp.Status).
Msg("Forwarded response back to tunnel")
}
}

func writeErrorResponse(conn net.Conn, message string) {
errResp := fmt.Sprintf(
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\n\r\n{\"message\": \"gateway: %s\"}",
message,
)
conn.Write([]byte(errResp))
}
Comment on lines +194 to +200
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON injection in error response body

The message argument is interpolated directly into a raw JSON string without escaping. If err.Error() (line 150) returns a string containing double-quotes or backslashes (e.g., a network error mentioning a path like "failed to dial \"target:8080\": ..."), the resulting response body will be malformed JSON.

For example, the call at line 150:

writeErrorResponse(clientConn, fmt.Sprintf("failed to reach target: %s", err.Error()))

could produce: {"message": "gateway: failed to reach target: dial tcp: lookup "evil" ..."} — invalid JSON.

Use encoding/json to safely encode the message:

func writeErrorResponse(conn net.Conn, message string) {
    type errBody struct {
        Message string `json:"message"`
    }
    body, _ := json.Marshal(errBody{Message: "gateway: " + message})
    errResp := fmt.Sprintf(
        "HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s",
        len(body), body,
    )
    conn.Write([]byte(errResp))
}

21 changes: 21 additions & 0 deletions packages/pam/pam-proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/Infisical/infisical-merge/packages/pam/handlers/mysql"
"github.com/Infisical/infisical-merge/packages/pam/handlers/redis"
"github.com/Infisical/infisical-merge/packages/pam/handlers/ssh"
"github.com/Infisical/infisical-merge/packages/pam/handlers/webapp"
"github.com/Infisical/infisical-merge/packages/pam/session"
"github.com/go-resty/resty/v2"
"github.com/rs/zerolog/log"
Expand All @@ -39,6 +40,7 @@ func GetSupportedResourceTypes() []string {
session.ResourceTypeSSH,
session.ResourceTypeKubernetes,
session.ResourceTypeRedis,
session.ResourceTypeWebApp,
}
}

Expand Down Expand Up @@ -260,6 +262,25 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
Str("target", kubernetesConfig.TargetApiServer).
Msg("Starting Kubernetes PAM proxy")
return proxy.HandleConnection(ctx, conn)
case session.ResourceTypeWebApp:
webappProtocol := "https"
if !credentials.SSLEnabled {
webappProtocol = "http"
}
webappConfig := webapp.WebAppProxyConfig{
TargetAddr: fmt.Sprintf("%s:%d", credentials.Host, credentials.Port),
Protocol: webappProtocol,
TLSConfig: tlsConfig,
SessionID: pamConfig.SessionId,
SessionLogger: sessionLogger,
}
proxy := webapp.NewWebAppProxy(webappConfig)
log.Info().
Str("sessionId", pamConfig.SessionId).
Str("target", webappConfig.TargetAddr).
Str("protocol", webappProtocol).
Msg("Starting WebApp PAM proxy")
return proxy.HandleConnection(ctx, conn)
default:
return fmt.Errorf("unsupported resource type: %s", pamConfig.ResourceType)
}
Expand Down
5 changes: 3 additions & 2 deletions packages/pam/session/uploader.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
ResourceTypeRedis = "redis"
ResourceTypeSSH = "ssh"
ResourceTypeKubernetes = "kubernetes"
ResourceTypeWebApp = "web-app"
)

type SessionFileInfo struct {
Expand Down Expand Up @@ -55,7 +56,7 @@ func NewSessionUploader(httpClient *resty.Client, credentialsManager *Credential
func ParseSessionFilename(filename string) (*SessionFileInfo, error) {
// Try new format first: pam_session_{sessionID}_{resourceType}_expires_{timestamp}.enc
// Build regex pattern using constants
resourceTypePattern := fmt.Sprintf("(%s|%s|%s|%s|%s)", ResourceTypeSSH, ResourceTypePostgres, ResourceTypeRedis, ResourceTypeMysql, ResourceTypeKubernetes)
resourceTypePattern := fmt.Sprintf("(%s|%s|%s|%s|%s|%s)", ResourceTypeSSH, ResourceTypePostgres, ResourceTypeRedis, ResourceTypeMysql, ResourceTypeKubernetes, ResourceTypeWebApp)
newFormatRegex := regexp.MustCompile(fmt.Sprintf(`^pam_session_(.+)_%s_expires_(\d+)\.enc$`, resourceTypePattern))
matches := newFormatRegex.FindStringSubmatch(filename)

Expand Down Expand Up @@ -306,7 +307,7 @@ func (su *SessionUploader) uploadSessionFile(fileInfo *SessionFileInfo) error {

return api.CallUploadPamSessionLogs(su.httpClient, fileInfo.SessionID, request)
}
if fileInfo.ResourceType == ResourceTypeKubernetes {
if fileInfo.ResourceType == ResourceTypeKubernetes || fileInfo.ResourceType == ResourceTypeWebApp {
httpEvents, err := ReadEncryptedHttpEventsFromFile(fileInfo.Filename, encryptionKey)
if err != nil {
return fmt.Errorf("failed to read SSH session file: %w", err)
Expand Down