Pulse/internal/relay/proxy.go
rcourtman 24b2e40e92 Harden scanned request and storage boundaries
Harden CodeQL-scanned request, command, path, and frontend sinks across relay proxying, availability probes, connection probing, notification CLI execution, report storage, licensing persistence, preview bootstrapping, tooltip rendering, logging, and test identity generation.
2026-07-09 17:22:29 +01:00

493 lines
15 KiB
Go

package relay
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/rs/zerolog"
)
const (
// maxProxyBodySize is the maximum request/response body size before truncation.
// Must fit inside a 64KB relay frame after base64 encoding (~33% expansion) and
// JSON wrapper overhead (~500 bytes). 47KB * 4/3 ≈ 62.7KB + overhead ≈ 63.2KB < 64KB.
maxProxyBodySize = 47 * 1024 // 47KB
// proxyRequestTimeout is the per-request timeout for proxied HTTP calls.
proxyRequestTimeout = 30 * time.Second
)
// ProxyRequest is the JSON payload inside a DATA frame from the app to the instance.
type ProxyRequest struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"` // base64-encoded
}
// ProxyResponse is the JSON payload inside a DATA frame from the instance to the app.
type ProxyResponse struct {
ID string `json:"id"`
Status int `json:"status"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"` // base64-encoded
Stream bool `json:"stream,omitempty"` // true for all streaming chunks
StreamDone bool `json:"stream_done,omitempty"` // true for the final chunk
}
// HTTPProxy proxies DATA frame payloads to the local Pulse API.
type HTTPProxy struct {
localAddr string
client *http.Client // for normal request/response proxying
streamClient *http.Client // for SSE streaming (no timeout)
logger zerolog.Logger
}
// Close releases idle HTTP connections owned by the proxy clients.
func (p *HTTPProxy) Close() {
if p == nil {
return
}
if p.client != nil {
p.client.CloseIdleConnections()
}
if p.streamClient != nil {
p.streamClient.CloseIdleConnections()
}
}
// NewHTTPProxy creates a proxy that forwards requests to the given local address.
func NewHTTPProxy(localAddr string, logger zerolog.Logger) *HTTPProxy {
return &HTTPProxy{
localAddr: localAddr,
client: &http.Client{
Timeout: proxyRequestTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
streamClient: &http.Client{
// No Timeout — streaming responses are long-lived.
// Cancellation is handled via context.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logger,
}
}
// HandleRequest processes a DATA frame payload as an HTTP request and returns the response payload.
// The apiToken is the validated token from the channel's CHANNEL_OPEN, injected as X-API-Token.
func (p *HTTPProxy) HandleRequest(payload []byte, apiToken string) ([]byte, error) {
var req ProxyRequest
if err := json.Unmarshal(payload, &req); err != nil {
return p.errorResponse("", http.StatusBadRequest, "invalid request payload"), nil
}
req.Method = strings.TrimSpace(req.Method)
req.Path = strings.TrimSpace(req.Path)
if req.ID == "" || req.Method == "" || req.Path == "" {
return p.errorResponse(req.ID, http.StatusBadRequest, "missing required fields (id, method, path)"), nil
}
localURL, err := p.localAPIURL(req.Path)
if err != nil {
return p.errorResponse(req.ID, http.StatusBadRequest, "invalid proxy path"), nil
}
req.Path = localURL.RequestURI()
// Decode body if present
var bodyReader io.Reader
if req.Body != "" {
bodyBytes, err := base64.StdEncoding.DecodeString(req.Body)
if err != nil {
return p.errorResponse(req.ID, http.StatusBadRequest, "invalid base64 body"), nil
}
if len(bodyBytes) > maxProxyBodySize {
return p.errorResponse(req.ID, http.StatusRequestEntityTooLarge, "request body exceeds 47KB limit"), nil
}
bodyReader = bytes.NewReader(bodyBytes)
}
httpReq, err := http.NewRequest(req.Method, p.localAPIOrigin(), bodyReader)
if err != nil {
return p.errorResponse(req.ID, http.StatusInternalServerError, "failed to create request"), nil
}
httpReq.URL.Path = localURL.Path
httpReq.URL.RawQuery = localURL.RawQuery
// Allowlist: only forward safe, content-describing headers.
// Everything else is stripped to prevent auth-context leakage
// (X-Proxy-Secret, X-Forwarded-*, Forwarded, Cookie, Authorization, etc.)
for k, v := range req.Headers {
headerName := strings.TrimSpace(k)
if allowedProxyHeader(headerName) {
httpReq.Header.Set(headerName, v)
}
}
// Inject the API token for Pulse auth middleware
httpReq.Header.Set("X-API-Token", apiToken)
p.logger.Debug().
Str("request_id", req.ID).
Str("method", req.Method).
Str("path", req.Path).
Msg("proxying relay request to local API")
resp, err := p.client.Do(httpReq)
if err != nil {
p.logger.Warn().Err(err).Str("request_id", req.ID).Msg("local API request failed")
return p.errorResponse(req.ID, http.StatusBadGateway, "local API request failed"), nil
}
defer func() {
if err := resp.Body.Close(); err != nil {
p.logger.Warn().Err(err).Str("request_id", req.ID).Msg("Failed to close local API response body")
}
}()
// Read response body with size limit
limitedReader := io.LimitReader(resp.Body, maxProxyBodySize+1)
respBody, err := io.ReadAll(limitedReader)
if err != nil {
return p.errorResponse(req.ID, http.StatusBadGateway, "failed to read response body"), nil
}
if len(respBody) > maxProxyBodySize {
return p.errorResponse(req.ID, http.StatusRequestEntityTooLarge, "response body exceeds 47KB limit"), nil
}
// Build response headers (pick relevant ones)
respHeaders := make(map[string]string)
for _, key := range []string{"Content-Type", "X-Request-Id", "Cache-Control"} {
if v := resp.Header.Get(key); v != "" {
respHeaders[key] = v
}
}
proxyResp := ProxyResponse{
ID: req.ID,
Status: resp.StatusCode,
Headers: respHeaders,
}
if len(respBody) > 0 {
proxyResp.Body = base64.StdEncoding.EncodeToString(respBody)
}
data, err := json.Marshal(proxyResp)
if err != nil {
return p.errorResponse(req.ID, http.StatusInternalServerError, "failed to marshal response"), nil
}
return data, nil
}
// HandleStreamRequest processes a DATA frame payload as an HTTP request and streams
// the response as multiple ProxyResponse frames via sendFrame. For non-SSE responses,
// it falls back to single-response behavior identical to HandleRequest.
func (p *HTTPProxy) HandleStreamRequest(ctx context.Context, payload []byte, apiToken string, sendFrame func([]byte)) error {
var req ProxyRequest
if err := json.Unmarshal(payload, &req); err != nil {
sendFrame(p.errorResponse("", http.StatusBadRequest, "invalid request payload"))
return nil
}
req.Method = strings.TrimSpace(req.Method)
req.Path = strings.TrimSpace(req.Path)
if req.ID == "" || req.Method == "" || req.Path == "" {
sendFrame(p.errorResponse(req.ID, http.StatusBadRequest, "missing required fields (id, method, path)"))
return nil
}
localURL, err := p.localAPIURL(req.Path)
if err != nil {
sendFrame(p.errorResponse(req.ID, http.StatusBadRequest, "invalid proxy path"))
return nil
}
req.Path = localURL.RequestURI()
var bodyReader io.Reader
if req.Body != "" {
bodyBytes, err := base64.StdEncoding.DecodeString(req.Body)
if err != nil {
sendFrame(p.errorResponse(req.ID, http.StatusBadRequest, "invalid base64 body"))
return nil
}
if len(bodyBytes) > maxProxyBodySize {
sendFrame(p.errorResponse(req.ID, http.StatusRequestEntityTooLarge, "request body exceeds 47KB limit"))
return nil
}
bodyReader = bytes.NewReader(bodyBytes)
}
httpReq, err := http.NewRequestWithContext(ctx, req.Method, p.localAPIOrigin(), bodyReader)
if err != nil {
sendFrame(p.errorResponse(req.ID, http.StatusInternalServerError, "failed to create request"))
return nil
}
httpReq.URL.Path = localURL.Path
httpReq.URL.RawQuery = localURL.RawQuery
for k, v := range req.Headers {
headerName := strings.TrimSpace(k)
if allowedProxyHeader(headerName) {
httpReq.Header.Set(headerName, v)
}
}
httpReq.Header.Set("X-API-Token", apiToken)
p.logger.Debug().
Str("request_id", req.ID).
Str("method", req.Method).
Str("path", req.Path).
Msg("proxying relay request (stream-capable)")
resp, err := p.streamClient.Do(httpReq)
if err != nil {
p.logger.Warn().Err(err).Str("request_id", req.ID).Msg("local API request failed")
sendFrame(p.errorResponse(req.ID, http.StatusBadGateway, "local API request failed"))
return nil
}
defer func() {
if err := resp.Body.Close(); err != nil {
p.logger.Warn().Err(err).Str("request_id", req.ID).Msg("Failed to close streamed local API response body")
}
}()
// Check if this is an SSE response
ct := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "text/event-stream") {
// Non-streaming: read full body and send a single response (same as HandleRequest)
limitedReader := io.LimitReader(resp.Body, maxProxyBodySize+1)
respBody, err := io.ReadAll(limitedReader)
if err != nil {
sendFrame(p.errorResponse(req.ID, http.StatusBadGateway, "failed to read response body"))
return nil
}
if len(respBody) > maxProxyBodySize {
sendFrame(p.errorResponse(req.ID, http.StatusRequestEntityTooLarge, "response body exceeds 47KB limit"))
return nil
}
respHeaders := make(map[string]string)
for _, key := range []string{"Content-Type", "X-Request-Id", "Cache-Control"} {
if v := resp.Header.Get(key); v != "" {
respHeaders[key] = v
}
}
proxyResp := ProxyResponse{
ID: req.ID,
Status: resp.StatusCode,
Headers: respHeaders,
}
if len(respBody) > 0 {
proxyResp.Body = base64.StdEncoding.EncodeToString(respBody)
}
data, err := json.Marshal(proxyResp)
if err != nil {
sendFrame(p.errorResponse(req.ID, http.StatusInternalServerError, "failed to marshal response"))
return nil
}
sendFrame(data)
return nil
}
// SSE streaming mode: send an initial header frame
respHeaders := make(map[string]string)
respHeaders["Content-Type"] = "text/event-stream"
if v := resp.Header.Get("X-Request-Id"); v != "" {
respHeaders["X-Request-Id"] = v
}
initResp := ProxyResponse{
ID: req.ID,
Status: resp.StatusCode,
Headers: respHeaders,
Stream: true,
}
initData, err := json.Marshal(initResp)
if err != nil {
sendFrame(p.errorResponse(req.ID, http.StatusInternalServerError, "failed to marshal stream init"))
return nil
}
sendFrame(initData)
// Read SSE events line-by-line and forward as individual frames
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, maxProxyBodySize), maxProxyBodySize)
var eventBuf strings.Builder
for scanner.Scan() {
// Check if context was cancelled (relay disconnected)
if ctx.Err() != nil {
return ctx.Err()
}
line := scanner.Text()
if line == "" {
// Empty line = end of SSE event
if eventBuf.Len() > 0 {
eventText := eventBuf.String()
eventBuf.Reset()
chunk := ProxyResponse{
ID: req.ID,
Status: resp.StatusCode,
Body: base64.StdEncoding.EncodeToString([]byte(eventText)),
Stream: true,
}
chunkData, err := json.Marshal(chunk)
if err != nil {
p.logger.Warn().Err(err).Msg("failed to marshal SSE chunk")
continue
}
sendFrame(chunkData)
}
} else {
// Bound total buffered event size, not just per-line scanner token size.
// Without this, many small lines before a blank separator can grow
// eventBuf unbounded and exhaust memory.
added := len(line)
if eventBuf.Len() > 0 {
added++ // newline separator inserted between lines
}
if eventBuf.Len()+added > maxProxyBodySize {
sendFrame(p.errorResponse(req.ID, http.StatusRequestEntityTooLarge, "stream event exceeds 47KB limit"))
return nil
}
if eventBuf.Len() > 0 {
eventBuf.WriteByte('\n')
}
eventBuf.WriteString(line)
}
}
// Check for scanner error before sending completion.
// If scanning failed (e.g. token too long, transport read error), send an
// error response instead of stream_done so the client knows it's incomplete.
if err := scanner.Err(); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
p.logger.Warn().Err(err).Str("request_id", req.ID).Msg("SSE scanner error")
sendFrame(p.errorResponse(req.ID, http.StatusBadGateway, "stream read error"))
return nil
}
// Flush any remaining buffered event
if eventBuf.Len() > 0 {
eventText := eventBuf.String()
chunk := ProxyResponse{
ID: req.ID,
Status: resp.StatusCode,
Body: base64.StdEncoding.EncodeToString([]byte(eventText)),
Stream: true,
}
chunkData, _ := json.Marshal(chunk)
sendFrame(chunkData)
}
// Send stream-done frame (only on clean completion)
doneResp := ProxyResponse{
ID: req.ID,
Status: resp.StatusCode,
StreamDone: true,
}
doneData, _ := json.Marshal(doneResp)
sendFrame(doneData)
return nil
}
// allowedProxyHeaders is the set of headers that may be forwarded from relay
// requests to the local Pulse API. All other headers are stripped to prevent
// auth-context leakage (X-Proxy-Secret, X-Forwarded-*, etc.).
var allowedProxyHeaders = map[string]bool{
"accept": true,
"accept-encoding": true,
"accept-language": true,
"content-type": true,
"content-length": true,
"if-match": true,
"if-none-match": true,
"if-modified-since": true,
}
func allowedProxyHeader(name string) bool {
return allowedProxyHeaders[strings.ToLower(strings.TrimSpace(name))]
}
func (p *HTTPProxy) localAPIURL(rawPath string) (*url.URL, error) {
requestURI, err := normalizeProxyRequestURI(rawPath)
if err != nil {
return nil, err
}
return &url.URL{
Scheme: "http",
Host: p.localAddr,
Path: requestURI.Path,
RawQuery: requestURI.RawQuery,
}, nil
}
func (p *HTTPProxy) localAPIOrigin() string {
return "http://" + p.localAddr
}
func normalizeProxyRequestURI(rawPath string) (*url.URL, error) {
trimmed := strings.TrimSpace(rawPath)
if trimmed == "" {
return nil, fmt.Errorf("proxy path is required")
}
parsed, err := url.ParseRequestURI(trimmed)
if err != nil && !strings.HasPrefix(trimmed, "/") {
parsed, err = url.ParseRequestURI("/" + trimmed)
}
if err != nil {
return nil, fmt.Errorf("invalid proxy path: %w", err)
}
if parsed.Scheme != "" || parsed.Host != "" || parsed.Opaque != "" {
return nil, fmt.Errorf("proxy path must be origin-relative")
}
if parsed.Path == "" {
parsed.Path = "/"
}
if !strings.HasPrefix(parsed.Path, "/") {
parsed.Path = "/" + parsed.Path
}
if strings.HasPrefix(parsed.Path, "//") {
return nil, fmt.Errorf("proxy path must not be network-path relative")
}
parsed.Fragment = ""
return parsed, nil
}
func (p *HTTPProxy) errorResponse(requestID string, status int, message string) []byte {
resp := ProxyResponse{
ID: requestID,
Status: status,
Headers: map[string]string{
"Content-Type": "application/json",
},
}
body, _ := json.Marshal(map[string]string{"error": message})
resp.Body = base64.StdEncoding.EncodeToString(body)
data, _ := json.Marshal(resp)
return data
}