Skip to content
Book a test

home / research

// writeup · recon · javascript · bug-bounty · methodology

JavaScript Is a Recon Goldmine, Part 2: Advanced Techniques

Nov 4, 2026 · @sdx · 12 min read

Part 1 covered the core workflow: collect every file, read source maps, extract routes, triage secrets and read the logic. Grep and a crawler get you most of the way on a simple app. On a large modern app, with hundreds of chunks, generated API clients and routes built at runtime, they miss a lot.

This part covers the techniques we use on those apps at SecureDX. As before, every example uses example.com, and everything here is for apps you are authorized to test.

1. Start with the framework’s own route list

Before parsing anything, identify the framework. Many publish a full list of the app’s pages to every visitor, because the browser needs it to navigate without a full page reload.

Framework How to recognise it Where its routes are listed
Next.js /_next/static/ in script URLs, a __NEXT_DATA__ script tag /_next/static/<buildId>/_buildManifest.js lists every page; the build ID is in __NEXT_DATA__
Nuxt /_nuxt/ in script URLs, window.__NUXT__ The router config inside the main /_nuxt/ bundle
Angular ng-version attribute on the root element The Routes array in main.js, including canActivate guards
React Router Route objects with path and element or Component The route config in the main bundle or a routes chunk
SvelteKit /_app/immutable/ in script URLs Node and route lists in the app’s start file
# Next.js: get the build ID, then the manifest
BUILD=$(curl -s https://app.example.com/ | grep -oE '"buildId":"[^"]+"' | cut -d'"' -f4)
curl -s "https://app.example.com/_next/static/$BUILD/_buildManifest.js" \
  | grep -oE '"/[^"]*"' | tr -d '"' | sort -u > pages.txt

The page list shows the app’s structure in one request: /admin/billing, /internal/flags, /support/impersonate. Visit each one logged in as a normal user. A page that loads is a lead; what decides whether it’s a bug is whether the API calls behind it return data.

2. Recover every chunk from the webpack runtime

Part 1 found lazy-loaded chunks by searching for file names. On large apps that misses many, because webpack doesn’t store full names. Its small runtime file holds a map of chunk IDs to content hashes, and builds each file name from that map only when the chunk is needed. Rebuild the names yourself and you get every chunk the app could ever load.

# 1. Learn the naming pattern from a chunk the page already loaded
grep -i chunk js_live.txt | head -3
#    e.g. https://app.example.com/static/js/482.9c1f3e2a.chunk.js  ->  <id>.<hash>.chunk.js

# 2. Pull the id:hash map out of the runtime and build every URL
grep -ohE '\{([0-9]+:"[0-9a-f]{8,20}",?)+\}' js/*.js | head -1 \
  | grep -oE '[0-9]+:"[0-9a-f]+"' | tr -d '"' \
  | awk -F: '{print "https://app.example.com/static/js/" $1 "." $2 ".chunk.js"}' > chunk_urls.txt

# 3. Add the ones that exist to your collection
httpx -l chunk_urls.txt -silent -mc 200 | anew js_live.txt

The pattern differs by setup. Create React App uses static/js/<id>.<hash>.chunk.js; Next.js uses /_next/static/chunks/ with its own runtime file named webpack-<hash>.js; Vite apps use assets/<name>-<hash>.js and list their imports in the entry file. Always confirm the pattern from one real chunk before building the list.

On large apps this step often finds many more files than the crawler did. Most of the new ones are screens a normal user never opens: admin panels, support tools, partner portals and settings for paid plans.

3. Parse, don’t grep

Grep sees text. It can’t tell api.get("/users/" + id) is a GET request, it misses paths split across variables, and it matches strings that are never used as URLs. A JavaScript parser reads the code the way the browser does, so you can ask it a precise question: list every call that looks like an HTTP request, with its method and path.

This script uses Babel’s parser, which handles minified code and recovers from syntax it doesn’t fully understand.

// extract-calls.mjs
// Setup: npm i @babel/parser @babel/traverse
// Usage: node extract-calls.mjs js/*.js > calls.tsv
import fs from "node:fs";
import { parse } from "@babel/parser";
import traverseModule from "@babel/traverse";

const traverse = traverseModule.default ?? traverseModule; // works across Babel versions
const VERBS = ["get", "post", "put", "patch", "delete"];

// Turn a URL expression into text, marking unknown parts as {var}
function text(n) {
  if (!n) return null;
  if (n.type === "StringLiteral") return n.value;
  if (n.type === "TemplateLiteral")
    return n.quasis.map((q, i) => q.value.cooked + (i < n.expressions.length ? "{var}" : "")).join("");
  if (n.type === "BinaryExpression" && n.operator === "+")
    return (text(n.left) ?? "{var}") + (text(n.right) ?? "{var}");
  return null;
}

for (const file of process.argv.slice(2)) {
  const code = fs.readFileSync(file, "utf8");
  let ast;
  try {
    ast = parse(code, { sourceType: "unambiguous", errorRecovery: true, plugins: ["jsx", "typescript"] });
  } catch { continue; }

  traverse(ast, {
    CallExpression({ node }) {
      const { callee, arguments: args } = node;
      let method = null;

      // fetch(url, { method: "POST" })
      if (callee.type === "Identifier" && callee.name === "fetch") {
        const opts = args[1]?.type === "ObjectExpression" ? args[1].properties : [];
        const m = opts.find(p => (p.key?.name ?? p.key?.value) === "method");
        method = (text(m?.value) ?? "GET").toUpperCase();
      }

      // client.get(url), axios.post(url), r.Z.delete(url) in minified code
      if (callee.type === "MemberExpression" && VERBS.includes(callee.property?.name))
        method = callee.property.name.toUpperCase();

      const url = method && text(args[0]);
      if (url && url.includes("/")) console.log(`${method}\t${url}\t${file}`);
    },
  });
}

The output is a tab-separated list you can sort and de-duplicate:

GET     /api/v2/orders/{var}              js/7c1e2a.js
PUT     /api/v2/admin/users/{var}/role    js/91b4f0.js
DELETE  /api/v2/projects/{var}/members    js/91b4f0.js

The method matters. A list of paths tells you where to go; knowing which ones are PUT and DELETE tells you which ones change data, so you can check those access controls first. Extend the script for the app’s own HTTP wrapper once you spot it, for example a request({ url, method }) helper.

4. Record real traffic for each role

Static analysis shows what the code could call. Recording the app while you use it shows what it actually calls, with real parameters, headers and responses. Do it once for each role you have test accounts for: a normal user, a team admin, a read-only member.

// record.mjs
// Setup: npm i playwright
// Usage: node record.mjs user   (then log in and click through every screen)
import { chromium } from "playwright";
import fs from "node:fs";

const role = process.argv[2] ?? "user";
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({
  recordHar: { path: `${role}.har`, urlFilter: /example\.com/ },
});
const page = await context.newPage();
const seen = new Set();

page.on("request", (r) => {
  if (["xhr", "fetch"].includes(r.resourceType())) {
    const u = new URL(r.url());
    seen.add(`${r.method()}\t${u.pathname.replace(/\/\d+(?=\/|$)/g, "/{id}")}`);
  }
});

await page.goto("https://app.example.com/login");
await page.pause();               // opens the Playwright inspector: use the app, then press Resume
fs.writeFileSync(`${role}_calls.tsv`, [...seen].sort().join("\n") + "\n");
await context.close();            // writes the HAR file
await browser.close();

What you get for each role:

  • <role>_calls.tsv: every API call the UI made, with numeric IDs normalised to {id} so the lists compare cleanly.
  • <role>.har: the full requests and responses, which you can open in Burp or your browser’s DevTools.

Two things to look for straight away. First, responses that contain more than the screen shows, such as other users’ email addresses in a list that only displays names. Second, differences between roles: a call the admin’s session makes that the normal user’s never does is the first thing to replay with the normal user’s session.

The HAR files contain your session tokens. Keep them out of shared folders and delete them when the engagement ends.

5. Compare static and runtime

You now have two lists: every call the code contains (section 3) and every call the UI really makes (section 4). Comparing them shows exactly where to spend your time.

  1. static_onlyIn the code, never calledleast-tested routes → test first
  2. bothIn code and in usenormal attack surface
  3. runtime_onlyBuilt at runtimefind how the path is assembled
# Same format for both lists: METHOD<TAB>path, with IDs written as {x}
cut -f1,2 calls.tsv | sed -E 's/\{(var|id)\}/{x}/g' | sort -u > static.tsv
sed -E 's/\{(var|id)\}/{x}/g' user_calls.tsv | sort -u > runtime.tsv

comm -23 static.tsv runtime.tsv > static_only.tsv    # in the code, never called by the UI
comm -12 static.tsv runtime.tsv > both.tsv
comm -13 static.tsv runtime.tsv > runtime_only.tsv   # built at runtime

If paths don’t line up, check for a base URL the app adds at runtime (such as /api/v2) and strip it from one side before comparing.

static_only.tsv is the shortlist. These routes exist in the code and on the server, but nobody using the app normally ever reaches them, so they’ve had the least testing from users, QA and other hunters alike. Call each one with your normal user’s session and compare what comes back with what that user should be allowed to see.

6. Find the API contract hiding in the client

Many teams don’t write API calls by hand. They generate a client from a schema, and the generated code is a near-complete copy of the API’s contract: every operation, its parameters and their types. Recognise the pattern and you can rebuild the schema instead of guessing.

Pattern How to spot it What it gives you
OpenAPI-generated client Classes like UsersApi, AdminApi; methods such as getUserById, updateUserRole; operationId-style names Every operation, grouped by tag, with parameter names. Also try /openapi.json, /swagger.json and /v3/api-docs on the API host
tRPC Requests to /api/trpc/<router>.<procedure>, often batched with ?batch=1 Procedure names such as admin.listUsers or billing.refund; each is an endpoint
GraphQL documents Strings starting query, mutation, fragment Operation names, fields and arguments, even with introspection off
GraphQL persisted queries extensions with persistedQuery and a sha256Hash Operation names next to their hashes, which tells you what each hash runs
gRPC-Web or protobuf application/grpc-web requests; message classes with serializeBinary Service and method names, and field numbers for each message
# tRPC procedure names referenced in the bundle
grep -rhoE '[a-zA-Z]+\.[a-zA-Z]+\.(query|mutate|useQuery|useMutation)' js/ | sort -u

# GraphQL operations with their names
grep -rhoE '(query|mutation) [A-Za-z0-9_]+' js/ | sort -u

Names carry intent. Operations named adminDeleteUser, impersonate, exportAll or internalRecalculate are worth checking first, with the session of a user who should never be allowed to run them.

7. Code review hotspots

Most bugs found through JavaScript are server-side: an API that trusts the client. But some live in the front-end code itself. You won’t read a whole bundle line by line, so search for the patterns where client-side bugs cluster and review the code around each hit by hand.

Search for Why it deserves a look What to check in the code around it
innerHTML, outerHTML, dangerouslySetInnerHTML, v-html Inserts raw HTML into the page Whether the value can come from a URL, another user or an API field without sanitising
eval(, new Function(, setTimeout("...") with a string Runs a string as code Where the string comes from
addEventListener("message" Receives messages from other windows Whether the handler checks event.origin before acting
location.href =, window.open(, location.assign( Sends the user somewhere Whether the target comes from a URL parameter without an allow-list
localStorage, sessionStorage Stores data in the browser Whether tokens or personal data are kept there
postMessage( with "*" Sends data to any listening window What data is sent

A hit is a question, not a finding. Most uses are safe because the value is a constant or is sanitised first. Trace the value back to where it comes from; only a path from input an attacker controls to one of these patterns is worth testing further, on your own accounts.

Source maps (Part 1, step 2) make this review far easier, because you read the original code with real names instead of minified variables.

8. Third-party scripts and version diffs

Third-party scripts. Every analytics tag, chat widget and A/B testing script runs with the same access to the page as the app’s own code. List them, because each one is a dependency the company may not have reviewed.

# Every external script origin the app loads, from your recorded HAR
jq -r '.log.entries[].request.url' user.har | grep -iE '\.js(\?|$)' \
  | awk -F/ '{print $3}' | grep -v 'example\.com$' | sort | uniq -c | sort -rn

Then check the page’s Content-Security-Policy header and whether <script> tags for external files carry an integrity attribute. Missing controls are usually low severity alone, but they tell you how much a single compromised vendor could affect.

Version diffs. Part 1 alerted on new routes. A semantic diff goes further: run the extraction script from section 3 on the old and new bundle, and compare the two call lists.

node extract-calls.mjs old/*.js | cut -f1,2 | sort -u > calls_old.tsv
node extract-calls.mjs new/*.js | cut -f1,2 | sort -u > calls_new.tsv

comm -13 calls_old.tsv calls_new.tsv    # calls added in the new build
comm -23 calls_old.tsv calls_new.tsv    # calls removed from the UI: is the API still live?

Removed calls are as interesting as added ones. When a feature is dropped from the front end, the backend route often keeps running, and nobody tests it again.

For developers: assume the whole bundle is read

Everything in this post works with public files and a normal account. Part 1’s checklist still applies; these add to it.

  • Treat the route manifest and chunk map as public documentation. Never rely on a page or route being unknown. Every API route enforces its own access check on the server.
  • Serve admin and internal chunks only to authorised users, from a separate build or behind authentication, so the chunk map in the public runtime doesn’t list them.
  • Keep generated API clients scoped. Generate the public front end’s client from the public part of the schema, not the full internal one.
  • Retire routes on the server when a feature leaves the UI. Add an API inventory check to your release process.
  • Return only the fields a screen needs. Responses that include more than the UI shows are found within minutes of recording traffic.
  • Review the patterns in section 7 in code review, and add lint rules that flag them.
  • Set a Content-Security-Policy and use integrity on third-party scripts, and keep a list of every vendor script and who owns it.

Next: Part 3: Automating the Pipeline turns these steps into a daily job with a route database and a ranked triage queue.


SecureDX reviews JavaScript this way on every web app engagement, alongside manual API testing with real role accounts. If you want your app reviewed, get in touch at securedx.in/contact. Hunters who work like this can request an SDX Society invite at securedx.in/society.

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Keep it on topic and don't post secrets or other people's data.