Skip to content

Bound client elevation duration to prevent overflow and permanent elevation - #1058

Open
piyush295 wants to merge 2 commits into
gotify:masterfrom
piyush295:fix/bound-elevation-duration
Open

piyush295 wants to merge 2 commits into
gotify:masterfrom
piyush295:fix/bound-elevation-duration

Conversation

@piyush295

Copy link
Copy Markdown

Fixes #1051.

Problem

ElevateRequest.DurationSeconds was only validated as non-zero (binding:"required"), and both elevation paths multiply it straight into a time.Duration:

  • api/client.go — POST /client/:id/elevate
  • api/oidc.go — the OIDC elevation callback

This allowed two problems (reproduced on v3.1.1):

  1. Elevation could be made effectively permanent — a single request with durationSeconds: 3153600000 set elevatedUntil a century into the future.
  2. Large values silently overflowed — time.Duration(DurationSeconds) * time.Second wraps for anything past ~292 years, so durationSeconds: 9223372036854775807 produced a past elevatedUntil. The request still returned 204, but the elevation was silently dropped.

Fix

Add a max bound (30 days) plus min=1 via the binding tag on both ElevateRequest.DurationSeconds and the OIDC pendingElevation.DurationSeconds, so out-of-range durations are rejected with 400 instead of being silently mishandled. A MaxElevationDurationSeconds constant documents the ceiling and the reasoning.

30 days is a generous ceiling (the default login elevation is 1 hour) that still eliminates both the "permanent elevation" and the overflow cases.

Testing

  • Added regression tests Test_ElevateClient_expectBadRequestOnDurationAboveMax (max+1 → 400) and Test_ElevateClient_expectBadRequestOnOverflowDuration (MaxInt64 → 400), asserting ElevatedUntil stays nil.
  • go test ./api/ — all pass, including the existing elevate suite.
  • gofmt clean, go vet ./model/ ./api/ clean.

…vation

ElevateRequest.DurationSeconds was only validated as non-zero and both
elevation paths multiply it straight into a time.Duration:

- api/client.go  POST /client/:id/elevate
- api/oidc.go    the OIDC elevation callback

This allowed two problems (reproduced on v3.1.1):

1. Elevation could be made effectively permanent — a single request could
   set elevatedUntil a century into the future.
2. Very large values silently overflowed: time.Duration(DurationSeconds) *
   time.Second wraps past ~292 years, yielding a past elevatedUntil so the
   elevation was silently dropped while the request still returned 204.

Add a max bound (30 days) via the binding tag on both ElevateRequest and
the OIDC pendingElevation struct, plus a min of 1, so out-of-range
durations are rejected with 400 instead of being silently mishandled. A
MaxElevationDurationSeconds constant documents the ceiling.

Adds regression tests for the above-max and overflow cases.

Closes gotify#1051
@piyush295
piyush295 requested a review from a team as a code owner September 28, 2026 08:57

@eternal-flame-AD eternal-flame-AD left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, we have already handled the same concern in a separate private advisory. Our response was:

Thanks for the report. I'm not sure I understand the impact, or rather how an high elevation period has a meaningful effect.

When the attacker has an elevated session, they can just change the password, create new admin users. Which is much more intrusive than having an unlimited elevated session.

If there was a limit to the duration, the attacker still can extend the elevation period before it expires, so I don't think adding it makes much difference.

So I would just clamp excessive inputs to a sane number (like 100 years) to fix the overflow and not impose an arbitrary "security" limit. When we designed this feature we also considered use cases where people might need to opt out of this timer feature, and setting a practically inifinite expiration time is a necessary relief valve.

Per maintainer feedback (@eternal-flame-AD): a hard max is not a
meaningful security control (an elevated attacker can already change the
password / create admins, and could re-extend before expiry), and an
effectively-infinite elevation is an intentionally supported relief valve.

So instead of rejecting large durations with 400, clamp them: add
model.ElevationDuration(seconds) which converts to a time.Duration and
caps at ~100 years — large enough to remain a practical 'infinite', but
safely below the point where time.Duration overflows (int64 ns wrap past
~292 years) and would yield a past elevatedUntil. Both elevation paths now
use it; the min/max binding tags are removed.

Tests: model.TestElevationDuration (normal, zero/negative default, huge
clamp, ceiling) and Test_ElevateClient_clampsHugeDuration (MaxInt64 still
elevates with a future timestamp).

Signed-off-by: piyush295 <mr.piyush295@gmail.com>
@piyush295

Copy link
Copy Markdown
Author

Thanks @eternal-flame-AD, that makes sense — I've reworked it accordingly.

Instead of imposing a hard max / rejecting with 400, I now clamp the duration: added model.ElevationDuration(seconds) which converts to a time.Duration and caps at ~100 years. That keeps an effectively-infinite elevation available as the relief valve you described, while staying safely below the point where time.Duration overflows (int64 ns wraps past ~292 years) and would silently produce a past elevatedUntil. So the only behavioural change is that the overflow case now elevates far into the future instead of dropping to the past — no arbitrary security limit, and no 400s.

Both elevation paths (/client/:id/elevate and the OIDC callback) use the helper; the min/max binding tags are gone.

Tests: model.TestElevationDuration (normal / zero+negative default / huge clamp / ceiling) and Test_ElevateClient_clampsHugeDuration (MaxInt64 still elevates with a future timestamp). Full go test ./api/ ./model/ passes.

@lbellows lbellows left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reworking this. Two problems with ElevationDuration:

1. "Cancel elevation" now elevates for an hour. The UI sends durationSeconds: -1 to cancel (ui/src/client/ElevateClientDialog.tsx:22). On master that sets elevatedUntil to the past. With this PR every seconds <= 0 becomes DefaultElevationDuration, so ElevationDuration(-1) returns 1h: clicking Cancel grants a fresh hour of elevation while the UI says "Canceled client elevation". The "negative falls back to default" test case locks this in.

2. The overflow check misses wraps that land positive. It multiplies first and then checks d <= 0, but int64 wrap can come out positive:

  • 18446744074 → 290ms (elevation silently expires, the original bug)
  • 20023544073 → ~50 years instead of the 100-year cap

Clamping in seconds before multiplying fixes both, and also covers the large negative wrap @somaz94 raised on #1051 (-9223372037 elevating until 2319):

const maxElevationSeconds = 100 * 365 * 24 * 60 * 60

func ElevationDuration(seconds int) time.Duration {
	if seconds > maxElevationSeconds {
		seconds = maxElevationSeconds
	} else if seconds < -maxElevationSeconds {
		seconds = -maxElevationSeconds
	}
	return time.Duration(seconds) * time.Second
}

Tests worth adding: -1 (result in the past), 18446744074, -9223372037. Minor: the "just under ceiling" case actually passes exactly the ceiling.

@eternal-flame-AD

Copy link
Copy Markdown
Member

I agree with @lbellows except negative values should be clamped to -1 or use a hardcoded result (like 0).

Also no need to make this overly complicated, this should be a small change in the function itself. You can add a unit test ensuring the hardcoded max will work, but don't overthink this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Session elevation duration is unbounded, and large values silently overflow to a past timestamp

3 participants