console.debug('[StravaHeatmapExt] Credentials fetched:', Boolean(credentials)), and the hardcoded JWT-shaped fixture in expireCredentials() was replaced with the obviously-fake string EXPIRED-NOT-A-REAL-TOKEN. The general rule is to log identifiers and booleans, never secret material, and to keep secret-shaped strings out of source entirely.
VULNERABILITY_AT_A_GLANCE:
Vulnerability: Sensitive credential exposure through debug logging (plus a secret-shaped hardcoded fixture)
CWE: CWE-532 (Insertion of Sensitive Information into Log File), related CWE-312, CWE-798
Language: JavaScript (WebExtension / MV3 background service worker)
Risk: A live Strava session cookie set (JWT + CloudFront signed-cookie triple) is written to the extension console, enabling full account/session takeover by anyone who can read that console
Root cause: console.debug in requestCredentials() received the entire credential string returned by fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES) instead of a non-sensitive summary
Fix: Log Boolean(credentials) instead of the credential value, and replace the realistic JWT in expireCredentials() with a clearly non-credential placeholder
FAQ:
Q: What is credential leakage through console logging?
A: It is a class of sensitive data exposure where secrets — tokens, cookies, API keys, passwords — are passed to a logging function such as console.debug, console.log, or a server-side logger. The secret is then persisted somewhere that has weaker access controls than the credential store it came from: a DevTools console buffer, a log file, a log aggregation service, or a crash report. It is tracked as CWE-532.
Q: How do you prevent credential leakage through console logging in JavaScript?
A: Never pass a secret-bearing value directly to a logger. Log presence or shape instead: Boolean(token), token.length, a hash prefix, or an opaque identifier. Wrap credential objects in a class with a toJSON()/Symbol.for('nodejs.util.inspect.custom') that returns '[REDACTED]', keep a central redact() helper, and add a lint rule or CI grep that fails when known secret variable names appear inside console.* calls.
Q: What CWE is credential leakage through console logging?
A: CWE-532, "Insertion of Sensitive Information into Log File." Because the credentials also sit unencrypted in browser.storage.local, CWE-312 ("Cleartext Storage of Sensitive Information") applies, and the JWT-shaped literal that was removed from expireCredentials() touches CWE-798 ("Use of Hard-coded Credentials").
Q: Is stripping logs in production builds enough to prevent this?
A: No. Build-time log stripping helps but fails in several common cases: debug builds shipped to beta channels, extensions loaded unpacked during development on machines that also hold real sessions, console.debug calls behind dynamic flags that the stripper cannot statically remove, and log statements added later by contributors who assume the stripper handles everything. The safe default is to make the logged value itself non-sensitive, as this fix does.
Q: Can static analysis detect credential leakage through console logging?
A: Yes, quite reliably. Taint analysis can trace a value from a credential source — here fetchCookies(...) and browser.storage.local.get('credentials') — to a logging sink like console.debug, and pattern rules can flag any console.* call whose arguments include identifiers matching credential, token, cookie, secret, or apiKey. Entropy and format heuristics also catch JWT-shaped string literals such as the eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... fixture that was removed.
TAGS: hardcoded-secrets, javascript, browser-extension, logging, jwt, cwe-532
CONTENT:
Answer Summary
This is a sensitive-information-in-logs vulnerability (CWE-532) in the JavaScript background script of a browser extension: requestCredentials() in src/background/credentials.js passed the raw Strava cookie string — _strava_idcf JWT, CloudFront-Policy, CloudFront-Signature, CloudFront-Key-Pair-Id — to console.debug, persisting live session credentials into the extension's console buffer. The fix logs only presence, not content: console.debug('[StravaHeatmapExt] Credentials fetched:', Boolean(credentials)), and the hardcoded JWT-shaped fixture in expireCredentials() was replaced with the obviously-fake string EXPIRED-NOT-A-REAL-TOKEN. The general rule is to log identifiers and booleans, never secret material, and to keep secret-shaped strings out of source entirely.
Vulnerability at a Glance
| Field | Value |
|---|---|
| ID | V-001 |
| Severity | Critical / High |
| File | src/background/credentials.js:35 |
| Type | Sensitive credential exposure via debug logging |
| CWE | CWE-532, CWE-312, CWE-798 |
| Language | JavaScript (WebExtension background script) |
| Sink | console.debug |
| Fix | Log Boolean(credentials) instead of the credential string |
Introduction
The src/background/credentials.js file in this Strava heatmap browser extension has one job: pull the Strava authentication cookies out of the browser, cache them in browser.storage.local, and hand them to the tile-fetching code so that private heatmap tiles render. That is a legitimately sensitive job — the credential blob it handles is the complete set of cookies that Strava's CDN uses to authorize a session:
_strava_idcf— a signed JWT containingathleteId,iat, andexpCloudFront-Policy— a base64 policy naming the allowed resource and expiryCloudFront-Signature— the signature over that policyCloudFront-Key-Pair-Id— the key pair that signed it
Together, those four values are the session. Present them to *.strava.com and the CDN treats you as the athlete.
The flaw was not in how the extension fetched or used those values. It was in how it talked about them. Two console.debug statements inside requestCredentials() printed the entire credential string verbatim, and a third problem sat in expireCredentials(), where a fully-formed, realistic JWT had been hardcoded as a test fixture. This post walks through exactly what leaked, how it could be abused, and the minimal change that closed it.
The Vulnerability Explained
The vulnerable code
Here is the relevant slice of requestCredentials() before the fix:
export async function requestCredentials(skipValidation = false) {
let credentials = await fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES);
console.debug('[StravaHeatmapExt] Credentials fetched:', credentials); // ← leak #1
const { credentials: storedCredentials } = await browser.storage.local.get(
'credentials'
);
// ...
// Update local storage only if credentials changed
if (credentials !== storedCredentials) {
await browser.storage.local.set({ credentials });
console.debug('[StravaHeatmapExt] Stored credentials updated', credentials); // ← leak #2
}
fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES) returns a serialized cookie string. That string goes straight into console.debug as the second argument. In a WebExtension background service worker, that output lands in the extension's own DevTools console — reachable from chrome://extensions → Inspect views: service worker, or about:debugging on Firefox — and it stays in the console's ring buffer for the lifetime of that inspection session.
The second leak is arguably worse: it fires on every credential change, meaning the console accumulates a history of freshly-minted, maximally-valid session tokens over time.
Why "you need DevTools access" is not a defense
The obvious objection is that reading an extension's background console requires local access to the machine. Three reasons that objection is thin:
- The threat model in the original finding is real. An attacker with brief physical access to an unlocked machine — a shared desk, a conference room, a laptop left open at a café — presses F12, opens the extension's service worker console, and copies a live token set. No malware, no privilege escalation, no persistence needed. They walk away with an offline-usable credential.
- Console output is not as private as it looks. Anything that reads the console reads the secret. Crash/telemetry SDKs that hook
console.*, screen-share and pair-programming sessions, screenshots pasted into bug reports, and "here's my console output" GitHub issues have all leaked production secrets before. A developer debugging a tile-loading bug is precisely the person likely to paste this console into a public issue tracker. - The credential outlives the moment. Unlike a value in a variable, a logged value is persisted. It survives the function returning, the page navigating, and in many environments it is flushed to disk.
Concrete attack scenario
- Alice installs the extension. It calls
requestCredentials(), which fetches her_strava_idcfJWT and CloudFront signed-cookie triple and logs the full string. - Alice hits a rendering bug. She opens
chrome://extensions, inspects the service worker, sees[StravaHeatmapExt] Credentials fetched: _strava_idcf=eyJ0eXAi..., and pastes the console output into a GitHub issue to be helpful. - Bob reads the issue. He copies the four cookie values into
curlor a browser cookie editor scoped to*.strava.com. - Until
expin the JWT andDateLessThanin the CloudFront policy pass, Bob can fetch Alice's private heatmap tiles and any other CDN resource that policy covers — revealing her home address, commute route, and training patterns from the tile coordinates alone.
For a fitness app, GPS-derived location history is among the most sensitive data a user has. That is why a "just a debug log" finding is rated critical here.
The second problem: a JWT-shaped literal in source
expireCredentials() — a helper that deliberately installs stale cookies so the extension's re-auth path can be exercised — contained this:
const expiredCredentials =
'_strava_idcf=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDAwMDAwMDAsImlhdCI6MTYwMDAwMDAwMCwiYXRobGV0ZUlkIjo5OTk5OTk5OSwidGltZXN0YW1wIjoxNjAwMDAwMDAwfQ.invalid; CloudFront-Key-Pair-Id=INVALID; CloudFront-Policy=eyJTdGF0ZW1lbnQiOiBbeyJSZXNvdXJjZSI6Imh0dHBzOi8vKi5zdHJhdmEuY29tLyoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2MDAwMDAwMDB9fX1dfQ==; CloudFront-Signature=InvalidSignature';
That JWT is not a real credential — the signature is literally the word invalid and the expiry is 2020 — but it is shaped exactly like one. That has real costs:
- It defeats secret scanning. Every JWT-format detector fires on it. Teams that get one false positive here learn to ignore the detector, and the next alert — a real one — gets ignored too.
- It teaches the wrong pattern. The next contributor who needs a fixture copies this style, and now there is a plausible chance the copied value is derived from a real capture.
- It is not self-documenting. Nothing about
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...tells a reviewer "this is a deliberately fake test value." You have to base64-decode it to find out.
The Fix
The patch is deliberately surgical: three lines, no behavioral change to the auth flow.
Change 1 & 2 — log presence, not content
Before:
let credentials = await fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES);
console.debug('[StravaHeatmapExt] Credentials fetched:', credentials);
After:
let credentials = await fetchCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES);
console.debug('[StravaHeatmapExt] Credentials fetched:', Boolean(credentials));
Before:
if (credentials !== storedCredentials) {
await browser.storage.local.set({ credentials });
console.debug('[StravaHeatmapExt] Stored credentials updated', credentials);
}
After:
if (credentials !== storedCredentials) {
await browser.storage.local.set({ credentials });
console.debug('[StravaHeatmapExt] Stored credentials updated', Boolean(credentials));
}
Boolean(credentials) collapses the entire cookie string to true or false. This is the key insight about the fix: the debug statements retained 100% of their diagnostic value. What a developer actually needs to know from Credentials fetched: is "did the cookie fetch succeed or come back empty?" — a yes/no question. The token bytes were never the useful part of the log line; they were incidental payload that happened to be in scope.
Note also that the second log line's condition (credentials !== storedCredentials) already tells you the credentials changed. The value added nothing there either.
Change 3 — de-fang the test fixture
Before:
const expiredCredentials =
'_strava_idcf=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDAwMDAwMDAsImlhdCI6MTYwMDAwMDAwMCwiYXRobGV0ZUlkIjo5OTk5OTk5OSwidGltZXN0YW1wIjoxNjAwMDAwMDAwfQ.invalid; CloudFront-Key-Pair-Id=INVALID; CloudFront-Policy=eyJTdGF0ZW1lbnQiOiBbeyJSZXNvdXJjZSI6Imh0dHBzOi8vKi5zdHJhdmEuY29tLyoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2MDAwMDAwMDB9fX1dfQ==; CloudFront-Signature=InvalidSignature';
After:
const expiredCredentials =
'_strava_idcf=EXPIRED-NOT-A-REAL-TOKEN; CloudFront-Key-Pair-Id=INVALID; CloudFront-Policy=EXPIRED-NOT-A-REAL-POLICY; CloudFront-Signature=InvalidSignature';
The cookie names are preserved, so clearCookies(STRAVA_COOKIE_URL, STRAVA_COOKIE_NAMES) and the downstream browser.storage.local.set still exercise the same code path — the function's purpose (make the extension think it has stale credentials) is unaffected. What changed is that the values now announce themselves as fake. EXPIRED-NOT-A-REAL-TOKEN is unambiguous to a human reviewer and inert to a secret scanner.
What the fix does not address
Worth stating plainly: the credentials still live unencrypted in browser.storage.local, which is CWE-312. In a browser extension that is a genuinely hard problem — storage.local has no OS keychain binding, and any key you'd derive to encrypt it would have to be stored... in storage.local. The realistic mitigations are: minimize retention (delete the cached copy as soon as the tile request completes), rely on the CloudFront policy's short DateLessThan expiry so a stolen blob has a narrow window, and never widen exposure beyond storage.local — which is exactly what removing the console logs accomplishes. This PR closed the avoidable exposure surface.
Key Takeaways
- **
console.debug('...', credentials)inrequestCredentials()wrote a complete Str