diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 96cbd1de5..2078ac3cd 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "context" "encoding/json" "errors" @@ -30,6 +31,13 @@ import ( var ignoreState = []string{"releaseNotes"} // excessive size +// limits for the unauthenticated jq parameter of the state endpoint +const ( + maxJqQueryLen = 512 // maximum length of the jq query + maxJqDuration = time.Second // maximum jq evaluation time + maxJqResultBytes = 1 << 20 // maximum size of the encoded jq result +) + // getPreferredLanguage returns the preferred language as two letter code func getPreferredLanguage(header string) string { languages, _, err := language.ParseAcceptLanguage(header) @@ -117,6 +125,24 @@ func jsonWrite(w http.ResponseWriter, data any) { json.NewEncoder(w).Encode(data) } +// jsonWriteLimited writes data as json, failing if the encoded result exceeds limit bytes. +// Encoding into a buffer keeps oversized results from reaching the client at all. +func jsonWriteLimited(w http.ResponseWriter, data any, limit int) { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(data); err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + if buf.Len() > limit { + jsonError(w, http.StatusBadRequest, errors.New("result too large")) + return + } + + w.Header().Set("Content-Type", "application/json") + buf.WriteTo(w) +} + func jsonError(w http.ResponseWriter, status int, err error) { w.WriteHeader(status) jsonWrite(w, util.ErrorAsJson(err)) @@ -256,6 +282,11 @@ func stateHandler(cache *util.ParamCache) http.HandlerFunc { if q := r.URL.Query().Get("jq"); q != "" { q = strings.TrimPrefix(q, ".result") + if len(q) > maxJqQueryLen { + jsonError(w, http.StatusBadRequest, errors.New("jq: query too long")) + return + } + query, err := gojq.Parse(q) if err != nil { jsonError(w, http.StatusBadRequest, err) @@ -268,13 +299,21 @@ func stateHandler(cache *util.ParamCache) http.HandlerFunc { return } - res, err := jq.Query(query, b) + // the query is attacker-controlled, so bound evaluation time and result size + ctx, cancel := context.WithTimeout(r.Context(), maxJqDuration) + defer cancel() + + res, err := jq.QueryContext(ctx, query, b) if err != nil { - jsonError(w, http.StatusBadRequest, err) + status := http.StatusBadRequest + if ctx.Err() != nil { + status = http.StatusServiceUnavailable + } + jsonError(w, status, err) return } - jsonWrite(w, res) + jsonWriteLimited(w, res, maxJqResultBytes) return } diff --git a/util/jq/jq.go b/util/jq/jq.go index 0f801bbd6..722311ebd 100644 --- a/util/jq/jq.go +++ b/util/jq/jq.go @@ -1,6 +1,7 @@ package jq import ( + "context" "encoding/json" "errors" "fmt" @@ -10,12 +11,19 @@ import ( // Query executes a compiled jq query against given json. It expects a single result only. func Query(query *gojq.Query, input []byte) (any, error) { + return QueryContext(context.Background(), query, input) +} + +// QueryContext executes a compiled jq query against given json, aborting when ctx is done. +// It expects a single result only. Use this instead of Query whenever the query originates +// from an untrusted source, since jq is turing-complete and evaluation may not terminate. +func QueryContext(ctx context.Context, query *gojq.Query, input []byte) (any, error) { var j any if err := json.Unmarshal(input, &j); err != nil { return j, err } - iter := query.Run(j) + iter := query.RunWithContext(ctx, j) v, ok := iter.Next() if !ok { @@ -23,6 +31,16 @@ func Query(query *gojq.Query, input []byte) (any, error) { } if err, ok := v.(error); ok { + // halt/halt_error do not terminate the iterator by themselves + var he *gojq.HaltError + if errors.As(err, &he) { + return nil, errors.New("jq: query halted") + } + + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("jq: %w", ctxErr) + } + return nil, fmt.Errorf("jq: query failed: %v", err) } diff --git a/util/jq/jq_test.go b/util/jq/jq_test.go new file mode 100644 index 000000000..f53c6335d --- /dev/null +++ b/util/jq/jq_test.go @@ -0,0 +1,59 @@ +package jq + +import ( + "context" + "testing" + "time" + + "github.com/itchyny/gojq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQuery(t *testing.T) { + query, err := gojq.Parse(".foo") + require.NoError(t, err) + + res, err := Query(query, []byte(`{"foo": 42}`)) + require.NoError(t, err) + assert.Equal(t, float64(42), res) +} + +// TestQueryContextTimeout ensures non-terminating queries are aborted instead of +// running forever, exhausting cpu or memory +func TestQueryContextTimeout(t *testing.T) { + for _, q := range []string{ + `[range(1e9)]`, // unbounded allocation + `def f: f; f`, // unbounded recursion + `[repeat(0)]`, // infinite generator + } { + t.Run(q, func(t *testing.T) { + query, err := gojq.Parse(q) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := QueryContext(ctx, query, []byte(`{}`)) + done <- err + }() + + select { + case err := <-done: + assert.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("query did not terminate") + } + }) + } +} + +func TestQueryContextHalt(t *testing.T) { + query, err := gojq.Parse(`halt`) + require.NoError(t, err) + + _, err = Query(query, []byte(`{}`)) + assert.Error(t, err) +}