Skip to content
2 changes: 1 addition & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ func (a *ClientAPI) ElevateClient(ctx *gin.Context) {
return
}

elevatedUntil := time.Now().Add(time.Duration(params.DurationSeconds) * time.Second)
elevatedUntil := model.ElevationTime(time.Now(), params.DurationSeconds)
if err := a.DB.UpdateClientElevatedUntil(client.ID, &elevatedUntil); err != nil {
ctx.AbortWithError(500, err)
return
Expand Down
42 changes: 42 additions & 0 deletions api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,48 @@ func (s *ClientSuite) Test_ElevateClient_expectBadRequestOnMissingDuration() {
assert.Nil(s.T(), client.ElevatedUntil)
}

func (s *ClientSuite) Test_ElevateClient_cancelSuccess() {
s.db.User(5).Client(8)

test.WithUser(s.ctx, 5)
elevatedUntil := time.Now().Add(1 * time.Hour)
err := s.db.UpdateClientElevatedUntil(8, &elevatedUntil)
assert.NoError(s.T(), err)
client, err := s.db.GetClientByID(8)
assert.NoError(s.T(), err)
assert.Equal(s.T(), elevatedUntil.Unix(), client.ElevatedUntil.Unix())

s.withElevateRequest(8, -1)

before := time.Now()
s.a.ElevateClient(s.ctx)

assert.Equal(s.T(), 204, s.ctx.Writer.Status())
client, err = s.db.GetClientByID(8)
assert.NoError(s.T(), err)
assert.True(s.T(), client.ElevatedUntil.Before(before))
}

func (s *ClientSuite) Test_ElevateClient_clampsHugeDuration() {
s.db.User(5).Client(8)

test.WithUser(s.ctx, 5)
// A value that would overflow time.Duration must be clamped to the ceiling
// (not rejected, and not wrapped to a past timestamp).
s.ctx.AddParam("id", "8")
s.withElevateBody(`{"durationSeconds":9223372036854775807}`)

before := time.Now()
s.a.ElevateClient(s.ctx)

assert.Equal(s.T(), 204, s.ctx.Writer.Status())
client, err := s.db.GetClientByID(8)
assert.NoError(s.T(), err)
assert.NotNil(s.T(), client.ElevatedUntil)
// Must be in the future (overflow would have produced a past time).
assert.True(s.T(), client.ElevatedUntil.After(before), "elevatedUntil should be in the future, got %v", client.ElevatedUntil)
}

func (s *ClientSuite) withFormData(formData string) {
s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData))
s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
Expand Down
2 changes: 1 addition & 1 deletion api/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ func (a *OIDCAPI) handleElevationCallback(w http.ResponseWriter, elevate *pendin
http.Error(w, "client not found", http.StatusNotFound)
return
}
elevatedUntil := time.Now().Add(time.Duration(elevate.DurationSeconds) * time.Second)
elevatedUntil := model.ElevationTime(time.Now(), elevate.DurationSeconds)
if err := a.DB.UpdateClientElevatedUntil(client.ID, &elevatedUntil); err != nil {
http.Error(w, fmt.Sprintf("failed to elevate session: %v", err), http.StatusInternalServerError)
return
Expand Down
10 changes: 10 additions & 0 deletions model/elevate.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@ type ElevateRequest struct {
}

var DefaultElevationDuration = time.Hour

const maxElevation = 100 * 365 * 24 * time.Hour

// ElevationTime calculates the time at which an elevation will expire.
func ElevationTime(base time.Time, deltaSeconds int) time.Time {
// Prevent int64 overflow by limiting to an arbitrary high number.
seconds := min(time.Duration(deltaSeconds), maxElevation/time.Second)
seconds = max(seconds, -1)
return base.Add(seconds * time.Second)
}
52 changes: 52 additions & 0 deletions model/elevate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package model

import (
"math"
"testing"
"time"
)

func TestElevationTime(t *testing.T) {
// Use runtime variables so arithmetic doesn't happen as a constant
// expression and overflow Go's constant checker on either arch.
maxInt := math.MaxInt
minInt := math.MinInt
maxElevationSeconds := int64(maxElevation / time.Second)

// On 32-bit math.MaxInt (~2.1B) is below maxElevationSeconds (~3.15B),
// so no clamp triggers and the expected output is the requested
// duration; on 64-bit math.MaxInt is far above, so the expected output
// is the cap.
var expectedForHuge time.Duration
if int64(maxInt) < maxElevationSeconds {
expectedForHuge = time.Duration(maxInt) * time.Second
} else {
expectedForHuge = maxElevation
}

baseTime := time.Now()

cases := []struct {
name string
seconds int
want time.Time
}{
{"normal", 900, baseTime.Add(900 * time.Second)},
{"zero stays zero", 0, baseTime},
// Negative values collapse to a one-second past duration so the
// client can cancel an elevation (UI sends -1); they must NOT grant
// a fresh positive elevation.
{"negative value cancels", -1, baseTime.Add(-time.Second)},
{"large negative cancels", minInt, baseTime.Add(-time.Second)},
// A huge positive request is clamped on 64-bit; on 32-bit no clamp
// is reachable since math.MaxInt < maxElevationSeconds there.
{"huge clamped to max", maxInt, baseTime.Add(expectedForHuge)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ElevationTime(baseTime, c.seconds); got != c.want {
t.Fatalf("ElevationTime(%d) = %v, want %v", c.seconds, got, c.want)
}
})
}
}
Loading