API: bound jq evaluation on /api/state against unauthenticated DoS (#33028)
This commit is contained in:
parent
20058f6e0d
commit
769a6a1aa5
3 changed files with 120 additions and 4 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
59
util/jq/jq_test.go
Normal file
59
util/jq/jq_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue