-
Notifications
You must be signed in to change notification settings - Fork 207
Add foundational webhook package (Phase 1 of dynamic webhook middleware) #3840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
49e9d16
ref: Implementation for Dynamic webhook phase one
Sanskarzz ec85a10
fix: CI error and codecov
Sanskarzz dfaa5bc
Merge branch 'main' into dynamicwebhook1
Sanskarzz 5ab0f59
fix: CI lint error fix
Sanskarzz 21157fe
Merge branch 'main' into dynamicwebhook1
Sanskarzz ae1343a
Merge branch 'main' into dynamicwebhook1
Sanskarzz 0f694b3
fix: resolved review comments
Sanskarzz c219ecb
Merge branch 'main' into dynamicwebhook1
Sanskarzz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package webhook | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "net" | ||
| "net/http" | ||
| "os" | ||
| "strconv" | ||
| "time" | ||
|
|
||
| "github.com/stacklok/toolhive/pkg/networking" | ||
| ) | ||
|
|
||
| // Client is an HTTP client for calling webhook endpoints. | ||
| type Client struct { | ||
| httpClient *http.Client | ||
| config Config | ||
| hmacSecret []byte | ||
| // TODO: webhookType will be used by a future Send() method that dispatches | ||
| // to Call or CallMutating based on type. For now callers pick the method directly. | ||
| webhookType Type | ||
| } | ||
|
|
||
| // NewClient creates a new webhook Client from the given configuration. | ||
| // The hmacSecret parameter is the resolved secret bytes for HMAC signing; | ||
| // pass nil if signing is not configured. | ||
| func NewClient(cfg Config, webhookType Type, hmacSecret []byte) (*Client, error) { | ||
| if err := cfg.Validate(); err != nil { | ||
| return nil, fmt.Errorf("invalid webhook config: %w", err) | ||
| } | ||
|
|
||
| timeout := cfg.Timeout | ||
| if timeout == 0 { | ||
| timeout = DefaultTimeout | ||
| } | ||
|
|
||
| transport, err := buildTransport(cfg.TLSConfig) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to build HTTP transport: %w", err) | ||
| } | ||
|
|
||
| return &Client{ | ||
| httpClient: &http.Client{ | ||
| Transport: transport, | ||
| Timeout: timeout, | ||
| }, | ||
| config: cfg, | ||
| hmacSecret: hmacSecret, | ||
| webhookType: webhookType, | ||
| }, nil | ||
| } | ||
|
|
||
| // Call sends a request to a validating webhook and returns its response. | ||
| func (c *Client) Call(ctx context.Context, req *Request) (*Response, error) { | ||
| body, err := json.Marshal(req) | ||
| if err != nil { | ||
| return nil, NewInvalidResponseError(c.config.Name, fmt.Errorf("failed to marshal request: %w", err), 0) | ||
| } | ||
|
|
||
| respBody, err := c.doHTTPCall(ctx, body) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var resp Response | ||
| if err := json.Unmarshal(respBody, &resp); err != nil { | ||
| return nil, NewInvalidResponseError(c.config.Name, fmt.Errorf("failed to unmarshal response: %w", err), 0) | ||
| } | ||
|
|
||
| return &resp, nil | ||
| } | ||
|
|
||
| // CallMutating sends a request to a mutating webhook and returns its response. | ||
| func (c *Client) CallMutating(ctx context.Context, req *Request) (*MutatingResponse, error) { | ||
| body, err := json.Marshal(req) | ||
| if err != nil { | ||
| return nil, NewInvalidResponseError(c.config.Name, fmt.Errorf("failed to marshal request: %w", err), 0) | ||
| } | ||
|
|
||
| respBody, err := c.doHTTPCall(ctx, body) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var resp MutatingResponse | ||
| if err := json.Unmarshal(respBody, &resp); err != nil { | ||
| return nil, NewInvalidResponseError(c.config.Name, fmt.Errorf("failed to unmarshal mutating response: %w", err), 0) | ||
| } | ||
|
|
||
| return &resp, nil | ||
| } | ||
|
|
||
| // doHTTPCall performs the HTTP POST to the webhook endpoint, handling signing, | ||
| // error classification, and response size limiting. | ||
| func (c *Client) doHTTPCall(ctx context.Context, body []byte) ([]byte, error) { | ||
| httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.URL, bytes.NewReader(body)) | ||
| if err != nil { | ||
| return nil, NewNetworkError(c.config.Name, fmt.Errorf("failed to create HTTP request: %w", err)) | ||
| } | ||
| httpReq.Header.Set("Content-Type", "application/json") | ||
|
|
||
| // Apply HMAC signing if configured. | ||
| if len(c.hmacSecret) > 0 { | ||
| timestamp := time.Now().Unix() | ||
| signature := SignPayload(c.hmacSecret, timestamp, body) | ||
| httpReq.Header.Set(SignatureHeader, signature) | ||
| httpReq.Header.Set(TimestampHeader, strconv.FormatInt(timestamp, 10)) | ||
| } | ||
|
|
||
| // #nosec G704 -- URL is validated in Config.Validate and we use ValidatingTransport for SSRF protection. | ||
| resp, err := c.httpClient.Do(httpReq) | ||
| if err != nil { | ||
| return nil, classifyError(c.config.Name, err) | ||
| } | ||
| defer func() { | ||
| _ = resp.Body.Close() | ||
| }() | ||
|
|
||
| // Enforce response size limit. | ||
| limitedReader := io.LimitReader(resp.Body, MaxResponseSize+1) | ||
| respBody, err := io.ReadAll(limitedReader) | ||
| if err != nil { | ||
| return nil, NewNetworkError(c.config.Name, fmt.Errorf("failed to read response body: %w", err)) | ||
| } | ||
| if int64(len(respBody)) > MaxResponseSize { | ||
| return nil, NewInvalidResponseError(c.config.Name, | ||
| fmt.Errorf("response body exceeds maximum size of %d bytes", MaxResponseSize), 0) | ||
| } | ||
|
|
||
| // 5xx errors indicate webhook operational failures. | ||
| if resp.StatusCode >= http.StatusInternalServerError { | ||
|
JAORMX marked this conversation as resolved.
|
||
| return nil, NewNetworkError(c.config.Name, | ||
| fmt.Errorf("webhook returned HTTP %d: %s", resp.StatusCode, truncateBody(respBody))) | ||
| } | ||
|
|
||
| // Non-200 responses (excluding 5xx handled above) are treated as invalid. | ||
| // The StatusCode is surfaced so callers can distinguish HTTP 422 (RFC always-deny) | ||
| // from other non-2xx codes that may follow the failure policy. | ||
| if resp.StatusCode != http.StatusOK { | ||
| return nil, NewInvalidResponseError(c.config.Name, | ||
| fmt.Errorf("webhook returned HTTP %d: %s", resp.StatusCode, truncateBody(respBody)), | ||
| resp.StatusCode) | ||
| } | ||
|
|
||
| return respBody, nil | ||
| } | ||
|
|
||
| // buildTransport creates an http.RoundTripper with the specified TLS configuration, | ||
| // always wrapped in ValidatingTransport for SSRF protection. | ||
| func buildTransport(tlsCfg *TLSConfig) (http.RoundTripper, error) { | ||
| transport := &http.Transport{ | ||
| TLSHandshakeTimeout: 10 * time.Second, | ||
| ResponseHeaderTimeout: 10 * time.Second, | ||
| MaxIdleConns: 100, | ||
| MaxIdleConnsPerHost: 10, | ||
|
JAORMX marked this conversation as resolved.
|
||
| IdleConnTimeout: 90 * time.Second, | ||
| } | ||
|
|
||
| // allowHTTP is true when InsecureSkipVerify is set, which also covers in-cluster | ||
| allowHTTP := tlsCfg != nil && tlsCfg.InsecureSkipVerify | ||
|
|
||
| if tlsCfg != nil { | ||
| tlsConfig := &tls.Config{ | ||
| MinVersion: tls.VersionTLS12, | ||
| } | ||
|
|
||
| // Load CA bundle if provided. | ||
| if tlsCfg.CABundlePath != "" { | ||
| caCert, err := os.ReadFile(tlsCfg.CABundlePath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read CA bundle: %w", err) | ||
| } | ||
| caCertPool := x509.NewCertPool() | ||
| if !caCertPool.AppendCertsFromPEM(caCert) { | ||
| return nil, fmt.Errorf("failed to parse CA certificate bundle") | ||
| } | ||
| tlsConfig.RootCAs = caCertPool | ||
| } | ||
|
|
||
| // Load client certificate for mTLS if provided. | ||
| if tlsCfg.ClientCertPath != "" && tlsCfg.ClientKeyPath != "" { | ||
| cert, err := tls.LoadX509KeyPair(tlsCfg.ClientCertPath, tlsCfg.ClientKeyPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to load client certificate: %w", err) | ||
| } | ||
| tlsConfig.Certificates = []tls.Certificate{cert} | ||
| } | ||
|
|
||
| if tlsCfg.InsecureSkipVerify { | ||
| //#nosec G402 -- InsecureSkipVerify is intentionally user-configurable for development/testing only. | ||
| tlsConfig.InsecureSkipVerify = true | ||
| } | ||
|
|
||
| transport.TLSClientConfig = tlsConfig | ||
| } | ||
|
|
||
| // Always wrap in ValidatingTransport for SSRF protection, even without TLS config. | ||
| return &networking.ValidatingTransport{ | ||
| Transport: transport, | ||
| InsecureAllowHTTP: allowHTTP, | ||
| }, nil | ||
| } | ||
|
|
||
| // classifyError examines an HTTP client error and returns an appropriately | ||
| // typed webhook error (TimeoutError or NetworkError). | ||
| func classifyError(webhookName string, err error) error { | ||
| // Check for context cancellation/deadline first, as these may not wrap net.Error. | ||
| if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { | ||
| return NewTimeoutError(webhookName, err) | ||
| } | ||
| // Check for timeout errors via net.Error interface (e.g., dial timeout). | ||
| var netErr net.Error | ||
| if errors.As(err, &netErr) && netErr.Timeout() { | ||
| return NewTimeoutError(webhookName, err) | ||
| } | ||
| return NewNetworkError(webhookName, err) | ||
| } | ||
|
|
||
| // truncateBody returns a preview of the response body for error messages. | ||
| func truncateBody(body []byte) string { | ||
| const maxPreview = 256 | ||
| if len(body) <= maxPreview { | ||
| return string(body) | ||
| } | ||
| return string(body[:maxPreview]) + "..." | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.