HackerOne · Report #4071670
HackerOne Code: a live password reset token reaches Datadog RUM, and the same session records the account it unlocks
How it was found
Asset URL
Summary
Opening a password reset link on app.pullrequest.com sends the complete, unused, 64 character
reset token to Datadog, before the reset form is submitted. The link carries the token as a bare
query string key, and the Datadog RUM browser SDK on that page records view.url verbatim.
The SDK is initialised with no beforeSend callback. The key is absent from the configuration
entirely, so no URL redaction is performed on any route. sampleRate is 100, so this is not a
sampled subset. Every reset performed by every user of this application is recorded.
Three things make this more than a secret written to a log.
- The token is live when it is transmitted. It is sent on page load, before the user submits the form, and the form renders normally at that point.
- The same RUM session records the account the token unlocks. The authenticated route places the
account handle in the URL path, and RUM groups both views under one
session_id. The reset endpoint requires token and email together, and that pairing is the thing this session supplies. - The defect is the RUM configuration, not the reset page. Any secret placed in any URL in this application is recorded the same way.
This is the same issue as #4000185, on a sink that fix did not cover
Report #4000185, "HackerOne Code sends live password-reset tokens to Segment in automatic page events", was filed against this same asset, triaged, fixed, and resolved at Low with a $200 bounty. It was disclosed on 24 September 2026. The mechanism there was the same one described here: opening the reset link put the live token into automatic page telemetry before the form was submitted.
The retest on that report records the fix that shipped:
The client now registers a Segment source middleware (
analytics.addSourceMiddleware) that runs on every event before send and scrubscontext.pageandproperties: on the/password-resetand/forgot-passwordpaths it strips the query string and fragment fromurlandreferrer(via a URL rewrite) and blanks thesearchfield.
analytics.addSourceMiddleware is a Segment API. It runs inside Segment's own event pipeline and
has no effect on any other SDK. Datadog RUM is a separate library with a separate transport and its
own redaction hook, beforeSend, which is not set. The fix is therefore sink specific by
construction, and path specific on top of that.
The retest on #4000185 establishes that the Segment half is closed, and my own observation is
consistent with it rather than a second confirmation of it: over the observation window this page
issued no request to api.segment.io, none to Intercom and none to Sentry, although analytics.js
is still loaded and initialised. Of the telemetry recipients this page loads, Datadog RUM was the
only one that received a request while the token was in the URL.
The Segment sink was closed. The same token still reaches Datadog.
Affected components
| Item | Value |
|---|---|
| Page | app.pullrequest.com/password-reset |
| Reset endpoint | POST /api/v1/password-reset, fields token, email, password |
| Telemetry SDK | Datadog RUM browser SDK 4.14.0 |
| RUM application | ████████ |
| Intake | rum.browser-intake-datadoghq.com/api/v2/rum |
| Tags | service:pullrequest, env:production |
The configuration
What the SDK does when it is not told otherwise
The RUM browser SDK records the full current URL into view.url on every view, query string
included. A beforeSend callback is the supported place to redact that field. Datadog's default
retention for RUM events is 30 days.
What this application configured
> Object.keys(window.DD_RUM.getInitConfiguration())
["applicationId","clientToken","site","service","env","version","sampleRate","trackInteractions"]
> typeof window.DD_RUM.getInitConfiguration().beforeSend
"undefined"
> window.DD_RUM.getInitConfiguration().sampleRate
100
beforeSend is not set to a permissive value. It is not present. There is no redaction path on any
route, and nothing is sampled out.
The baseline this is measured against
The reset link issued to an account I control had this shape. The token is the bare key, with no value, ahead of the campaign parameters.
https://app.pullrequest.com/password-reset?<64 hex characters>&utm_swu=547&utm_source=account%20action%20emails&utm_campaign=Reset%20Password%20...&utm_medium=email
At the moment of every reading below, the form had not been submitted. The page rendered the Email Address, New Password and Confirm New Password fields normally, so the token was live and unused.
What came back, measured in the browser
1. The token is inside the field the SDK serialises
> const TOKEN = '<the 64 hex characters from the reset link>';
> window.DD_RUM.getInternalContext().view.url.includes(TOKEN)
true
> window.DD_RUM.getInternalContext().view.url.length
231
2. The SDK is transmitting, and the intake is accepting
POST https://rum.browser-intake-datadoghq.com/api/v2/rum
?ddsource=browser&ddtags=sdk_version:4.14.0,env:production,service:pullrequest...
&batch_time=1790747851585
-> HTTP 202
POST https://rum.browser-intake-datadoghq.com/api/v2/rum
...&batch_time=1790747881586
-> HTTP 202
Batches repeat at roughly 30 second intervals while the view is active. Wrapping
navigator.sendBeacon on an authenticated route captured a body of 863 bytes containing "url"
fields.
3. The same session records the account the token unlocks
view 1 /password-reset?<live token> session_id ████████...
view 2 /dash/scans/1/<account handle> session_id ████████...
The reset endpoint requires token and email. A token alone is not directly usable, and that is
the mitigation this finding removes. View 2 puts the account handle in the URL path and RUM files it
under the same session as view 1. A filter on one session_id returns the credential and the account
together.
4. The capture is global, not a property of the reset page
Demonstrated on an authenticated route with a dummy marker. No secret was used and no request was issued. The original URL was restored immediately.
> history.pushState({}, '', location.pathname + '?demo_param=' + MARK);
rum_tracked_the_spa_route_change true
RUM_CAPTURED_ARBITRARY_QUERY_VALUE true
session_id unchanged
5. Tokens are single use, and I measured that rather than assuming it
Submitting a consumed token to POST /api/v1/password-reset returns HTTP 422. The page still
renders for a consumed token, because the form is client side, and the rejection happens on
submission. A token was confirmed still valid roughly 15 minutes after issue. That is a measured
lower bound and not the time to live, which I did not determine.
6. What this cannot do
This does not read a session or bearer credential. Authenticated API requests on this application
carry their bearer token in a request header, not in a URL, so nothing in that path is recorded by
this mechanism. I did not find an authorisation code in a captured URL either. The OAuth callback at
/oauth/callback answers with a server side 302, so no page script runs on the code bearing URL.
This report is the reset token, the account correlation, and the configuration that produces both.
Scope of this report
I hold no access to the Datadog organisation for service:pullrequest, so I can show the client side
only. Everything above was read from the live page. The stored event is confirmable by the program in
one query, in Datadog, RUM, Explorer:
service:pullrequest @view.url:*password-reset*
Each event returned carries a real user's reset token in view.url. The same query sizes how many
tokens are already held and which of them are still inside their validity window.
Severity
Who the attacker is here, before anything else
The reader of this data is whoever holds RUM read access on the Datadog organisation, together with any API key or integration scoped to read RUM, and anyone who later obtains either. That population is normally much wider than the set of people entitled to handle account recovery material for customers, and it is not governed by the application's own authorisation model.
The token is a bearer credential. Whoever reads it can use it with no further authentication, no password, and no second factor. Nobody here is recovering their own account.
The band
The published table for app.pullrequest.com is $200 at Low and $1,000 at Medium. #4000185 was
resolved at Low, and its reporter rated it Low for a reason that applies here too: exploitation
needs access to the telemetry during the token's validity window. I am not going to argue that
point away.
I am asking for Medium on one difference, and I will state plainly what is not a difference.
Not a difference. #4000185 also correlated the token to an account, through an anonymousId
already tied to the account email and user ID. That correlation was at least as strong as the
session_id correlation in this report. Coverage is not a difference either; Segment page events
fired for every user, as RUM does here.
The difference. #4000185 was one analytics.page() call that failed to strip a secret, and it
was fixed by scrubbing two named paths inside one vendor's pipeline. This report is not a call
site. beforeSend is absent from the RUM initialisation entirely, so there is no redaction path on
any route, for any parameter, for any sink that SDK feeds. Section 4 above shows an arbitrary query
value on an unrelated authenticated route being recorded the same way. The reset token is the
instance I can demonstrate with a real credential. It is not the boundary of the defect.
That is why the remediation here is different in kind from the last one. A path-scoped scrub of the
RUM view.url would close this report and leave the cause in place for the next parameter that
carries a secret, exactly as the Segment middleware closed #4000185 and left this one open.
CVSS
This program's form offers CVSS 4.0 or a manual rating, so I have set the report to Medium
manually. I did not submit a 4.0 vector because that scale turns heavily on Privileges Required,
and scoring PR:High for "the reader needs Datadog access" measures the wrong thing here. The
population holding that access is not governed by this application's authorisation model, and the
token needs nothing further to use once read.
For reference, CVSS 3.1 AV:N/AC:H/PR:H/UI:R/S:U/C:H/I:H/A:N scores 5.9, Medium. I offer either
number as support rather than as the argument. The argument is the three facts above.
Suggested fix
- Take the token out of the URL. Accept it in the POST body, or exchange it server side on first
load for a short lived cookie and redirect to a clean path. This closes RUM, analytics,
Referer, browser history and any CDN or proxy log in one change, rather than one sink at a time. One sink at a time is what left this open after the Segment fix. - Until then, set
beforeSendon the RUM SDK to strip query strings. Given that the field is absent rather than misconfigured, prefer stripping by default and allowing known safe parameters through. - Purge stored RUM events matching
@view.url:*password-reset*, and treat any token still inside its validity window as exposed. - Consider a shorter time to live on reset tokens. Single use invalidation is already implemented and verified working, so it does not need revisiting.
- Set an explicit restrictive
Referrer-Policyon credential bearing pages. Current browser defaults already limit cross originReferer, so this is hardening.
Steps To Reproduce
-
Request a password reset for an account you control on
app.pullrequest.com. -
Open the emailed link. Do not submit the form.
-
Observe POSTs to
rum.browser-intake-datadoghq.com/api/v2/rumwithservice:pullrequest, answered with HTTP 202. -
In the console, with
TOKENset to the 64 hex characters from the link:window.DD_RUM.getInternalContext().view.url.includes(TOKEN) // true typeof window.DD_RUM.getInitConfiguration().beforeSend // "undefined" window.DD_RUM.getInitConfiguration().sampleRate // 100 window.DD_RUM.getInternalContext().session_id // note this -
Complete the reset and sign in. Read
session_idagain on an authenticated page. It is unchanged, and that view'sview.urlcontains the account handle. -
Confirm server side with the RUM Explorer query in Scope of this report.
verify.js in the attached rum-token-poc.zip performs steps 4 and 5 and prints a table.
Supporting Material/References
rum-token-poc.zip
verify.js, console script for the checks above, reads SDK state only and sends nothingglobal-capture.js, shows URL capture on any route using a dummy markerreplay-token.sh, checks that a consumed token is refused, expected 422
rum-token-evidence.zip
01-rum-init-configuration.txt, full SDK configuration,beforeSendabsent,sampleRate10002-token-in-view-url.txt, the live token inside the serialised field03-intake-transmission.txt, accepted intake POSTs and a captured beacon body04-session-correlation.txt, onesession_idacross the token view and the authenticated view05-capture-is-global.txt, capture on an unrelated route with a dummy marker06-server-side-verification.txt, the single query that confirms the chain in Datadog07-token-single-use.txt, the 422 on replay and what it bounds08-prior-report-and-fix-scope.txt, why the fix for #4000185 cannot reach the Datadog sink
Testing notes. All readings were taken on accounts controlled by me. No customer data and no third
party program data was accessed. Header X-Bug-Bounty: HackerOne-sujitjaunjal was sent on direct HTTP
testing traffic. Accounts used: sujitjaunjal and ████████. The account used on
app.pullrequest.com was ████████.
Impact
Anyone able to read the RUM stream for service:pullrequest obtains a live account recovery token
together with the identity of the account it belongs to, for any user of HackerOne Code who resets a
password and does not immediately complete the flow. The token is a bearer credential and needs no
further authentication to use.
The realistic case is the abandoned reset. A user who opens the link and does not finish, because they were interrupted, opened it on the wrong device, or remembered the password, leaves a valid token in third party telemetry until it expires. Nothing marks it as spent, and the user has no signal that the token was ever exposed.
Underneath the reset flow, the configuration itself is the finding. beforeSend is absent and
sampleRate is 100, so every URL in this application, on every route, is recorded into a third party
platform that is not built to hold secrets and that carries its own access control, retention and
export surface.