diff --git a/kubelet-to-gcm/monitor/config/initialize.go b/kubelet-to-gcm/monitor/config/initialize.go index 0562a6951..712871cab 100644 --- a/kubelet-to-gcm/monitor/config/initialize.go +++ b/kubelet-to-gcm/monitor/config/initialize.go @@ -18,17 +18,20 @@ package config import ( "fmt" - "io/ioutil" "net/http" "strings" "time" "github.com/GoogleCloudPlatform/k8s-stackdriver/kubelet-to-gcm/monitor" + "github.com/GoogleCloudPlatform/k8s-stackdriver/kubelet-to-gcm/monitor/util" ) const ( gceMetaDataEndpoint = "http://169.254.169.254" gceMetaDataPrefix = "/computeMetadata/v1" + // maxResponseBodySize is the maximum size of the response body we will read. + // 10MB is more than enough for GCE metadata responses. + maxResponseBodySize = 10 * 1024 * 1024 ) // NewConfigs returns the SourceConfigs for all monitored endpoints, and @@ -117,7 +120,7 @@ func getGCEMetaData(uri string) ([]byte, error) { return nil, fmt.Errorf("Failed request %q for GCE metadata: %v", uri, err) } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + body, err := util.ReadWithLimit(resp.Body, maxResponseBodySize) if err != nil { return nil, fmt.Errorf("Failed to read body for request %q for GCE metadata: %v", uri, err) } diff --git a/kubelet-to-gcm/monitor/controller/client.go b/kubelet-to-gcm/monitor/controller/client.go index 2ed0cf6f7..bb0009cb8 100644 --- a/kubelet-to-gcm/monitor/controller/client.go +++ b/kubelet-to-gcm/monitor/controller/client.go @@ -19,11 +19,11 @@ package controller import ( "fmt" "io" - "io/ioutil" "net/http" "net/url" "strings" + "github.com/GoogleCloudPlatform/k8s-stackdriver/kubelet-to-gcm/monitor/util" "github.com/prometheus/common/expfmt" "github.com/prometheus/common/model" ) @@ -72,6 +72,12 @@ func NewMetrics(body []byte) (*Metrics, error) { return metrics, nil } +const ( + // maxResponseBodySize is the maximum size of the response body we will read. + // 10MB is more than enough for most prometheus metrics responses. + maxResponseBodySize = 10 * 1024 * 1024 +) + // Client queries metrics from the controller process. type Client struct { client *http.Client @@ -99,7 +105,7 @@ func (c *Client) doRequestAndParse(req *http.Request) (*Metrics, error) { return nil, err } defer response.Body.Close() - body, err := ioutil.ReadAll(response.Body) + body, err := util.ReadWithLimit(response.Body, maxResponseBodySize) if err != nil { return nil, fmt.Errorf("failed to read response body - %v", err) } diff --git a/kubelet-to-gcm/monitor/controller/client_test.go b/kubelet-to-gcm/monitor/controller/client_test.go new file mode 100644 index 000000000..2bff8fca0 --- /dev/null +++ b/kubelet-to-gcm/monitor/controller/client_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/GoogleCloudPlatform/k8s-stackdriver/kubelet-to-gcm/monitor/util" +) + +func TestDoRequestAndParse_SizeLimit(t *testing.T) { + // Create a mock server that returns a response larger than maxResponseBodySize. + largeDataSize := maxResponseBodySize + 1024 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Write more than maxResponseBodySize bytes. + data := make([]byte, largeDataSize) + w.Write(data) + })) + defer ts.Close() + + u, _ := url.Parse(ts.URL) + client := &Client{ + client: ts.Client(), + metricsURL: u, + } + + req, _ := http.NewRequest("GET", ts.URL, nil) + _, err := client.doRequestAndParse(req) + + if err == nil { + t.Fatal("Expected error due to size limit, but got none") + } + + if !strings.Contains(err.Error(), util.ErrBodyTooLarge.Error()) { + t.Errorf("Expected error containing %q, got %v", util.ErrBodyTooLarge, err) + } +} + +func TestDoRequestAndParse_Success(t *testing.T) { + metricsData := ` +# HELP node_collector_evictions_number Number of evictions +# TYPE node_collector_evictions_number counter +node_collector_evictions_number 10 +# HELP process_start_time_seconds Start time +# TYPE process_start_time_seconds gauge +process_start_time_seconds 1234567890 +` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(metricsData)) + })) + defer ts.Close() + + u, _ := url.Parse(ts.URL) + client := &Client{ + client: ts.Client(), + metricsURL: u, + } + + req, _ := http.NewRequest("GET", ts.URL, nil) + metrics, err := client.doRequestAndParse(req) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if metrics.NodeEvictions != 10 { + t.Errorf("Expected 10 evictions, got %d", metrics.NodeEvictions) + } + if metrics.CreateTime != 1234567890 { + t.Errorf("Expected 1234567890 create time, got %d", metrics.CreateTime) + } +} diff --git a/kubelet-to-gcm/monitor/kubelet/client.go b/kubelet-to-gcm/monitor/kubelet/client.go index db402439f..cd902b66d 100644 --- a/kubelet-to-gcm/monitor/kubelet/client.go +++ b/kubelet-to-gcm/monitor/kubelet/client.go @@ -19,13 +19,19 @@ package kubelet import ( "encoding/json" "fmt" - "io/ioutil" "net/http" "net/url" + "github.com/GoogleCloudPlatform/k8s-stackdriver/kubelet-to-gcm/monitor/util" stats "k8s.io/kubelet/pkg/apis/stats/v1alpha1" ) +const ( + // maxResponseBodySize is the maximum size of the response body we will read. + // 50MB should be enough for any Kubelet stats summary response. + maxResponseBodySize = 50 * 1024 * 1024 +) + // Client contains all the information and methods to encapsulate // communication with the Kubelet. type Client struct { @@ -58,7 +64,7 @@ func (k *Client) doRequestAndUnmarshal(client *http.Client, req *http.Request, v return err } defer response.Body.Close() - body, err := ioutil.ReadAll(response.Body) + body, err := util.ReadWithLimit(response.Body, maxResponseBodySize) if err != nil { return fmt.Errorf("failed to read response body - %v", err) } diff --git a/kubelet-to-gcm/monitor/kubelet/client_test.go b/kubelet-to-gcm/monitor/kubelet/client_test.go new file mode 100644 index 000000000..cdbf0d994 --- /dev/null +++ b/kubelet-to-gcm/monitor/kubelet/client_test.go @@ -0,0 +1,88 @@ +/* +Copyright 2017 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kubelet + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/GoogleCloudPlatform/k8s-stackdriver/kubelet-to-gcm/monitor/util" + stats "k8s.io/kubelet/pkg/apis/stats/v1alpha1" +) + +func TestDoRequestAndUnmarshal_SizeLimit(t *testing.T) { + largeDataSize := maxResponseBodySize + 1024 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := make([]byte, largeDataSize) + w.Write(data) + })) + defer ts.Close() + + u, _ := url.Parse(ts.URL) + k := &Client{ + client: ts.Client(), + summaryURL: u, + } + + req, _ := http.NewRequest("GET", ts.URL, nil) + var value stats.Summary + err := k.doRequestAndUnmarshal(ts.Client(), req, &value) + + if err == nil { + t.Fatal("Expected error due to size limit, but got none") + } + + if !strings.Contains(err.Error(), util.ErrBodyTooLarge.Error()) { + t.Errorf("Expected error containing %q, got %v", util.ErrBodyTooLarge, err) + } +} + +func TestDoRequestAndUnmarshal_Success(t *testing.T) { + summary := &stats.Summary{ + Node: stats.NodeStats{ + NodeName: "test-node", + }, + } + data, _ := json.Marshal(summary) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(data) + })) + defer ts.Close() + + u, _ := url.Parse(ts.URL) + k := &Client{ + client: ts.Client(), + summaryURL: u, + } + + req, _ := http.NewRequest("GET", ts.URL, nil) + var result stats.Summary + err := k.doRequestAndUnmarshal(ts.Client(), req, &result) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result.Node.NodeName != "test-node" { + t.Errorf("Expected test-node, got %s", result.Node.NodeName) + } +} diff --git a/kubelet-to-gcm/monitor/util/util.go b/kubelet-to-gcm/monitor/util/util.go new file mode 100644 index 000000000..93fbee007 --- /dev/null +++ b/kubelet-to-gcm/monitor/util/util.go @@ -0,0 +1,41 @@ +/* +Copyright 2026 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "errors" + "io" +) + +var ( + // ErrBodyTooLarge is returned when the response body exceeds the configured limit. + ErrBodyTooLarge = errors.New("response body too large") +) + +// ReadWithLimit reads from r until EOF or the limit is reached. +// If the limit is exceeded, it returns ErrBodyTooLarge. +func ReadWithLimit(r io.Reader, limit int64) ([]byte, error) { + // Read up to limit + 1 bytes to detect overflow. + data, err := io.ReadAll(io.LimitReader(r, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, ErrBodyTooLarge + } + return data, nil +} diff --git a/kubelet-to-gcm/monitor/util/util_test.go b/kubelet-to-gcm/monitor/util/util_test.go new file mode 100644 index 000000000..09a2e7b4d --- /dev/null +++ b/kubelet-to-gcm/monitor/util/util_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "bytes" + "strings" + "testing" +) + +func TestReadWithLimit(t *testing.T) { + testCases := []struct { + name string + input string + limit int64 + expected string + expectedErr error + }{ + { + name: "under limit", + input: "short", + limit: 10, + expected: "short", + expectedErr: nil, + }, + { + name: "at limit", + input: "exactlimit", + limit: 10, + expected: "exactlimit", + expectedErr: nil, + }, + { + name: "over limit", + input: "this is way too long", + limit: 10, + expected: "", + expectedErr: ErrBodyTooLarge, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := ReadWithLimit(strings.NewReader(tc.input), tc.limit) + if err != tc.expectedErr { + t.Fatalf("Expected error %v, got %v", tc.expectedErr, err) + } + if tc.expectedErr == nil && !bytes.Equal(got, []byte(tc.expected)) { + t.Errorf("Expected data %q, got %q", tc.expected, string(got)) + } + }) + } +}