diff --git a/features/vcr/vcr.go b/features/vcr/vcr.go index dd25ceb9a..21f5d4fba 100644 --- a/features/vcr/vcr.go +++ b/features/vcr/vcr.go @@ -224,31 +224,44 @@ type captureWriter struct { wroteHeader bool } -// WriteHeader records the status and, like writeRecorded, makes sure the live -// response carries a concrete content type (a non-active default when the -// handler set none) and nosniff, so a browser cannot read it as HTML. +// WriteHeader records the status and sends the headers. Only the first call +// counts, as with net/http. func (c *captureWriter) WriteHeader(code int) { + _, _ = c.send(code, nil, false) +} + +// Write records the body chunk and forwards it, sending a 200 first if the +// handler did not set a status. +func (c *captureWriter) Write(b []byte) (int, error) { + c.body.Write(b) + + return c.send(http.StatusOK, b, true) +} + +// send is the only path to the live writer. Before the headers go out it pins +// a concrete content type (the handler's, else a non-active default) and +// nosniff on that writer, like writeRecorded does on replay, so a browser +// cannot read the forwarded body as HTML. +func (c *captureWriter) send(code int, b []byte, hasBody bool) (int, error) { + w := c.ResponseWriter + if !c.wroteHeader { c.status = code c.wroteHeader = true - hdr := c.ResponseWriter.Header() - if hdr.Get("Content-Type") == "" { - hdr.Set("Content-Type", "application/octet-stream") + ct := w.Header().Get("Content-Type") + if ct == "" { + ct = "application/octet-stream" } - hdr.Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Type", ct) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(code) } - c.ResponseWriter.WriteHeader(code) -} - -func (c *captureWriter) Write(b []byte) (int, error) { - if !c.wroteHeader { - c.WriteHeader(http.StatusOK) + if !hasBody { + return 0, nil } - c.body.Write(b) - - return c.ResponseWriter.Write(b) + return w.Write(b) } diff --git a/features/vcr/vcr_headers_test.go b/features/vcr/vcr_headers_test.go index c8a7f89e5..396b5e1cc 100644 --- a/features/vcr/vcr_headers_test.go +++ b/features/vcr/vcr_headers_test.go @@ -13,9 +13,10 @@ import ( // it set one, a non-active default when it did not. func TestRecordSetsSafeHeaders(t *testing.T) { tests := []struct { - name string - handler http.HandlerFunc - wantCT string + name string + handler http.HandlerFunc + wantStatus int + wantCT string }{ { name: "handler content type kept", @@ -24,14 +25,43 @@ func TestRecordSetsSafeHeaders(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name":""}`)) }, - wantCT: "application/json", + wantStatus: http.StatusOK, + wantCT: "application/json", }, { name: "write without header gets default", handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("")) }, - wantCT: "application/octet-stream", + wantStatus: http.StatusOK, + wantCT: "application/octet-stream", + }, + { + name: "explicit status without content type gets default", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("")) + }, + wantStatus: http.StatusCreated, + wantCT: "application/octet-stream", + }, + { + name: "text content type kept", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("")) + }, + wantStatus: http.StatusOK, + wantCT: "text/plain; charset=utf-8", + }, + { + name: "first status wins", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + w.WriteHeader(http.StatusTeapot) + }, + wantStatus: http.StatusAccepted, + wantCT: "application/octet-stream", }, } @@ -43,8 +73,8 @@ func TestRecordSetsSafeHeaders(t *testing.T) { } resp := doRequest(t, v.Wrap(tc.handler, "aws"), http.MethodGet, "/x", "") - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) + if resp.StatusCode != tc.wantStatus { + t.Fatalf("status = %d, want %d", resp.StatusCode, tc.wantStatus) } if got := resp.Header.Get("Content-Type"); got != tc.wantCT { @@ -57,3 +87,46 @@ func TestRecordSetsSafeHeaders(t *testing.T) { }) } } + +// TestRecordKeepsBodyAndStatus checks that the recorder forwards and records +// the handler's bytes unchanged, across several writes, with its status. +func TestRecordKeepsBodyAndStatus(t *testing.T) { + path := filepath.Join(t.TempDir(), "c.json") + + v, err := vcr.New(vcr.Options{Mode: vcr.ModeRecord, CassettePath: path}) + if err != nil { + t.Fatalf("New: %v", err) + } + + h := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"a":"",`)) + _, _ = w.Write([]byte(`"c":1}`)) + }) + + resp := doRequest(t, v.Wrap(h, "azure"), http.MethodPut, "/x", "") + + const want = `{"a":"","c":1}` + if got := readBody(t, resp); got != want || resp.StatusCode != http.StatusCreated { + t.Fatalf("live response = %d %q, want 201 %q", resp.StatusCode, got, want) + } + + if err := v.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + c, err := vcr.LoadCassette(path) + if err != nil { + t.Fatalf("LoadCassette: %v", err) + } + + if len(c.Interactions) != 1 { + t.Fatalf("interactions = %d, want 1", len(c.Interactions)) + } + + got := c.Interactions[0].Response + if string(got.Body) != want || got.Status != http.StatusCreated { + t.Fatalf("recorded = %d %q, want 201 %q", got.Status, got.Body, want) + } +} diff --git a/internal/vtl/eval.go b/internal/vtl/eval.go index f82ecfd48..7c85863d1 100644 --- a/internal/vtl/eval.go +++ b/internal/vtl/eval.go @@ -680,11 +680,23 @@ func truthy(v any) bool { } } +// toInt narrows a template number to an int index, offset or range bound. Like +// the Java int those take in Velocity, only the int32 range is accepted; a value +// outside it, NaN or an infinity is not an int, so the caller treats it like any +// other non-integer argument. A fraction truncates toward zero. func toInt(v any) (int, bool) { switch t := v.(type) { case int64: + if t < math.MinInt32 || t > math.MaxInt32 { + return 0, false + } + return int(t), true case float64: + if math.IsNaN(t) || t < math.MinInt32 || t > math.MaxInt32 { + return 0, false + } + return int(t), true default: return 0, false diff --git a/internal/vtl/int_range_test.go b/internal/vtl/int_range_test.go new file mode 100644 index 000000000..45f5a77a9 --- /dev/null +++ b/internal/vtl/int_range_test.go @@ -0,0 +1,78 @@ +package vtl + +import ( + "math" + "testing" +) + +func TestToIntRange(t *testing.T) { + cases := []struct { + name string + in any + want int + wantOK bool + }{ + {"int64 in range", int64(42), 42, true}, + {"int64 max int32", int64(math.MaxInt32), math.MaxInt32, true}, + {"int64 min int32", int64(math.MinInt32), math.MinInt32, true}, + {"int64 above int32", int64(math.MaxInt32) + 1, 0, false}, + {"int64 below int32", int64(math.MinInt32) - 1, 0, false}, + {"int64 max", int64(math.MaxInt64), 0, false}, + {"float truncates", 1.9, 1, true}, + {"negative float truncates", -1.9, -1, true}, + {"float above int32", 1e19, 0, false}, + {"float below int32", -1e19, 0, false}, + {"NaN", math.NaN(), 0, false}, + {"positive infinity", math.Inf(1), 0, false}, + {"negative infinity", math.Inf(-1), 0, false}, + {"string", "1", 0, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := toInt(tc.in) + if got != tc.want || ok != tc.wantOK { + t.Fatalf("toInt(%v) = (%d, %v), want (%d, %v)", tc.in, got, ok, tc.want, tc.wantOK) + } + }) + } +} + +// TestOutOfRangeNumbersInTemplates checks that every place a template number +// becomes an index, offset or range bound treats an out-of-range value, NaN or +// an infinity as a non-integer instead of wrapping or saturating it. +func TestOutOfRangeNumbersInTemplates(t *testing.T) { + vars := map[string]any{ + "big": 1e19, + "huge": int64(math.MaxInt64), + "wide": int64(math.MaxInt32) + 1, + "nan": math.NaN(), + "inf": math.Inf(1), + "s": "hello", + } + + cases := []struct { + name, src, want string + }{ + {"range float bound", "#foreach($i in [0..$big])x#end.", "."}, + {"range int64 bound", "#foreach($i in [$huge..$huge])x#end.", "."}, + {"range NaN bound", "#foreach($i in [0..$nan])x#end.", "."}, + {"index read", "#set($l = [1, 2, 3])[$!l[$wide]]", "[]"}, + {"list get", "#set($l = [1, 2, 3])[$!l.get($huge)]", "[]"}, + {"list set", "#set($l = [1, 2, 3])#set($r = $l.set($wide, 9))$l", "[1, 2, 3]"}, + {"list remove by index", "#set($l = [1, 2, 3])[$!l.remove($huge)]$l", "[][1, 2, 3]"}, + {"index write", "#set($l = [1, 2, 3])#set($l[$wide] = 9)$l", "[1, 2, 3]"}, + {"substring NaN", "[$!s.substring($nan)]", "[]"}, + {"substring infinity", "[$!s.substring($inf)]", "[]"}, + {"charAt huge", "[$!s.charAt($big)]", "[]"}, + {"in range still works", "$s.substring(1, 3)$s.charAt(0)", "elh"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := render(t, tc.src, vars); got != tc.want { + t.Fatalf("render(%q) = %q, want %q", tc.src, got, tc.want) + } + }) + } +} diff --git a/internal/vtl/methods.go b/internal/vtl/methods.go index 95d709434..cb525d7e9 100644 --- a/internal/vtl/methods.go +++ b/internal/vtl/methods.go @@ -280,8 +280,9 @@ func listRemove(l *List, args []any) any { return nil } - if i, ok := args[0].(int64); ok { - if i < 0 || int(i) >= len(l.Items) { + if n, ok := args[0].(int64); ok { + i, inRange := toInt(n) + if !inRange || i < 0 || i >= len(l.Items) { return nil } diff --git a/providers/aws/cognito/passwords.go b/providers/aws/cognito/passwords.go index f8abd59fa..638435180 100644 --- a/providers/aws/cognito/passwords.go +++ b/providers/aws/cognito/passwords.go @@ -20,13 +20,11 @@ const ( maxPasswordLen = 256 ) -// Stored hashes look like "pbkdf2-sha256$$". The prefix -// tells them apart from hashes written before it existed (older snapshots), -// which are a bare hex SHA-256 of salt+password, so a sign-in flow can verify -// both. The iteration count is modest because this is an emulator holding test -// credentials, and it keeps tests fast. +// Stored hashes look like "pbkdf2-sha256$$". The count is +// stored with each hash, so changing pbkdf2Iterations keeps old hashes valid. const ( - pbkdf2Prefix = "pbkdf2-sha256$" + pbkdf2Prefix = "pbkdf2-sha256$" + // pbkdf2Iterations is deliberately low for an emulator: fast tests, no real secrets. pbkdf2Iterations = 10000 pbkdf2KeyLen = 32 // maxPBKDF2Iterations caps the iteration count a stored hash may claim, so @@ -115,18 +113,12 @@ func pbkdf2Hash(salt, pw string, iter int) string { return pbkdf2Prefix + strconv.Itoa(iter) + "$" + hex.EncodeToString(key) } -// verifyPassword reports whether pw matches a stored salt and hash, in either -// the PBKDF2 format or the legacy bare SHA-256 format older snapshots carry. +// verifyPassword reports whether pw matches a stored salt and PBKDF2 hash. A +// hash in any other format never matches. func verifyPassword(salt, stored, pw string) bool { - if stored == "" { - return false - } - rest, ok := strings.CutPrefix(stored, pbkdf2Prefix) if !ok { - sum := sha256.Sum256([]byte(salt + pw)) - - return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(stored)) == 1 + return false } iterText, _, ok := strings.Cut(rest, "$") diff --git a/providers/aws/cognito/passwords_test.go b/providers/aws/cognito/passwords_test.go index 40e8ecb7a..2716b3a26 100644 --- a/providers/aws/cognito/passwords_test.go +++ b/providers/aws/cognito/passwords_test.go @@ -1,10 +1,13 @@ package cognito import ( + "context" "crypto/sha256" "encoding/hex" "strings" "testing" + + "github.com/stackshy/cloudemu/v2/services/cognito/driver" ) func TestVerifyPassword(t *testing.T) { @@ -26,8 +29,7 @@ func TestVerifyPassword(t *testing.T) { {"pbkdf2 match", salt, hash, pw, true}, {"pbkdf2 wrong password", salt, hash, "Wr0ng!horse", false}, {"pbkdf2 wrong salt", legacySalt, hash, pw, false}, - {"legacy sha256 match", legacySalt, legacyHash, pw, true}, - {"legacy sha256 wrong password", legacySalt, legacyHash, "Wr0ng!horse", false}, + {"unprefixed sha256 hash rejected", legacySalt, legacyHash, pw, false}, {"empty stored hash", salt, "", pw, false}, {"malformed pbkdf2 hash", salt, pbkdf2Prefix + "abc", pw, false}, {"non-numeric iterations", salt, pbkdf2Prefix + "x$00", pw, false}, @@ -60,3 +62,28 @@ func TestHashPasswordFormat(t *testing.T) { t.Fatal("two hashes of the same password share a salt") } } + +// TestSignInRejectsUnprefixedHash checks that a user record holding a bare +// SHA-256 hash (written only by unreleased development builds) cannot sign in, +// and that AdminSetUserPassword recovers the account. +func TestSignInRejectsUnprefixedHash(t *testing.T) { + m, _ := newClockMock(t) + ctx := context.Background() + pool := mustCreateEmailPool(t, m) + client := mustCreateClient(t, m, pool.ID, false, passwordFlows...) + confirmedUser(t, m, pool.ID, client.ClientID, "alice") + + key := userKey(pool.ID, "alice") + rec, _ := m.users.Get(key) + sum := sha256.Sum256([]byte(rec.PasswordSalt + testPassword)) + rec.PasswordHash = hex.EncodeToString(sum[:]) + m.users.Set(key, rec) + + _, err := m.InitiateAuth(ctx, passwordAuth(client.ClientID, "alice", testPassword)) + assertException(t, err, driver.ExNotAuthorized, "Incorrect username or password.") + + requireNoError(t, m.AdminSetUserPassword(ctx, pool.ID, "alice", testPassword, true), "AdminSetUserPassword") + + _, err = m.InitiateAuth(ctx, passwordAuth(client.ClientID, "alice", testPassword)) + requireNoError(t, err, "InitiateAuth after reset") +}