Skip to content

fix(session): expire a session that cannot prove it is still within bounds - #33

Merged
Fiona2016 merged 3 commits into
publishfrom
fix/session-expiry-fail-closed
Sep 1, 2026
Merged

fix(session): expire a session that cannot prove it is still within bounds#33
Fiona2016 merged 3 commits into
publishfrom
fix/session-expiry-fail-closed

Conversation

@Fiona2016

@Fiona2016 Fiona2016 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

isActiveSession gave a session the benefit of the doubt whenever its stamps were missing, because both comparisons short-circuit to true on undefined:

(created === undefined || dateNow() - Number(created) < SESSION_TIME_OUT_DELAY) &&
(expire  === undefined || dateNow() < Number(expire))

A stored state holding an id but no created therefore cleared the hard timeout, and one without expire cleared the inactivity deadline. created was only written in the step that generates a new id, so such a state could never acquire one: the session stayed usable indefinitely.

This only shows up on pages that are left open. trackVisibility extends the session once a minute for as long as the page is visible, with no user interaction needed, so the inactivity deadline never fires there and the hard cap is the only bound left. When that bound is short-circuited, nothing ends the session at all.

Fix

Both bounds collapse into a single effective deadline, min(expire, created + SESSION_TIME_OUT_DELAY), and a state that cannot produce one is expired rather than assumed young. This also caps a deadline written by a clock that was running ahead, which would otherwise hold a session open until that error had elapsed for real.

Taken from upstream DataDog#4531 ("fix session lifetime bugs for long-lived pages and multi-tab scenarios"). Their SessionManager rewrite makes the commit itself unmergeable here, so only the rule is carried over.

created is stamped on every started session, tracked or not. A not-tracked session used to carry no creation date, so the cap had nothing to count from while the visibility timer pushed its deadline forward once a minute — the same escape, left open for the sampled-out half. The practical effect was that a user sampled out of a long-lived page never got to re-run the sampling draw, because every renewal read the stored rum=0 back.

The id-less fallback in getExpireDate remains, for mixed builds rather than for sampling: a bundle predating the stamp keeps writing state without one, and expiring those on sight would have old and new builds take turns ending each other's sessions.

expandOrRenewSessionState now stamps the deadline before returning. processSessionStoreOperations decides whether the session is expired immediately after it runs and only stamped it afterwards, so a session renewed there would have been judged with no deadline at all and no session could ever have established itself. Upstream does the same inside its own expandOrRenew.

The legacy bundle is brought in line

packages/rum-legacy carried its own copy of the same fail-open check, and its header promises "same cookie name, same serialisation, same expiration rules". Without this, that promise would have become false: the modern bundle stops trusting a state with no creation date while the legacy one would adopt it and refresh its deadline forever, so any page loading the legacy build could resurrect exactly the sessions this change ends. Its rule now mirrors the modern one, and it stamps what it adopts.

Also removed: the pre-2020 cookie format migration

Mirrors upstream DataDog#4198, same three files. The migration read the legacy _dd / _dd_r / _dd_l cookies and folded them into today's single _dd_s. Nothing has written those cookies since 2020 (upstream DataDog#342), this SDK line never did (_dd_s is already the store key in v0.0.1), and tryOldCookiesMigration is not exported from any package entry.

It also mattered for the fix: it was the only place that set session.id without stamping created, so it was the one code path that could produce the state this PR stops trusting.

Behaviour change worth knowing

A page that stays visible now reaches the 4h cap and its session expires there. Renewal is driven by real user interaction (trackActivity listens for click / touchstart / keydown / scroll); the visibility timer can only extend a session that is still alive. So an unattended always-on screen expires at 4h and does not start a new session until someone interacts with it. This matches upstream's behaviour after the same fix.

Worth calling out in the release notes: for a page that had been holding one session open indefinitely, the symptom of the fix — data stopping four hours in — reads like a regression unless it is explained.

Test changes

Specs that asserted the old contract are updated: a stored session carrying no stamps used to be treated as live, and fixtures that stood in for an existing session now carry the stamps a real one has. Upstream flipped the same assertions in their change.

One existing test is repaired rather than moved. should renew an existing timed out session set a fixture with no expire, so it would have passed for the mundane reason of having no deadline at all, and the cap it names was never exercised.

Three regression tests added: a not-tracked session must be stamped; the legacy build must not adopt a session with no creation date, and must stamp a sampled-out one it adopts. Each was run against the unfixed source first — all three fail without the change.

Verification

  • yarn test:unit2867 passing, 0 failing
  • yarn format and yarn lint clean
  • New unit tests cover: a session with an id but no creation date, stamps dated in the future, the creation cap winning over the sliding deadline, getExpireDate returning nothing when a required stamp is absent, and the legacy bundle's handling of all of the above

