HexBrain

HackerOne · Report #4071670

HackerOne Code: a live password reset token reaches Datadog RUM, and the same session records the account it unlocks

Validstatus
lowseverity
$200bounty
45upvotes
8 Oct 2026disclosed

How it was found

Asset URL

https://app.pullrequest.com

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.

  1. 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.
  2. 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.
  3. 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 scrubs context.page and properties: on the /password-reset and /forgot-password paths it strips the query string and fragment from url and referrer (via a URL rewrite) and blanks the search field.

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

ItemValue
Pageapp.pullrequest.com/password-reset
Reset endpointPOST /api/v1/password-reset, fields token, email, password
Telemetry SDKDatadog RUM browser SDK 4.14.0
RUM application████████
Intakerum.browser-intake-datadoghq.com/api/v2/rum
Tagsservice: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

  1. 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.
  2. Until then, set beforeSend on 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.
  3. Purge stored RUM events matching @view.url:*password-reset*, and treat any token still inside its validity window as exposed.
  4. Consider a shorter time to live on reset tokens. Single use invalidation is already implemented and verified working, so it does not need revisiting.
  5. Set an explicit restrictive Referrer-Policy on credential bearing pages. Current browser defaults already limit cross origin Referer, so this is hardening.

Steps To Reproduce

  1. Request a password reset for an account you control on app.pullrequest.com.

  2. Open the emailed link. Do not submit the form.

  3. Observe POSTs to rum.browser-intake-datadoghq.com/api/v2/rum with service:pullrequest, answered with HTTP 202.

  4. In the console, with TOKEN set 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
    
  5. Complete the reset and sign in. Read session_id again on an authenticated page. It is unchanged, and that view's view.url contains the account handle.

  6. 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 nothing
  • global-capture.js, shows URL capture on any route using a dummy marker
  • replay-token.sh, checks that a consumed token is refused, expected 422

rum-token-evidence.zip

  • 01-rum-init-configuration.txt, full SDK configuration, beforeSend absent, sampleRate 100
  • 02-token-in-view-url.txt, the live token inside the serialised field
  • 03-intake-transmission.txt, accepted intake POSTs and a captured beacon body
  • 04-session-correlation.txt, one session_id across the token view and the authenticated view
  • 05-capture-is-global.txt, capture on an unrelated route with a dummy marker
  • 06-server-side-verification.txt, the single query that confirms the chain in Datadog
  • 07-token-single-use.txt, the 422 on replay and what it bounds
  • 08-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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original