Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 29 additions & 16 deletions features/vcr/vcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
87 changes: 80 additions & 7 deletions features/vcr/vcr_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -24,14 +25,43 @@ func TestRecordSetsSafeHeaders(t *testing.T) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"name":"<script>x</script>"}`))
},
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("<html><script>x</script></html>"))
},
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("<script>x</script>"))
},
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("<script>x</script>"))
},
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",
},
}

Expand All @@ -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 {
Expand All @@ -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":"<b>",`))
_, _ = w.Write([]byte(`"c":1}`))
})

resp := doRequest(t, v.Wrap(h, "azure"), http.MethodPut, "/x", "")

const want = `{"a":"<b>","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)
}
}
12 changes: 12 additions & 0 deletions internal/vtl/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions internal/vtl/int_range_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 3 additions & 2 deletions internal/vtl/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
22 changes: 7 additions & 15 deletions providers/aws/cognito/passwords.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,11 @@ const (
maxPasswordLen = 256
)

// Stored hashes look like "pbkdf2-sha256$<iterations>$<hex key>". 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$<iterations>$<hex key>". 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
Expand Down Expand Up @@ -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, "$")
Expand Down
31 changes: 29 additions & 2 deletions providers/aws/cognito/passwords_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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},
Expand Down Expand Up @@ -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")
}
Loading