diff --git a/cmd/test/main.go b/cmd/test/main.go new file mode 100644 index 00000000..072dc383 --- /dev/null +++ b/cmd/test/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" + "time" +) + +func requireBasicAuth(username, password string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + actualUsername, actualPassword, ok := r.BasicAuth() + if !ok || actualUsername != username || actualPassword != password { + w.Header().Set("WWW-Authenticate", `Basic realm="web-server-test"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func sanitizeHeaders(headers http.Header) http.Header { + sanitized := headers.Clone() + for _, name := range []string{ + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", + "X-Api-Key", + "X-Auth-Token", + } { + if _, exists := sanitized[name]; exists { + sanitized[name] = []string{"[REDACTED]"} + } + } + return sanitized +} + +func main() { + username := os.Getenv("WEB_TEST_USERNAME") + if username == "" { + username = "demo" + } + password := os.Getenv("WEB_TEST_PASSWORD") + if password == "" { + password = "secret" + } + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"status":"ok"}`) + }) + + mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "unable to read body", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.RawQuery, + "headers": sanitizeHeaders(r.Header), + "body": string(body), + }) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + log.Printf("Starting test web server on http://127.0.0.1:4040") + + server := &http.Server{ + Addr: "127.0.0.1:4040", + Handler: requireBasicAuth(username, password, mux), + ReadHeaderTimeout: 5 * time.Second, + } + log.Fatal(server.ListenAndServe()) +} diff --git a/packages/pam/handlers/webserver/proxy.go b/packages/pam/handlers/webserver/proxy.go new file mode 100644 index 00000000..8a0dc679 --- /dev/null +++ b/packages/pam/handlers/webserver/proxy.go @@ -0,0 +1,357 @@ +package webserver + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/pam/session" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +type WebServerProxyConfig struct { + TargetURI string + Username string + Password string + TLSConfig *tls.Config + SessionID string + SessionLogger session.SessionLogger +} + +type WebServerProxy struct { + config WebServerProxyConfig + targetURL *url.URL + client *http.Client +} + +func NewWebServerProxy(config WebServerProxyConfig) (*WebServerProxy, error) { + targetURL, err := url.Parse(config.TargetURI) + if err != nil { + return nil, fmt.Errorf("invalid web server URL: %w", err) + } + if targetURL.Host == "" || (targetURL.Scheme != "http" && targetURL.Scheme != "https") { + return nil, fmt.Errorf("web server URL must include http or https scheme and host") + } + if targetURL.User != nil { + return nil, fmt.Errorf("web server URL must not include user info") + } + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: false, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: config.TLSConfig, + } + + return &WebServerProxy{ + config: config, + targetURL: targetURL, + client: &http.Client{ + Transport: transport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + }, nil +} + +func joinTargetURL(base, requestURL *url.URL) *url.URL { + target := *base + target.Path, target.RawPath = joinURLPath(base, requestURL) + if target.RawQuery == "" || requestURL.RawQuery == "" { + target.RawQuery += requestURL.RawQuery + } else { + target.RawQuery += "&" + requestURL.RawQuery + } + target.Fragment = "" + return &target +} + +func joinURLPath(base, requestURL *url.URL) (string, string) { + if base.RawPath == "" && requestURL.RawPath == "" { + baseSlash := strings.HasSuffix(base.Path, "/") + requestSlash := strings.HasPrefix(requestURL.Path, "/") + switch { + case baseSlash && requestSlash: + return base.Path + requestURL.Path[1:], "" + case !baseSlash && !requestSlash: + return base.Path + "/" + requestURL.Path, "" + default: + return base.Path + requestURL.Path, "" + } + } + + basePath := base.EscapedPath() + requestPath := requestURL.EscapedPath() + baseSlash := strings.HasSuffix(basePath, "/") + requestSlash := strings.HasPrefix(requestPath, "/") + switch { + case baseSlash && requestSlash: + return base.Path + requestURL.Path[1:], basePath + requestPath[1:] + case !baseSlash && !requestSlash: + return base.Path + "/" + requestURL.Path, basePath + "/" + requestPath + default: + return base.Path + requestURL.Path, basePath + requestPath + } +} + +func sanitizeHeaders(headers http.Header) http.Header { + sanitized := headers.Clone() + for _, name := range []string{ + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", + "X-Api-Key", + "X-Auth-Token", + } { + if _, exists := sanitized[name]; exists { + sanitized[name] = []string{"[REDACTED]"} + } + } + return sanitized +} + +func removeHopByHopHeaders(headers http.Header) { + connectionHeaders := headers.Values("Connection") + for _, connectionHeader := range connectionHeaders { + for _, token := range strings.Split(connectionHeader, ",") { + if headerName := strings.TrimSpace(token); headerName != "" { + headers.Del(headerName) + } + } + } + + for _, headerName := range []string{ + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", + } { + headers.Del(headerName) + } +} + +func (p *WebServerProxy) HandleConnection(ctx context.Context, clientConn net.Conn) error { + reader := bufio.NewReader(clientConn) + + for { + req, err := http.ReadRequest(reader) + if err != nil { + if err == io.EOF { + return nil + } + return fmt.Errorf("failed to read web server request: %w", err) + } + + requestID := uuid.NewString() + targetURL := joinTargetURL(p.targetURL, req.URL) + if req.Method == http.MethodConnect || req.Header.Get("Upgrade") != "" { + requestBody := readBufferedRequestBody(req, reader) + p.logHTTPRequest(requestID, req, targetURL, requestBody) + return p.writeErrorResponse(clientConn, requestID, http.StatusNotImplemented, []byte("protocol upgrades are not supported\n")) + } + + if strings.EqualFold(req.Header.Get("Expect"), "100-continue") { + if _, err := io.WriteString(clientConn, "HTTP/1.1 100 Continue\r\n\r\n"); err != nil { + return fmt.Errorf("failed to write HTTP 100 Continue response: %w", err) + } + } + + requestBody, err := io.ReadAll(req.Body) + if err != nil { + return fmt.Errorf("failed to read web server request body: %w", err) + } + if err := req.Body.Close(); err != nil { + return fmt.Errorf("failed to close web server request body: %w", err) + } + + p.logHTTPRequest(requestID, req, targetURL, requestBody) + + proxyReq, err := http.NewRequestWithContext(ctx, req.Method, targetURL.String(), bytes.NewReader(requestBody)) + if err != nil { + return fmt.Errorf("failed to create upstream web server request: %w", err) + } + proxyReq.Header = req.Header.Clone() + removeHopByHopHeaders(proxyReq.Header) + proxyReq.Header.Del("Authorization") + proxyReq.Header.Del("Proxy-Authorization") + proxyReq.Header.Del("Expect") + proxyReq.SetBasicAuth(p.config.Username, p.config.Password) + + resp, err := p.client.Do(proxyReq) + if err != nil { + return p.writeBadGateway(clientConn, requestID) + } + if resp.StatusCode == http.StatusSwitchingProtocols { + if err := resp.Body.Close(); err != nil { + log.Error().Err(err).Str("sessionId", p.config.SessionID).Msg("Failed to close upstream web server response body") + } + return p.writeBadGateway(clientConn, requestID) + } + + var responseBody []byte + var readErr error + bodyAllowed := responseAllowsBody(req.Method, resp.StatusCode) + if bodyAllowed { + responseBody, readErr = io.ReadAll(resp.Body) + } + closeErr := resp.Body.Close() + if readErr != nil { + return p.writeBadGateway(clientConn, requestID) + } + if closeErr != nil { + log.Error().Err(closeErr).Str("sessionId", p.config.SessionID).Msg("Failed to close upstream web server response body") + } + + removeHopByHopHeaders(resp.Header) + resp.Trailer = nil + p.logHTTPEvent(session.HttpEvent{ + Timestamp: time.Now(), + EventType: session.HttpEventResponse, + RequestId: requestID, + Headers: sanitizeHeaders(resp.Header), + Status: resp.Status, + Body: responseBody, + }) + + resp.TransferEncoding = nil + resp.Header.Del("Transfer-Encoding") + if bodyAllowed { + resp.Body = io.NopCloser(bytes.NewReader(responseBody)) + resp.ContentLength = int64(len(responseBody)) + resp.Header.Set("Content-Length", strconv.Itoa(len(responseBody))) + } else { + resp.Body = http.NoBody + } + if err := writeProxyResponse(clientConn, resp, bodyAllowed); err != nil { + return fmt.Errorf("failed to write web server response: %w", err) + } + + if req.Close || resp.Close { + return nil + } + } +} + +func readBufferedRequestBody(req *http.Request, reader *bufio.Reader) []byte { + if req.Body == nil || req.Body == http.NoBody || req.ContentLength <= 0 || req.ContentLength > int64(reader.Buffered()) { + return nil + } + + body := make([]byte, int(req.ContentLength)) + n, err := io.ReadFull(req.Body, body) + if err != nil { + return body[:n] + } + if err := req.Body.Close(); err != nil { + return body + } + return body +} + +func responseAllowsBody(method string, statusCode int) bool { + return method != http.MethodHead && statusCode >= http.StatusOK && statusCode != http.StatusNoContent && statusCode != http.StatusNotModified +} + +func writeProxyResponse(writer io.Writer, response *http.Response, bodyAllowed bool) error { + if bodyAllowed { + return response.Write(writer) + } + + statusText := response.Status + if statusText == "" { + statusText = http.StatusText(response.StatusCode) + } else { + statusText = strings.TrimPrefix(statusText, strconv.Itoa(response.StatusCode)+" ") + } + if statusText == "" { + statusText = "status code " + strconv.Itoa(response.StatusCode) + } + if _, err := fmt.Fprintf(writer, "HTTP/%d.%d %03d %s\r\n", response.ProtoMajor, response.ProtoMinor, response.StatusCode, statusText); err != nil { + return err + } + + headers := response.Header.Clone() + if response.Close { + headers.Set("Connection", "close") + } + if err := headers.Write(writer); err != nil { + return err + } + _, err := io.WriteString(writer, "\r\n") + return err +} + +func (p *WebServerProxy) logHTTPRequest(requestID string, req *http.Request, targetURL *url.URL, body []byte) { + p.logHTTPEvent(session.HttpEvent{ + Timestamp: time.Now(), + EventType: session.HttpEventRequest, + RequestId: requestID, + Headers: sanitizeHeaders(req.Header), + Method: req.Method, + URL: targetURL.String(), + Body: body, + }) +} + +func (p *WebServerProxy) writeBadGateway(clientConn net.Conn, requestID string) error { + return p.writeErrorResponse(clientConn, requestID, http.StatusBadGateway, []byte("bad gateway\n")) +} + +func (p *WebServerProxy) writeErrorResponse(clientConn net.Conn, requestID string, statusCode int, responseBody []byte) error { + response := &http.Response{ + StatusCode: statusCode, + Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)), + ProtoMajor: 1, + ProtoMinor: 1, + ContentLength: int64(len(responseBody)), + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(responseBody)), + } + response.Header.Set("Content-Type", "text/plain") + response.Header.Set("Content-Length", strconv.Itoa(len(responseBody))) + response.Header.Set("Connection", "close") + response.Close = true + p.logHTTPEvent(session.HttpEvent{ + Timestamp: time.Now(), + EventType: session.HttpEventResponse, + RequestId: requestID, + Headers: sanitizeHeaders(response.Header), + Status: response.Status, + Body: responseBody, + }) + + if err := response.Write(clientConn); err != nil { + return fmt.Errorf("failed to write web server error response: %w", err) + } + return nil +} + +func (p *WebServerProxy) logHTTPEvent(event session.HttpEvent) { + if p.config.SessionLogger == nil { + return + } + if err := p.config.SessionLogger.LogHttpEvent(event); err != nil { + log.Error().Err(err).Str("sessionId", p.config.SessionID).Msg("Failed to record web server HTTP event") + } +} diff --git a/packages/pam/local/access.go b/packages/pam/local/access.go index d0debefa..a43a31a5 100644 --- a/packages/pam/local/access.go +++ b/packages/pam/local/access.go @@ -33,6 +33,7 @@ const ( AccountTypeAzureCli = "azure-cli" AccountTypeWindows = "windows" AccountTypeWindowsAd = "windows-ad" + AccountTypeWebServer = "web-server" ) const approvalRequiredErrorName = "PAM_APPROVAL_REQUIRED" @@ -118,6 +119,8 @@ func StartPAMAccess(accessToken, path, reason, durationStr, targetHost string, p startAzureAccess(httpClient, &pamResponse, displayPath, durationStr, port) case AccountTypeWindows, AccountTypeWindowsAd: startRDPProxy(httpClient, &pamResponse, displayPath, durationStr, port) + case AccountTypeWebServer: + startWebServerProxy(httpClient, &pamResponse, displayPath, durationStr, port) default: util.PrintErrorMessageAndExit(fmt.Sprintf("Unsupported account type: %s", pamResponse.AccountType)) } @@ -358,6 +361,16 @@ var accountDisplays = map[string]AccountConnectionDisplay{ return []string{fmt.Sprintf("redis-cli -h 127.0.0.1 -p %d", port)} }, }, + AccountTypeWebServer: { + TypeLabel: "Web Server", + DefaultPort: 4040, + ConnectionString: func(_, _ string, port int) string { + return fmt.Sprintf("http://127.0.0.1:%d", port) + }, + UsageExamples: func(_, _ string, port int) []string { + return []string{fmt.Sprintf("curl http://127.0.0.1:%d/health", port)} + }, + }, AccountTypeSSH: { TypeLabel: "SSH", DefaultPort: 22, @@ -499,6 +512,60 @@ func startDatabaseProxy(httpClient *resty.Client, response *api.PAMAccessRespons proxy.Run() } +func startWebServerProxy(httpClient *resty.Client, response *api.PAMAccessResponse, _ string, durationStr string, port int) { + duration, err := time.ParseDuration(durationStr) + if err != nil { + util.HandleError(err, "Failed to parse duration") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + + proxy := &DatabaseProxyServer{ + BaseProxyServer: BaseProxyServer{ + httpClient: httpClient, + relayHost: response.RelayHost, + relayClientCert: response.RelayClientCertificate, + relayClientKey: response.RelayClientPrivateKey, + relayServerCertChain: response.RelayServerCertificateChain, + gatewayClientCert: response.GatewayClientCertificate, + gatewayClientKey: response.GatewayClientPrivateKey, + gatewayServerCertChain: response.GatewayServerCertificateChain, + sessionExpiry: time.Now().Add(duration), + sessionId: response.SessionId, + resourceType: response.AccountType, + ctx: ctx, + cancel: cancel, + shutdownCh: make(chan struct{}), + }, + } + + if err := proxy.ValidateResourceTypeSupported(); err != nil { + util.HandleError(err, "Gateway version outdated") + return + } + + if err := proxy.Start(port); err != nil { + util.HandleError(err, "Failed to start proxy server") + return + } + + localURL := fmt.Sprintf("http://127.0.0.1:%d", proxy.port) + fmt.Printf("Web Server session ready at %s\n", localURL) + fmt.Printf("Example: curl %s/health\n", localURL) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Info().Msgf("Received signal %v, initiating graceful shutdown...", sig) + proxy.gracefulShutdown() + }() + + proxy.Run() +} + func startRedisProxy(httpClient *resty.Client, response *api.PAMAccessResponse, path, durationStr string, port int) { duration, err := time.ParseDuration(durationStr) if err != nil { diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index 2ff2653a..a33ba23a 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -24,6 +24,7 @@ import ( "github.com/Infisical/infisical-merge/packages/pam/handlers/rdp" "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/webserver" "github.com/Infisical/infisical-merge/packages/pam/session" "github.com/Infisical/infisical-merge/packages/util" "github.com/go-resty/resty/v2" @@ -61,6 +62,7 @@ func GetSupportedResourceTypes() []string { session.ResourceTypeOracledb, session.ResourceTypeGcpServiceAccount, session.ResourceTypeAzureCli, + session.ResourceTypeWebServer, } // Only advertise RDP when the real bridge is compiled in. A stub // build would otherwise accept RDP session routing and fail every @@ -528,6 +530,23 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo Str("sessionId", pamConfig.SessionId). Msg("Starting Azure CLI PAM proxy") return proxy.HandleConnection(ctx, handlerConn) + case session.ResourceTypeWebServer: + proxy, err := webserver.NewWebServerProxy(webserver.WebServerProxyConfig{ + TargetURI: credentials.Url, + Username: credentials.Username, + Password: credentials.Password, + TLSConfig: tlsConfig, + SessionID: pamConfig.SessionId, + SessionLogger: sessionLogger, + }) + if err != nil { + return fmt.Errorf("failed to initialize web server proxy: %w", err) + } + log.Info(). + Str("sessionId", pamConfig.SessionId). + Str("target", credentials.Url). + Msg("Starting Web Server PAM proxy") + return proxy.HandleConnection(ctx, handlerConn) default: return fmt.Errorf("unsupported resource type: %s", pamConfig.ResourceType) } diff --git a/packages/pam/session/uploader.go b/packages/pam/session/uploader.go index 42f4557c..569a27d8 100644 --- a/packages/pam/session/uploader.go +++ b/packages/pam/session/uploader.go @@ -36,6 +36,7 @@ const ( ResourceTypeWindows = "windows" ResourceTypeGcpServiceAccount = "gcp-service-account" ResourceTypeAzureCli = "azure-cli" + ResourceTypeWebServer = "web-server" ) type SessionFileInfo struct { @@ -82,7 +83,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|%s|%s|%s|%s|%s|%s)", ResourceTypeSSH, ResourceTypePostgres, ResourceTypeRedis, ResourceTypeMysql, ResourceTypeMssql, ResourceTypeKubernetes, ResourceTypeMongodb, ResourceTypeOracledb, ResourceTypeWindows, ResourceTypeGcpServiceAccount, ResourceTypeAzureCli) + resourceTypePattern := fmt.Sprintf("(%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s)", ResourceTypeSSH, ResourceTypePostgres, ResourceTypeRedis, ResourceTypeMysql, ResourceTypeMssql, ResourceTypeKubernetes, ResourceTypeMongodb, ResourceTypeOracledb, ResourceTypeWindows, ResourceTypeGcpServiceAccount, ResourceTypeAzureCli, ResourceTypeWebServer) newFormatRegex := regexp.MustCompile(fmt.Sprintf(`^pam_session_(.+)_%s_expires_(\d+)\.enc$`, resourceTypePattern)) matches := newFormatRegex.FindStringSubmatch(filename) @@ -694,7 +695,7 @@ func (su *SessionUploader) uploadSessionFile(fileInfo *SessionFileInfo) error { return api.CallUploadPamSessionLogs(su.httpClient, fileInfo.SessionID, api.UploadPAMSessionLogsRequest{Logs: logs}) } - if fileInfo.ResourceType == ResourceTypeKubernetes || fileInfo.ResourceType == ResourceTypeGcpServiceAccount || fileInfo.ResourceType == ResourceTypeAzureCli { + if fileInfo.ResourceType == ResourceTypeKubernetes || fileInfo.ResourceType == ResourceTypeGcpServiceAccount || fileInfo.ResourceType == ResourceTypeAzureCli || fileInfo.ResourceType == ResourceTypeWebServer { httpEvents, err := ReadEncryptedHttpEventsFromFile(fileInfo.Filename, encryptionKey) if err != nil { return fmt.Errorf("failed to read HTTP session file: %w", err) @@ -704,7 +705,7 @@ func (su *SessionUploader) uploadSessionFile(fileInfo *SessionFileInfo) error { Str("sessionId", fileInfo.SessionID). Str("resourceType", fileInfo.ResourceType). Int("eventCount", len(httpEvents)). - Msg("Uploading Kubernetes session events") + Msg("Uploading HTTP session events") var logs []api.UploadHttpEvent for _, event := range httpEvents {