thomas-lebeau and others added 3 commits August 31, 2026 03:09
Mirrors upstream datadog/browser-sdk c2bba2d ("Remove old cookie
migration", 2026-02-18), same three files, same 121 deletions.

The migration read the legacy `_dd` / `_dd_r` / `_dd_l` cookies and folded
them into today's single `_dd_s`. Nothing has written those cookies since
2020-04-07 (upstream DataDog#342), and this SDK line never did: `_dd_s` is already
the store key in v0.0.1. tryOldCookiesMigration is not exported from any
package entry, so nothing outside the cookie strategy can reach it.

It also mattered for a second reason. It was the only place that set
`session.id` without stamping `created`, and since `created` is only written
when a new id is generated, a session that came through it could never
acquire one -- which is exactly the state the next commit stops trusting.
…ounds

A session was kept alive whenever its stamps were missing, because both
comparisons short-circuited to `true` on `undefined`:

    (created === undefined || dateNow() - Number(created) < SESSION_TIME_OUT_DELAY) &&
    (expire  === undefined || dateNow() < Number(expire))

So a stored state holding an id but no `created` cleared the timeout, and one
without `expire` cleared the inactivity deadline. `created` is only written
in the step that generates a new id, so such a state could never acquire one
and the session went on indefinitely -- surviving idle gaps of hours on a
page that was never closed, well past both bounds.

Both bounds now collapse into one effective deadline, min(expire, created +
SESSION_TIME_OUT_DELAY), and a state that cannot produce one is expired
rather than assumed young. This also caps a deadline written by a clock that
was running ahead, which would otherwise hold a session open until that
error had elapsed for real.

Taken from upstream DataDog/browser-sdk 5257b52 ("fix session lifetime
bugs for long-lived pages and multi-tab scenarios", DataDog#4531). Their
SessionManager rewrite makes the commit itself unmergeable here, so only the
rule is carried over, with one deliberate difference: upstream stamps every
started session, while here only tracked sessions get an id and a `created`,
so a session with no id is still judged on `expire` alone.

expandOrRenewSessionState now stamps the deadline before returning.
processSessionStoreOperations decides whether the session is expired
immediately after it runs and only stamped it afterwards, so a session
renewed there would be judged with no deadline at all and no session could
ever establish itself.

Specs that asserted the old contract are updated: a stored session with no
stamps used to be treated as live, and fixtures that stood in for an existing
session now carry the stamps a real one has.
Review follow-up. The first pass closed the escape for tracked sessions and
left it open for the other half.

`created` is now stamped on every started session, not only on the one that
mints an id. A not-tracked session carried no creation date, so the cap had
nothing to count from while the visibility timer pushed its deadline forward
once a minute: on a page that stays open it never expired. The practical
effect is that a user sampled out of a long-lived page never got to re-run
the sampling draw, because every renewal read the stored `rum=0` back.

The id-less fallback in getExpireDate stays. Its job is not this case but
mixed builds: a bundle that predates the stamp keeps writing state without
one, and expiring those on sight would have old and new builds take turns
ending each other's sessions.

The legacy bundle carried its own copy of the same fail-open check, and its
own header promises "same cookie name, same serialisation, same expiration
rules". After the first pass that promise was false: the modern bundle
stopped trusting a state with no creation date while the legacy one would
adopt it and refresh its deadline forever, so any page loading the legacy
build could resurrect exactly the sessions this change ends. Its rule now
mirrors the modern one, and it stamps what it adopts.

Tests: a not-tracked session must be stamped; the legacy build must not
adopt a session with no creation date, and must stamp a sampled-out one it
adopts. Each was checked against the unfixed source first — all three fail
without the change.

One existing test is repaired rather than moved: "should renew an existing
timed out session" set a fixture with no `expire`, so after the first pass it
was passing for the mundane reason of having no deadline at all, and the cap
it names was never exercised.
@Fiona2016

Copy link
Copy Markdown
Collaborator Author

Review round: two gaps closed

The first pass shut the escape for tracked sessions and left it open for the other half.

1. Not-tracked sessions had no cap. created was only stamped in the step that mints an id, so a sampled-out session carried none — the cap had nothing to count from while the visibility timer pushed its deadline forward once a minute. On a page that stays open it never expired, which also meant a user sampled out of such a page never got to re-run the sampling draw: every renewal read the stored rum=0 back. created is now stamped on every started session.

The id-less fallback in getExpireDate stays, but for a different reason than the one first written down: not to spare sampled-out sessions, but to survive mixed builds. A bundle predating the stamp keeps writing state without one, and expiring those on sight would have old and new builds take turns ending each other's sessions.

2. The legacy bundle carried its own copy of the same fail-open check. Its header promises "same cookie name, same serialisation, same expiration rules" — after the first pass that promise was false: the modern bundle stopped trusting a state with no creation date while the legacy one would adopt it and refresh its deadline forever. Any page loading the legacy build could resurrect exactly the sessions this PR ends. Its rule now mirrors the modern one, and it stamps what it adopts.

3. One existing test was repaired. should renew an existing timed out session set a fixture with no expire, so after the first pass it passed for the mundane reason of having no deadline at all — the cap it names was never exercised.

Three regression tests added. Each was run against the unfixed source first; all three fail without the change.

yarn test:unit2867 passing, 0 failing. yarn format and yarn lint clean.

@Fiona2016
Fiona2016 merged commit 4f1b6fe into publish Sep 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants