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")
+}